diff options
| author | Adam Malczewski <[email protected]> | 2026-06-28 15:26:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-28 15:26:40 +0900 |
| commit | ed3425cb8ed57862f954505831be38a2588729bd (patch) | |
| tree | 518e613106fbe1c4dd1a0bbf786be4bae3cc1227 /src | |
| parent | 061f44e0efecb9cf9fa817875517390d09655104 (diff) | |
| parent | 9f2710f7686b0521c2503446437b3a45d42f6d66 (diff) | |
| download | dispatch-web-ed3425cb8ed57862f954505831be38a2588729bd.tar.gz dispatch-web-ed3425cb8ed57862f954505831be38a2588729bd.zip | |
Merge branch 'feature/step-context-update' into predev
Diffstat (limited to 'src')
| -rw-r--r-- | src/core/metrics/format.test.ts | 8 | ||||
| -rw-r--r-- | src/core/metrics/format.ts | 20 | ||||
| -rw-r--r-- | src/core/metrics/reducer.test.ts | 247 | ||||
| -rw-r--r-- | src/core/metrics/reducer.ts | 92 | ||||
| -rw-r--r-- | src/features/chat/store.svelte.ts | 9 | ||||
| -rw-r--r-- | src/features/chat/ui/Composer.svelte | 9 |
6 files changed, 364 insertions, 21 deletions
diff --git a/src/core/metrics/format.test.ts b/src/core/metrics/format.test.ts index c7c4fbb..97170d0 100644 --- a/src/core/metrics/format.test.ts +++ b/src/core/metrics/format.test.ts @@ -351,8 +351,14 @@ describe("computeContextUsage", () => { expect(u.percent).toBeCloseTo(3.4102, 4); }); - it("treats unknown contextSize as current 0", () => { + it("treats unknown contextSize as current null (never 0)", () => { const u = computeContextUsage(undefined, 1_000_000); + expect(u.current).toBeNull(); + expect(u.percent).toBeNull(); + }); + + it("an explicit 0 context size is a real reported value (current 0)", () => { + const u = computeContextUsage(0, 1_000_000); expect(u.current).toBe(0); expect(u.percent).toBe(0); }); diff --git a/src/core/metrics/format.ts b/src/core/metrics/format.ts index 56e74e4..894bd54 100644 --- a/src/core/metrics/format.ts +++ b/src/core/metrics/format.ts @@ -45,14 +45,17 @@ export function formatCompactTokens(n: number): string { /** * Context-window occupancy: the current size against a max window limit. * - * `current` is the latest turn's context size (0 when unknown); `max` is the - * model's window limit (or `null` when unknown). `percent` is - * `current / max * 100` clamped to [0, 100], UNROUNDED (the UI picks the - * precision) — so a few-thousand-token context against a 1,000,000 window still - * reads non-zero. `percent` is `null` when `max` is unknown (no bar/denominator). + * `current` is the latest turn's context size, or `null` when unknown (no + * per-step usage reported yet) — NEVER coerced to `0`, so a consumer cannot + * silently render "0 tokens / 1M"; it must branch on `current === null` and show + * a placeholder instead. `max` is the model's window limit (or `null` when + * unknown). `percent` is `current / max * 100` clamped to [0, 100], UNROUNDED + * (the UI picks the precision) — so a few-thousand-token context against a + * 1,000,000 window still reads non-zero. `percent` is `null` when `current` OR + * `max` is unknown (no bar/denominator). */ export interface ContextUsage { - readonly current: number; + readonly current: number | null; readonly max: number | null; readonly percent: number | null; } @@ -61,9 +64,10 @@ export function computeContextUsage( contextSize: number | undefined, contextLimit: number | null | undefined, ): ContextUsage { - const current = contextSize ?? 0; + const current = contextSize ?? null; const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null; - const percent = max === null ? null : Math.max(0, Math.min(100, (current / max) * 100)); + const percent = + current === null || max === null ? null : Math.max(0, Math.min(100, (current / max) * 100)); return { current, max, percent }; } diff --git a/src/core/metrics/reducer.test.ts b/src/core/metrics/reducer.test.ts index 7d0a270..581a8b7 100644 --- a/src/core/metrics/reducer.test.ts +++ b/src/core/metrics/reducer.test.ts @@ -439,4 +439,251 @@ describe("contextSize / selectCurrentContextSize", () => { ]); expect(selectCurrentContextSize(s)).toBe(222); }); + + it("in-flight turn updates context size after the first step completes", () => { + // Before the requirement: an in-flight turn had total=null so its step usage + // was ignored until `done`. Now the latest step's input+output is used. + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + + // Still generating (no done) — context = step 1 input+output = 5200. + expect(selectCurrentContextSize(s)).toBe(5200); + }); + + it("in-flight turn updates progressively as each step reports usage", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + expect(selectCurrentContextSize(s)).toBe(5200); + + // Step 2 reports usage mid-stream (before its step-complete): each step's + // input already includes all prior context, so the last step's input+output + // is the current occupancy. + s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2")); + expect(selectCurrentContextSize(s)).toBe(5350); + + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + expect(selectCurrentContextSize(s)).toBe(5350); + }); + + it("in-flight context size is the latest step with usage, NOT the aggregate sum", () => { + // Mirrors the finalized-turn test: contextSize is the FINAL step's + // input+output, not the sum across steps (which would overcount a + // multi-step turn because every step re-prefills the growing prompt). + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + // Aggregate would be 300+130=430; the latest step is 200+80=280. + expect(selectCurrentContextSize(s)).toBe(280); + }); + + it("in-flight turn with a step-complete but no usage falls back to older turn", () => { + // step-complete before usage → the step has no usage yet, so the in-flight + // turn exposes no context size and the display falls back to the prior + // finalized turn's value (never 0). + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 })); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1", { genTotalMs: 500 })); + + expect(selectCurrentContextSize(s)).toBe(700); + }); + + it("in-flight turn with no steps/usage returns undefined (falls back)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 })); + // t2 just started — no usage, no complete step — omitted entirely. + s = foldMetricsEvent(s, { type: "turn-start", conversationId: "c1", turnId: "t2" }); + expect(selectCurrentContextSize(s)).toBe(700); + + // t2's first step reports usage → the display jumps to t2's live value. + s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + expect(selectCurrentContextSize(s)).toBe(810); + }); + + it("done finalizes the in-flight progressive value with the authoritative contextSize", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + expect(selectCurrentContextSize(s)).toBe(5200); + + s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + expect(selectCurrentContextSize(s)).toBe(5350); + + // done stamps the authoritative contextSize (the final step's input+output). + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 5350 })); + expect(selectCurrentContextSize(s)).toBe(5350); + }); + + it("in-flight context size excludes cache tokens (they are a subset of inputTokens)", () => { + // cacheReadTokens / cacheWriteTokens are portions of inputTokens already + // counted — adding them would double-count. Only input+output is occupancy. + let s = initialMetricsState(); + s = foldMetricsEvent(s, { + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: "s1" as StepId, + usage: { + inputTokens: 5000, + outputTokens: 200, + cacheReadTokens: 4000, + cacheWriteTokens: 1000, + }, + }); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + // 5000+200=5200, NOT 9200 (with cacheRead) or 10200 (with both). + expect(selectCurrentContextSize(s)).toBe(5200); + }); + + it("multiple in-flight turns: the newest turn's live value wins", () => { + let s = initialMetricsState(); + // t1 (older) in-flight with one completed step → 5200. + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + // t2 (newer, seen later → last in liveOrder) in-flight → 8000. + s = foldMetricsEvent(s, usageEvent("t2", 7800, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + expect(selectCurrentContextSize(s)).toBe(8000); + }); + + it("out-of-order step IDs: usage for step 2 before step 1's step-complete still scans newest-first", () => { + // stepOrder is FIRST-SEEN: s1 (its usage arrived first), then s2. So s2 is + // the newest step regardless of when each step's step-complete arrives. + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2")); + // Neither step complete yet → the turn is omitted (no complete step), so the + // display can't update until the first step completes. + expect(selectCurrentContextSize(s)).toBeUndefined(); + + // s1 completes AFTER s2's usage was reported. The turn is now visible; the + // newest-first scan picks s2 (the later step), not s1 (the just-completed one). + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + expect(selectCurrentContextSize(s)).toBe(5350); + + // s2 completes — still s2, unchanged. + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + expect(selectCurrentContextSize(s)).toBe(5350); + }); + + it("done turn without contextSize falls back to an older turn (even with step usage)", () => { + // Contract lock-in: a done turn's step usage is NOT consulted for the + // context display — only its authoritative total.contextSize is. When that + // is absent, the display falls back to the next older finalized turn rather + // than synthesizing a value from the step usage. + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 })); + // t2 done WITH step usage but NO done.contextSize (edge case: the done event + // omitted contextSize despite per-step usage). + s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + s = foldMetricsEvent(s, doneEvent("t2")); + expect(selectCurrentContextSize(s)).toBe(700); + }); + + it("in-flight context size skips a step with unsafe usage (NaN / negative)", () => { + // A corrupt provider report must never reach the status bar. The newest + // step with invalid counters is skipped, falling back to the prior valid one. + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + // s2 reports NaN input (e.g. a non-numeric provider field coerced). + s = foldMetricsEvent(s, { + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: "s2" as StepId, + usage: { inputTokens: Number.NaN, outputTokens: 150 }, + }); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + // s2 skipped (NaN) → falls back to s1's 5200, NOT NaN. + expect(selectCurrentContextSize(s)).toBe(5200); + + // Negative tokens are likewise skipped. + s = foldMetricsEvent(s, { + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: "s3" as StepId, + usage: { inputTokens: -10, outputTokens: 5 }, + }); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s3")); + expect(selectCurrentContextSize(s)).toBe(5200); + }); +}); + +describe("applyDurableMetrics pruning", () => { + it("prunes a live turn once durable data covers it (no unbounded growth)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 150 })); + expect(s.live.has("t1")).toBe(true); + expect(s.liveOrder).toContain("t1"); + + s = applyDurableMetrics(s, [ + { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [{ stepId: "s1" as StepId, usage: { inputTokens: 100, outputTokens: 50 } }], + contextSize: 150, + }, + ]); + // The live copy is gone; the durable (authoritative) entry replaces it. + expect(s.live.has("t1")).toBe(false); + expect(s.liveOrder).not.toContain("t1"); + expect(s.durable.has("t1")).toBe(true); + // The display still reads the durable value atomically (no gap). + expect(selectCurrentContextSize(s)).toBe(150); + }); + + it("prunes only the turns present in the durable batch (leaves other live turns)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 150 })); + // t2 still in flight — must NOT be pruned when only t1 seals. + s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 100, outputTokens: 50 }, steps: [], contextSize: 150 }, + ]); + expect(s.live.has("t1")).toBe(false); + expect(s.live.has("t2")).toBe(true); + expect(s.liveOrder).toEqual(["t2"]); + // The newest (in-flight) turn's live value still wins. + expect(selectCurrentContextSize(s)).toBe(810); + }); + + it("is a no-op when no incoming turn is live (no live mutation)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + const before = s; + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [] }, + ]); + // t1 was never live → the live map/order are unchanged (same reference). + expect(s.live).toBe(before.live); + expect(s.liveOrder).toBe(before.liveOrder); + // t1 (durable) is older; the in-flight t2 still wins. + expect(selectCurrentContextSize(s)).toBe(810); + }); + + it("durable wins over live for a shared turnId (pruned live no longer consulted)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 111 })); + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [], contextSize: 222 }, + ]); + // The live (111) copy is pruned; only durable (222) remains. + expect(s.live.has("t1")).toBe(false); + expect(selectCurrentContextSize(s)).toBe(222); + }); }); diff --git a/src/core/metrics/reducer.ts b/src/core/metrics/reducer.ts index bebef1d..39fc5ee 100644 --- a/src/core/metrics/reducer.ts +++ b/src/core/metrics/reducer.ts @@ -68,6 +68,51 @@ function liveTurnToMetrics(lt: LiveTurn): TurnMetrics { return base; } +/** + * A step's contribution to the live context size: `inputTokens + outputTokens`, + * or `undefined` when the step has no usage yet OR its counters are not safe to + * sum (non-finite / negative — defensive: a corrupt provider report must never + * reach the status bar as NaN/Infinity). Cache tokens are deliberately NOT + * included: `cacheReadTokens` / `cacheWriteTokens` are a SUBSET of + * `inputTokens`, so adding them would double-count. + */ +function stepContextSize(usage: Usage | undefined): number | undefined { + if (usage === undefined) return undefined; + const { inputTokens, outputTokens } = usage; + if (!Number.isFinite(inputTokens) || !Number.isFinite(outputTokens)) return undefined; + if (inputTokens < 0 || outputTokens < 0) return undefined; + return inputTokens + outputTokens; +} + +/** + * The context size an IN-FLIGHT (not-done) turn occupies right now — for + * progressive display DURING a turn (before it seals), so the indicator updates + * after each step instead of waiting for `done`. + * + * CONTRACT: only call this on a turn whose `done` event has NOT arrived (the + * caller, `selectCurrentContextSize`, reaches it solely for entries with + * `total === null`, i.e. `lt.done === false`). Finalized turns use their + * authoritative `contextSize` instead; `doneContextSize` is read on the + * `total` path, never here. + * + * Returns the most recent step WITH USABLE USAGE's `inputTokens + outputTokens` + * (scanning newest → oldest by first-seen step order): each step's input + * already includes all prior context (the prompt is re-prefilled every step), so + * the last step's input+output is the true occupancy — the same definition + * `TurnDoneEvent.contextSize` stamps at turn end. A just-reported step's usage + * wins immediately, even mid-stream. Steps with no usage or unsafe usage are + * skipped, falling back to the next older usable step. `undefined` when no step + * has reported usable usage yet. + */ +function liveTurnContextSize(lt: LiveTurn): number | undefined { + for (let i = lt.stepOrder.length - 1; i >= 0; i--) { + const step = lt.stepMap.get(lt.stepOrder[i] ?? ""); + const ctx = stepContextSize(step?.usage); + if (ctx !== undefined) return ctx; + } + return undefined; +} + function ensureLiveTurn(state: MetricsState, turnId: string): [MetricsState, LiveTurn] { const existing = state.live.get(turnId); if (existing !== undefined) return [state, existing]; @@ -180,6 +225,12 @@ export function foldMetricsEvent(state: MetricsState, event: AgentEvent): Metric /** * Store durable (sealed) metrics from the backend. These win over live data * for any shared `turnId`. + * + * Once durable (authoritative) data covers a turn, its live (in-memory) copy + * is REDUNDANT and is pruned from `state.live` / `liveOrder` so the live map + * doesn't grow unbounded over a long conversation. There is no display gap: + * the durable entry replaces the live one atomically in the same fold, and + * `selectOrderedTurnMetrics` / `selectCurrentContextSize` read durable for it. */ export function applyDurableMetrics( state: MetricsState, @@ -187,14 +238,27 @@ export function applyDurableMetrics( ): MetricsState { const newDurable = new Map(state.durable); const newDurableOrder = [...state.durableOrder]; + const prunedIds = new Set<string>(); for (const turn of turns) { if (!newDurable.has(turn.turnId)) { newDurableOrder.push(turn.turnId); } newDurable.set(turn.turnId, turn); + if (state.live.has(turn.turnId)) prunedIds.add(turn.turnId); } + + if (prunedIds.size === 0) { + return { ...state, durable: newDurable, durableOrder: newDurableOrder }; + } + + const newLive = new Map(state.live); + for (const id of prunedIds) newLive.delete(id); + const newLiveOrder = state.liveOrder.filter((id) => !prunedIds.has(id)); + return { ...state, + live: newLive, + liveOrder: newLiveOrder, durable: newDurable, durableOrder: newDurableOrder, }; @@ -247,17 +311,35 @@ export function selectOrderedTurnMetrics(state: MetricsState): readonly TurnMetr * Select the conversation's CURRENT context size — the tokens it occupies right * now. Per the wire contract a client reads the LATEST turn's `contextSize`; we * scan the merged ordered turns NEWEST → OLDEST and return the first DEFINED - * `contextSize` (a finalized turn whose provider reported per-step usage). + * value. + * + * For a FINALIZED turn (`done` event or durable data) we use its authoritative + * `contextSize`. For an IN-FLIGHT (not-done) turn we compute it PROGRESSIVELY + * from the most recent step WITH USAGE — its `inputTokens + outputTokens` is the + * current occupancy (mirroring `TurnDoneEvent.contextSize`'s definition) — so + * the indicator updates after each step completes instead of waiting for the + * turn to seal. An in-flight turn with no step usage yet is skipped, falling + * back to the next older finalized turn. * - * Returns `undefined` ("unknown") when no finalized turn carries a context size — - * the caller renders a placeholder, NEVER `0`. Durable (sealed) data wins over + * Returns `undefined` ("unknown") when no turn carries a context size — the + * caller renders a placeholder, NEVER `0`. Durable (sealed) data wins over * live for a shared `turnId` (it is the persisted, authoritative value). */ export function selectCurrentContextSize(state: MetricsState): number | undefined { const ordered = selectOrderedTurnMetrics(state); for (let i = ordered.length - 1; i >= 0; i--) { - const total = ordered[i]?.total; - if (total?.contextSize !== undefined) return total.contextSize; + const entry = ordered[i]; + if (entry === undefined) continue; + if (entry.total !== null) { + if (entry.total.contextSize !== undefined) return entry.total.contextSize; + continue; + } + // In-flight turn: progressive context size from the latest step with usage. + const lt = state.live.get(entry.turnId); + if (lt !== undefined) { + const live = liveTurnContextSize(lt); + if (live !== undefined) return live; + } } return undefined; } diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index 5278737..9911438 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -74,9 +74,12 @@ export interface ChatStore { readonly chunks: readonly RenderedChunk[]; readonly turnMetrics: readonly TurnMetricsEntry[]; /** - * The conversation's current context size (tokens occupied) — the latest - * finalized turn's `contextSize`, or `undefined` ("unknown") when none is - * known yet. Never `0` for the unknown case. + * The conversation's current context size (tokens occupied) — updated + * PROGRESSIVELY: during an in-flight turn, the most recent step's + * `inputTokens + outputTokens` (each step's input already includes all prior + * context); once the turn seals, its authoritative `contextSize`. `undefined` + * ("unknown") when no step has reported usage yet. Never `0` for the unknown + * case. */ readonly currentContextSize: number | undefined; /** diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte index afe1e3c..04c28cd 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -39,8 +39,10 @@ onQueue?: (text: string) => void; /** Stop the in-flight generation (`POST /conversations/:id/stop`). */ onStop?: () => void; - // Current context occupancy (latest turn's contextSize), or `undefined` - // when unknown — the status bar then shows "— tokens", never 0%. + // Current context occupancy — updated progressively during a turn (the + // latest step's input+output) and finalized to the turn's `contextSize` on + // seal, or `undefined` when unknown — the status bar then shows + // "— tokens", never 0%. contextSize?: number | undefined; /** Per-model context window (max tokens) from `GET /models` modelInfo. */ contextWindow?: number | undefined; @@ -66,7 +68,6 @@ const canSend = $derived(hasText || hasImages); const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW); const usage = $derived(computeContextUsage(contextSize, effectiveMax)); - const hasUsage = $derived(contextSize !== undefined); // One button, three modes: // - idle → "Send" (starts a turn via chat.send) @@ -395,7 +396,7 @@ {/if} <span class="shrink-0 whitespace-nowrap font-mono"> - {#if hasUsage} + {#if usage.current !== null} {formatCompactTokens(usage.current)}{#if usage.max !== null}<span class="text-base-content/40" > |
