You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/09/06 14:11:04 UTC
(skywalking-horizon-ui) branch main updated: feat: AI agent conversations — the AI Agents layer, a conversation list and a shareable conversation page (#152)
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
The following commit(s) were added to refs/heads/main by this push:
new aeec709e feat: AI agent conversations — the AI Agents layer, a conversation list and a shareable conversation page (#152)
aeec709e is described below
commit aeec709e34a5898fe800d8ee7bb55ea44d68f6b1
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun Sep 6 22:10:59 2026 +0800
feat: AI agent conversations — the AI Agents layer, a conversation list and a shareable conversation page (#152)
OAP 11.1.0 stores the conversations the SkyWalking AI Sessionizer pushes
under the AI_AGENT layer and exposes a GraphQL list and a REST route that
reproduces one conversation as an asz.view document. Horizon now reads
both.
BFF: the list route (window up to 90 days; OAP's limit counts rounds, so
the reply states the limit it was folded from; sender, title and
conversation id as OAP conditions) and the document relay, held before
answering so the page can be told the document's size and summary ahead
of the bytes; a new ai-conversation:read verb, a capability probe, two
performance.aiConversation settings, and an optional windowMinutes on
the instance route.
Bundled AI Agents layer (ai_agent.json with seven locale overlays, the
aiConversations component flag) with one tab, Conversations: the runtime
from the layer header's picker, a range, a sender, a title text and a
conversation id, all applied by OAP; a row opens the conversation in a
new tab.
packages/conversation-view: a framework-free renderer of an asz.view
document on the --sw-* tokens, with a categorical series per theme for
the kinds of step, a viewport-culled flow timeline, transcript and
inspector, floating overview, help and problem panels, and a host shell
for embedding outside Horizon; the theme list moves to design-tokens so
the UI picker and themes.json read one list.
Conversation page at /ai-conversation/:id, outside the AppShell and
sized to the viewport: the wait shown in phases (OAP assembling, n of m
MB with rate and time left, parse, draw), the position kept in the URL,
theme and language pickers, every failure named, an OAP older than
11.1.0 named as such, and a document OAP could fold nothing of saying
so. 198 strings in eight locales, with the asz terms stream, child
stream and nested stream kept verbatim.
---
.licenserc.yaml | 1 +
CLAUDE.md | 1 +
apps/bff/CLAUDE.md | 16 +
.../bundled_templates/layers/ai_agent.i18n.de.json | 7 +
.../bundled_templates/layers/ai_agent.i18n.es.json | 7 +
.../bundled_templates/layers/ai_agent.i18n.fr.json | 7 +
.../bundled_templates/layers/ai_agent.i18n.ja.json | 7 +
.../bundled_templates/layers/ai_agent.i18n.ko.json | 7 +
.../bundled_templates/layers/ai_agent.i18n.pt.json | 7 +
.../layers/ai_agent.i18n.zh-CN.json | 7 +
.../bff/src/bundled_templates/layers/ai_agent.json | 19 +
apps/bff/src/client/ai-conversation.test.ts | 259 +++++
apps/bff/src/client/ai-conversation.ts | 233 +++++
apps/bff/src/config/builtin-roles.ts | 3 +
apps/bff/src/config/schema.ts | 12 +
apps/bff/src/http/query/ai-conversation.test.ts | 365 +++++++
apps/bff/src/http/query/ai-conversation.ts | 269 +++++
apps/bff/src/http/query/instance.test.ts | 35 +
apps/bff/src/http/query/instance.ts | 13 +-
.../src/http/query/menu-rows.regression.test.ts | 35 +-
apps/bff/src/http/query/menu.ts | 2 +
.../logic/ai-conversation/hold-document.test.ts | 67 ++
.../bff/src/logic/ai-conversation/hold-document.ts | 184 ++++
apps/bff/src/logic/layers/caps.ts | 1 +
apps/bff/src/logic/layers/loader.ts | 3 +
apps/bff/src/logic/oap/capabilities.test.ts | 5 +-
apps/bff/src/logic/oap/capabilities.ts | 5 +-
apps/bff/src/logic/templates/bundled-schema.ts | 1 +
apps/bff/src/rbac/route-policy.ts | 3 +
apps/bff/src/rbac/verbs.ts | 7 +
apps/bff/src/server.ts | 2 +
apps/ui/package.json | 1 +
apps/ui/src/api/client.ts | 7 +
apps/ui/src/api/scopes/ai-conversation.test.ts | 139 +++
apps/ui/src/api/scopes/ai-conversation.ts | 240 +++++
apps/ui/src/api/scopes/layer.test.ts | 8 +
apps/ui/src/api/scopes/layer.ts | 4 +
apps/ui/src/components/primitives/ProgressBar.vue | 58 ++
apps/ui/src/features/admin/roles/RolesView.vue | 4 +-
apps/ui/src/i18n/locales/de.json | 221 ++++-
apps/ui/src/i18n/locales/en.json | 221 ++++-
apps/ui/src/i18n/locales/es.json | 221 ++++-
apps/ui/src/i18n/locales/fr.json | 221 ++++-
apps/ui/src/i18n/locales/ja.json | 223 ++++-
apps/ui/src/i18n/locales/ko.json | 221 ++++-
apps/ui/src/i18n/locales/pt.json | 221 ++++-
apps/ui/src/i18n/locales/zh-CN.json | 221 ++++-
.../layer/ai-conversation/ConversationPageView.vue | 337 +++++++
.../layer/ai-conversation/ConversationViewHost.vue | 88 ++
.../ai-conversation/LayerConversationsView.vue | 351 +++++++
.../ai-conversation/conversationStrings.test.ts | 47 +
.../layer/ai-conversation/conversationStrings.ts | 230 +++++
.../layer/ai-conversation/useLayerConversations.ts | 76 ++
apps/ui/src/layer/useLayerInstances.ts | 17 +-
apps/ui/src/shell/SidebarLayerChildren.vue | 1 +
apps/ui/src/shell/layerFromTemplate.test.ts | 2 +-
apps/ui/src/shell/layerFromTemplate.ts | 1 +
apps/ui/src/shell/router/index.ts | 14 +
apps/ui/src/shell/useOapInfo.ts | 1 +
apps/ui/src/shell/useTopbarTimeContext.ts | 2 +
apps/ui/src/state/theme.test.ts | 9 +-
apps/ui/src/state/theme.ts | 8 +-
docs/access-control/rbac.md | 3 +-
docs/changelog/1.1.0.md | 10 +
docs/customization/layer-templates.md | 2 +-
docs/customization/menu-structure.md | 2 +-
docs/dashboards/ai_agent.md | 32 +
docs/menu.yml | 6 +
docs/operate/ai-agent-conversations.md | 86 ++
docs/setup/horizon-yaml.md | 13 +
docs/setup/rbac.md | 6 +-
packages/api-client/src/ai-conversation.ts | 121 +++
packages/api-client/src/index.ts | 15 +
packages/api-client/src/layer-menu.test.ts | 7 +-
packages/api-client/src/layer-menu.ts | 4 +-
packages/api-client/src/menu.ts | 3 +
packages/api-client/src/oap-info.ts | 4 +
packages/conversation-view/README.md | 62 ++
packages/conversation-view/package.json | 39 +
.../conversation-view/scripts/build-host-shell.mjs | 97 ++
packages/conversation-view/src/dom.ts | 62 ++
packages/conversation-view/src/format.ts | 64 ++
packages/conversation-view/src/index.ts | 580 +++++++++++
packages/conversation-view/src/model.ts | 327 ++++++
packages/conversation-view/src/strings.ts | 448 +++++++++
packages/conversation-view/src/styles.css | 382 +++++++
packages/conversation-view/src/types.ts | 237 +++++
packages/conversation-view/src/view/chrome.ts | 190 ++++
packages/conversation-view/src/view/context.ts | 86 ++
packages/conversation-view/src/view/inspector.ts | 385 ++++++++
packages/conversation-view/src/view/panels.ts | 132 +++
packages/conversation-view/src/view/timeline.ts | 562 +++++++++++
packages/conversation-view/src/view/transcript.ts | 260 +++++
packages/conversation-view/src/vocabulary.ts | 140 +++
.../test/fixtures/asz-view-example.json | 1043 ++++++++++++++++++++
packages/conversation-view/test/render.test.ts | 279 ++++++
packages/conversation-view/tsconfig.build.json | 13 +
packages/conversation-view/tsconfig.json | 9 +
packages/conversation-view/vite.config.ts | 42 +
packages/design-tokens/src/index.ts | 2 +
packages/design-tokens/src/themes.css | 45 +
packages/design-tokens/src/themes.ts | 45 +
packages/design-tokens/src/tokens.css | 12 +
pnpm-lock.yaml | 30 +
scripts/check-unused-deps.mjs | 2 +-
105 files changed, 10861 insertions(+), 33 deletions(-)
diff --git a/.licenserc.yaml b/.licenserc.yaml
index 0cbac16b..4b5350fc 100644
--- a/.licenserc.yaml
+++ b/.licenserc.yaml
@@ -82,3 +82,4 @@ dependency:
- apps/bff/package.json
- packages/api-client/package.json
- packages/design-tokens/package.json
+ - packages/conversation-view/package.json
diff --git a/CLAUDE.md b/CLAUDE.md
index bcc4aca3..9f46c13b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -96,6 +96,7 @@ English is the source of truth. Every UI string and every translatable template
- **`features/<feature>/`** — static feature pages. Each folder is fully self-contained: its views, its composables, its feature-specific components. Operate sub-features (`cluster/`, `inspect/`, `dsl/`, `live-debug/`) share their cross-cutting bits via `features/operate/_shared/` (Modal / MonacoYaml / MonacoDiff / RuleCard / DestructiveConfirm / grouping).
- **`layer/<tab>/`** — the per-layer drill-down stack. Top-level files are the shell (`LayerShell`, `LayerServiceSelector`) plus shared layer composables (`useLayerLanding`, `useLayerEndpoints`, `useLayerInstances`, `useSelectedService/Instance/Endpoint`). Each tab subfolder owns its view + composables + tab-specific components (e.g. `layer/traces/` contains `LayerTracesView.vue` + `NativeTraceWaterfall.vue` + `TracePopout.vue` + `useLayerTraces.ts`).
- **`render/`** — template-driven render. `render/overview/` and `render/layer-dashboard/` are generic renderers driven by JSON templates from the BFF; `render/widgets/` holds the reusable widget primitives (AlarmsWidget, MetricWidget, KpiTileWidget, …). New dashboards mean new templates, not new Vue files.
+ - **`packages/conversation-view/`** — the one renderer that lives outside `apps/ui`: a framework-free module that draws an `asz.view` document (an AI agent conversation) on the `--sw-*` tokens. Horizon wraps it in a Vue component; the AI Sessionizer builds the same package from a pinned Horizon commit and embeds it, so it must stay free of Vue, of `fetch`, and of any colour that is not a token. Its host-shell output (tokens + themes + fonts) exists for that other host; Horizon never im [...]
- **`components/{primitives,charts,icons}/`** — feature-agnostic shared building blocks. Stateless, no business logic. If a component starts needing feature data, move it INTO the feature; don't enrich the shared pile.
- **`monaco/`, `utils/`, `assets/`** — same shared-is-feature-agnostic rule: editor setup, formatters, stylesheets, icon SVGs. New helpers land here only if more than one feature needs them and they don't carry feature semantics.
diff --git a/apps/bff/CLAUDE.md b/apps/bff/CLAUDE.md
index 99d5d98e..998e1984 100644
--- a/apps/bff/CLAUDE.md
+++ b/apps/bff/CLAUDE.md
@@ -112,6 +112,9 @@ it empties it. A Cold toggle would blank the alarm list and the entity pickers.
| `log.ts`, `browser-errors.ts` — logs | `instance.ts` — the instance picker |
| `dashboard.ts`, `landing.ts`, `explore.ts` — metrics | `endpoint.ts` — the endpoint picker |
| `mqe-exec.ts` — one metric expression, run from the template editor | |
+| `ai-conversation.ts` — the conversation LIST (`recordsAIAgent` is a record group with a cold stage) | `ai-conversation.ts` — the conversation VIEW relay: no `Duration` to carry a flag on |
+
+The conversation VIEW relay holds the whole document before answering (gzip in memory, a fifth of the document's size): OAP streams it without a length, and the page shows "n of m MB" only because the BFF is the first to know `m`. It therefore asks OAP for gzip or plain bytes only — never the browser's own `Accept-Encoding` list — since it decodes what it holds to count it and to read the summary off the head (`logic/ai-conversation/hold-document.ts`). The decoded size and the summary's [...]
| `topology.ts`, `deployment.ts`, `instance-topology.ts`, `endpoint-dependency.ts`, `infra-3d-metrics.ts` — metrics | `events.ts` — events live in `records` |
| | `ebpf.ts` — network profiling |
| | `trace-tag.ts` — tag key/value autocomplete |
@@ -251,6 +254,19 @@ Things worth remembering:
alarmed/related entity, not the firing instance. Disambiguate composite keys
with timestamp before using `id` as a row key.
- **Paging is `pageNum`/`pageSize`, never a cursor**, and no total is returned.
+- **`listConversations.limit` counts ROUNDS, not conversations.** OAP reads the
+ newest `limit` rounds in the window and folds them to one row per
+ conversation, so one long conversation starves the short ones — measured: at
+ OAP's default of 1,000, an 865-round conversation hid a 19-round one. Send the
+ ceiling (10,000) and say so on screen; there is no truncation signal to relay.
+- **The conversation view is relayed with `node:http`, not `fetch`.** Node's
+ fetch decompresses gzip on the way in and keeps the `Content-Encoding` header,
+ so a relay built on it forwards a header that lies about the bytes. The raw
+ client hands the compressed bytes through unchanged (a 61 MB document as
+ 13 MB), which also means this one call is outside the wire log. The first
+ byte arrives only after OAP has folded the whole chain (8.5 s on the largest
+ real conversation), so it runs under `performance.aiConversation.viewTimeoutMs`,
+ never `oap.timeoutMs`.
## Do not invent fields
diff --git a/apps/bff/src/bundled_templates/layers/ai_agent.i18n.de.json b/apps/bff/src/bundled_templates/layers/ai_agent.i18n.de.json
new file mode 100644
index 00000000..ec92b16d
--- /dev/null
+++ b/apps/bff/src/bundled_templates/layers/ai_agent.i18n.de.json
@@ -0,0 +1,7 @@
+{
+ "alias": "KI-Agenten",
+ "slots": {
+ "services": "Agent-Laufzeiten",
+ "instances": "Absender"
+ }
+}
diff --git a/apps/bff/src/bundled_templates/layers/ai_agent.i18n.es.json b/apps/bff/src/bundled_templates/layers/ai_agent.i18n.es.json
new file mode 100644
index 00000000..a4d9c35e
--- /dev/null
+++ b/apps/bff/src/bundled_templates/layers/ai_agent.i18n.es.json
@@ -0,0 +1,7 @@
+{
+ "alias": "Agentes de IA",
+ "slots": {
+ "services": "Entornos de agente",
+ "instances": "Remitentes"
+ }
+}
diff --git a/apps/bff/src/bundled_templates/layers/ai_agent.i18n.fr.json b/apps/bff/src/bundled_templates/layers/ai_agent.i18n.fr.json
new file mode 100644
index 00000000..96e760e9
--- /dev/null
+++ b/apps/bff/src/bundled_templates/layers/ai_agent.i18n.fr.json
@@ -0,0 +1,7 @@
+{
+ "alias": "Agents IA",
+ "slots": {
+ "services": "Environnements d'agent",
+ "instances": "Expéditeurs"
+ }
+}
diff --git a/apps/bff/src/bundled_templates/layers/ai_agent.i18n.ja.json b/apps/bff/src/bundled_templates/layers/ai_agent.i18n.ja.json
new file mode 100644
index 00000000..8f327679
--- /dev/null
+++ b/apps/bff/src/bundled_templates/layers/ai_agent.i18n.ja.json
@@ -0,0 +1,7 @@
+{
+ "alias": "AI エージェント",
+ "slots": {
+ "services": "エージェントランタイム",
+ "instances": "送信元"
+ }
+}
diff --git a/apps/bff/src/bundled_templates/layers/ai_agent.i18n.ko.json b/apps/bff/src/bundled_templates/layers/ai_agent.i18n.ko.json
new file mode 100644
index 00000000..71769a48
--- /dev/null
+++ b/apps/bff/src/bundled_templates/layers/ai_agent.i18n.ko.json
@@ -0,0 +1,7 @@
+{
+ "alias": "AI 에이전트",
+ "slots": {
+ "services": "에이전트 런타임",
+ "instances": "발신자"
+ }
+}
diff --git a/apps/bff/src/bundled_templates/layers/ai_agent.i18n.pt.json b/apps/bff/src/bundled_templates/layers/ai_agent.i18n.pt.json
new file mode 100644
index 00000000..6833aadc
--- /dev/null
+++ b/apps/bff/src/bundled_templates/layers/ai_agent.i18n.pt.json
@@ -0,0 +1,7 @@
+{
+ "alias": "Agentes de IA",
+ "slots": {
+ "services": "Runtimes de agente",
+ "instances": "Remetentes"
+ }
+}
diff --git a/apps/bff/src/bundled_templates/layers/ai_agent.i18n.zh-CN.json b/apps/bff/src/bundled_templates/layers/ai_agent.i18n.zh-CN.json
new file mode 100644
index 00000000..fda052b3
--- /dev/null
+++ b/apps/bff/src/bundled_templates/layers/ai_agent.i18n.zh-CN.json
@@ -0,0 +1,7 @@
+{
+ "alias": "AI 智能体",
+ "slots": {
+ "services": "智能体运行时",
+ "instances": "发送方"
+ }
+}
diff --git a/apps/bff/src/bundled_templates/layers/ai_agent.json b/apps/bff/src/bundled_templates/layers/ai_agent.json
new file mode 100644
index 00000000..5ea78f5c
--- /dev/null
+++ b/apps/bff/src/bundled_templates/layers/ai_agent.json
@@ -0,0 +1,19 @@
+{
+ "key": "AI_AGENT",
+ "alias": "AI Agents",
+ "color": "var(--sw-purple)",
+ "documentLink": "https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/readme/",
+ "aliases": {
+ "services": "Agent runtimes",
+ "instances": "Senders"
+ },
+ "components": {
+ "service": false,
+ "instances": false,
+ "endpoints": false,
+ "topology": false,
+ "traces": false,
+ "logs": false,
+ "aiConversations": true
+ }
+}
diff --git a/apps/bff/src/client/ai-conversation.test.ts b/apps/bff/src/client/ai-conversation.test.ts
new file mode 100644
index 00000000..7d374de1
--- /dev/null
+++ b/apps/bff/src/client/ai-conversation.test.ts
@@ -0,0 +1,259 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createServer, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
+import type { AddressInfo } from 'node:net';
+import type { Readable } from 'node:stream';
+import { gzipSync } from 'node:zlib';
+import { afterEach, describe, expect, it } from 'vitest';
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+import {
+ AiConversationViewTimeout,
+ aiConversationViewPath,
+ listAiConversations,
+ openAiConversationView,
+} from './ai-conversation.js';
+
+function json(body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+}
+
+interface Captured {
+ query: string;
+ variables: Record<string, unknown>;
+}
+
+const ROW = {
+ conversation: 'c1',
+ serviceInstanceId: 'aW5zdA==',
+ serviceInstanceName: 'me@host',
+ title: null,
+ round: 3,
+ talks: 2,
+ steps: 10,
+ streams: 1,
+ segments: 1,
+ unresolved: 0,
+ from: 100,
+ to: 200,
+};
+
+/** An OAP answering `listConversations` with one row and recording the call. */
+function fakeOap(errorReason: string | null = null): { fetch: FetchLike; last: () => Captured } {
+ let last: Captured = { query: '', variables: {} };
+ const fetch: FetchLike = async (_url, init) => {
+ last = JSON.parse(String(init?.body ?? '{}')) as Captured;
+ return json({ data: { listConversations: { errorReason, conversations: [ROW] } } });
+ };
+ return { fetch, last: () => last };
+}
+
+const OPTS = { queryUrl: 'http://oap:12800', timeoutMs: 1000 };
+const DURATION = { start: '2026-09-01 000000', end: '2026-09-05 000000', step: 'SECOND' as const };
+
+describe('listAiConversations', () => {
+ it('addresses the service by NAME, sends the limit, and normalises the row', async () => {
+ const oap = fakeOap();
+ const res = await listAiConversations(
+ { ...OPTS, fetch: oap.fetch },
+ { serviceName: 'Claude Code', limit: 10_000, duration: DURATION },
+ );
+ expect(oap.last().variables.condition).toEqual({
+ service: { serviceName: 'Claude Code' },
+ limit: 10_000,
+ });
+ expect(oap.last().variables.duration).toEqual(DURATION);
+ expect(res.errorReason).toBeUndefined();
+ // A null title is an empty title, never the string "null".
+ expect(res.rows).toEqual([{ ...ROW, title: '' }]);
+ });
+
+ it('names the sender as an InstanceCondition that repeats the service name', async () => {
+ const oap = fakeOap();
+ await listAiConversations(
+ { ...OPTS, fetch: oap.fetch },
+ { serviceName: 'Claude Code', instanceName: 'me@host', limit: 5, duration: DURATION },
+ );
+ expect(oap.last().variables.condition).toEqual({
+ service: { serviceName: 'Claude Code' },
+ instance: { serviceName: 'Claude Code', instanceName: 'me@host' },
+ limit: 5,
+ });
+ });
+
+ it('carries the cold-stage flag on the duration untouched', async () => {
+ const oap = fakeOap();
+ await listAiConversations(
+ { ...OPTS, fetch: oap.fetch },
+ { serviceName: 'Claude Code', limit: 5, duration: { ...DURATION, coldStage: true } },
+ );
+ expect(oap.last().variables.duration).toEqual({ ...DURATION, coldStage: true });
+ });
+
+ it('relays OAP errorReason beside whatever rows came', async () => {
+ const oap = fakeOap('storage is slow');
+ const res = await listAiConversations(
+ { ...OPTS, fetch: oap.fetch },
+ { serviceName: 'Claude Code', limit: 5, duration: DURATION },
+ );
+ expect(res.errorReason).toBe('storage is slow');
+ expect(res.rows).toHaveLength(1);
+ });
+});
+
+describe('aiConversationViewPath', () => {
+ it('is the OAP route with the service, and the sender only when given', () => {
+ expect(aiConversationViewPath('c 1', 'Claude Code')).toBe(
+ '/ai-agent/conversations/c%201/v1/view?service=Claude+Code',
+ );
+ expect(aiConversationViewPath('c1', 'Claude Code', 'me@host')).toBe(
+ '/ai-agent/conversations/c1/v1/view?service=Claude+Code&instance=me%40host',
+ );
+ });
+});
+
+describe('openAiConversationView', () => {
+ let server: Server | null = null;
+
+ afterEach(async () => {
+ if (!server) return;
+ server.closeAllConnections();
+ await new Promise<void>((resolve) => server!.close(() => resolve()));
+ server = null;
+ });
+
+ async function serve(handler: (req: IncomingMessage, res: ServerResponse) => void): Promise<string> {
+ server = createServer(handler);
+ await new Promise<void>((resolve) => server!.listen(0, '127.0.0.1', () => resolve()));
+ return `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
+ }
+
+ async function drain(stream: Readable): Promise<Buffer> {
+ const chunks: Buffer[] = [];
+ for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
+ return Buffer.concat(chunks);
+ }
+
+ const DOC_GZIP = gzipSync(JSON.stringify({ format: 'asz.view', version: '1.0', conversation: 'c1' }));
+ const JSON_TYPE = 'application/vnd.skywalking.asz.view+json; version=1.0; charset=utf-8';
+
+ it('forwards Accept, Accept-Encoding and basic auth, and returns the compressed bytes untouched', async () => {
+ let seen: IncomingHttpHeaders = {};
+ let path = '';
+ const url = await serve((req, res) => {
+ seen = req.headers;
+ path = req.url ?? '';
+ res.writeHead(200, { 'content-type': JSON_TYPE, 'content-encoding': 'gzip' });
+ // Two chunks, so the relay is proven to stream rather than buffer.
+ res.write(DOC_GZIP.subarray(0, 10));
+ setTimeout(() => res.end(DOC_GZIP.subarray(10)), 10);
+ });
+ const up = await openAiConversationView(
+ { queryUrl: url, timeoutMs: 2000, auth: { username: 'u', password: 'p' } },
+ { conversation: 'c1', serviceName: 'Claude Code', instanceName: 'me@host', format: 'json', acceptEncoding: 'gzip, br' },
+ );
+ expect(path).toBe('/ai-agent/conversations/c1/v1/view?service=Claude+Code&instance=me%40host');
+ expect(seen.accept).toBe('application/vnd.skywalking.asz.view+json');
+ expect(seen['accept-encoding']).toBe('gzip, br');
+ expect(seen.authorization).toBe(`Basic ${Buffer.from('u:p').toString('base64')}`);
+ expect(up.status).toBe(200);
+ expect(up.headers['content-type']).toBe(JSON_TYPE);
+ expect(up.headers['content-encoding']).toBe('gzip');
+ // Byte-equal to what the server wrote: nothing decompressed it on the way.
+ expect((await drain(up.body)).equals(DOC_GZIP)).toBe(true);
+ });
+
+ it('asks for the yaml twin when told, and sends no Accept-Encoding when the browser sent none', async () => {
+ let seen: IncomingHttpHeaders = {};
+ const url = await serve((req, res) => {
+ seen = req.headers;
+ res.writeHead(200, { 'content-type': 'application/vnd.skywalking.asz.view+yaml; version=1.0' });
+ res.end('format: asz.view\n');
+ });
+ const up = await openAiConversationView(
+ { queryUrl: `${url}/`, timeoutMs: 2000 },
+ { conversation: 'c1', serviceName: 'Claude Code', format: 'yaml' },
+ );
+ expect(seen.accept).toBe('application/vnd.skywalking.asz.view+yaml');
+ expect(seen['accept-encoding']).toBeUndefined();
+ expect(seen.authorization).toBeUndefined();
+ expect((await drain(up.body)).toString()).toBe('format: asz.view\n');
+ });
+
+ it('relays a problem+json answer with its status', async () => {
+ const problem = { type: 'about:blank', title: 'Not Found', status: 404, detail: 'no round of conversation x is stored for this service' };
+ const url = await serve((_req, res) => {
+ res.writeHead(404, { 'content-type': 'application/problem+json; charset=utf-8' });
+ res.end(JSON.stringify(problem));
+ });
+ const up = await openAiConversationView(
+ { queryUrl: url, timeoutMs: 2000 },
+ { conversation: 'x', serviceName: 'Claude Code', format: 'json' },
+ );
+ expect(up.status).toBe(404);
+ expect(up.headers['content-type']).toContain('application/problem+json');
+ expect(JSON.parse((await drain(up.body)).toString())).toEqual(problem);
+ });
+
+ it('times out when OAP sends nothing, naming the limit', async () => {
+ const url = await serve(() => {
+ /* the fold never finishes */
+ });
+ await expect(
+ openAiConversationView(
+ { queryUrl: url, timeoutMs: 100 },
+ { conversation: 'c1', serviceName: 'Claude Code', format: 'json' },
+ ),
+ ).rejects.toBeInstanceOf(AiConversationViewTimeout);
+ });
+
+ it('rejects at once when the caller has already gone', async () => {
+ const ac = new AbortController();
+ ac.abort();
+ await expect(
+ openAiConversationView(
+ { queryUrl: 'http://127.0.0.1:9', timeoutMs: 1000, signal: ac.signal },
+ { conversation: 'c1', serviceName: 'Claude Code', format: 'json' },
+ ),
+ ).rejects.toMatchObject({ name: 'AbortError' });
+ });
+
+ it('drops the upstream socket when the caller goes away mid-stream', async () => {
+ const closed = new Promise<void>((resolve) => {
+ void serve((_req, res) => {
+ res.writeHead(200, { 'content-type': JSON_TYPE });
+ res.write('{"format":"asz.view"');
+ res.on('close', () => resolve());
+ }).then((url) => {
+ const ac = new AbortController();
+ return openAiConversationView(
+ { queryUrl: url, timeoutMs: 5000, signal: ac.signal },
+ { conversation: 'c1', serviceName: 'Claude Code', format: 'json' },
+ ).then((up) => {
+ up.body.on('error', () => {
+ /* the destroyed socket reports here; the assertion is the server's close */
+ });
+ ac.abort();
+ });
+ });
+ });
+ await expect(closed).resolves.toBeUndefined();
+ });
+});
diff --git a/apps/bff/src/client/ai-conversation.ts b/apps/bff/src/client/ai-conversation.ts
new file mode 100644
index 00000000..0c85e5c0
--- /dev/null
+++ b/apps/bff/src/client/ai-conversation.ts
@@ -0,0 +1,233 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The two AI agent conversation reads OAP offers: `listConversations` over
+ * GraphQL, and the `asz.view` document over OAP's own HTTP route on the query
+ * host, `GET /ai-agent/conversations/{conversation}/v1/view`.
+ *
+ * The document is NOT fetched with `fetch`. Node's fetch decompresses a gzip
+ * body on the way in and hands back decoded bytes under the original
+ * `Content-Encoding` header, so a relay built on it would either forward a
+ * header that lies about the bytes or re-compress a body Horizon registers no
+ * compression plugin for. `node:http` hands the bytes over untouched, and the
+ * browser receives exactly what OAP produced — measured on a real corpus, a
+ * 61 MB document as 13 MB of gzip. The cost is that this one call runs outside
+ * the wire log, which wraps `fetch`; the body would be truncated there anyway.
+ */
+
+import http from 'node:http';
+import https from 'node:https';
+import type { Readable } from 'node:stream';
+import {
+ ASZ_VIEW_JSON_MEDIA_TYPE,
+ ASZ_VIEW_YAML_MEDIA_TYPE,
+ type AiConversationRow,
+} from '@skywalking-horizon-ui/api-client';
+import { basicAuthHeader, graphqlPost, type GraphqlOptions } from './graphql.js';
+
+export interface AiConversationDuration {
+ start: string;
+ end: string;
+ step: 'SECOND';
+ coldStage?: true;
+}
+
+export interface ListAiConversationsQuery {
+ serviceName: string;
+ instanceName?: string;
+ /** The newest N rounds OAP reads before folding to one row per conversation. */
+ limit: number;
+ duration: AiConversationDuration;
+ conversation?: string;
+ title?: string;
+}
+
+const LIST_QUERY = /* GraphQL */ `
+ query HorizonAiConversations($condition: ConversationListCondition!, $duration: Duration!) {
+ listConversations(condition: $condition, duration: $duration) {
+ errorReason
+ conversations {
+ conversation
+ serviceInstanceId
+ serviceInstanceName
+ title
+ round
+ talks
+ steps
+ streams
+ segments
+ unresolved
+ from
+ to
+ }
+ }
+ }
+`;
+
+interface ListRaw {
+ listConversations?: {
+ errorReason?: string | null;
+ conversations?: Array<Record<string, unknown>> | null;
+ } | null;
+}
+
+function num(v: unknown): number {
+ const n = typeof v === 'number' ? v : Number(v);
+ return Number.isFinite(n) ? n : 0;
+}
+
+function str(v: unknown): string {
+ return typeof v === 'string' ? v : v == null ? '' : String(v);
+}
+
+function toRow(r: Record<string, unknown>): AiConversationRow {
+ return {
+ conversation: str(r.conversation),
+ serviceInstanceId: str(r.serviceInstanceId),
+ serviceInstanceName: str(r.serviceInstanceName),
+ title: str(r.title),
+ round: num(r.round),
+ talks: num(r.talks),
+ steps: num(r.steps),
+ streams: num(r.streams),
+ segments: num(r.segments),
+ unresolved: num(r.unresolved),
+ from: num(r.from),
+ to: num(r.to),
+ };
+}
+
+/** One row per conversation of a service active in the window. The `instance`
+ * condition repeats the service name because OAP's `InstanceCondition` is
+ * addressed that way. */
+export async function listAiConversations(
+ opts: GraphqlOptions,
+ q: ListAiConversationsQuery,
+): Promise<{ rows: AiConversationRow[]; errorReason?: string }> {
+ const condition: Record<string, unknown> = {
+ service: { serviceName: q.serviceName },
+ ...(q.instanceName
+ ? { instance: { serviceName: q.serviceName, instanceName: q.instanceName } }
+ : {}),
+ ...(q.conversation ? { conversation: q.conversation } : {}),
+ ...(q.title ? { title: q.title } : {}),
+ limit: q.limit,
+ };
+ const raw = await graphqlPost<ListRaw>(opts, LIST_QUERY, { condition, duration: q.duration });
+ const env = raw.listConversations;
+ const rows = (env?.conversations ?? []).map(toRow);
+ return env?.errorReason ? { rows, errorReason: env.errorReason } : { rows };
+}
+
+export interface AiConversationViewRequest {
+ conversation: string;
+ serviceName: string;
+ instanceName?: string;
+ /** `yaml` asks for the `+yaml` twin; anything else is the JSON document. */
+ format: 'json' | 'yaml';
+ /** The browser's own `Accept-Encoding`, forwarded verbatim so OAP compresses
+ * for the browser and the BFF never has to. Omitted → identity. */
+ acceptEncoding?: string;
+}
+
+export interface AiConversationViewOptions {
+ queryUrl: string;
+ auth?: { username: string; password: string };
+ /** Socket idle limit, and therefore the longest wait for the first byte —
+ * which is the whole fold on OAP's side. Not `oap.timeoutMs`. */
+ timeoutMs: number;
+ /** The caller's cancellation. Destroys the upstream socket, so an abandoned
+ * page stops the render on OAP too. */
+ signal?: AbortSignal;
+}
+
+/** The OAP response, as it came: status, lower-cased headers, and the body as
+ * a stream of the bytes on the wire. `abort` drops the socket. */
+export interface AiConversationViewUpstream {
+ status: number;
+ headers: Record<string, string>;
+ body: Readable;
+ abort(): void;
+}
+
+export class AiConversationViewTimeout extends Error {
+ constructor(readonly timeoutMs: number) {
+ super(`the OAP conversation view sent nothing for ${timeoutMs} ms`);
+ this.name = 'AiConversationViewTimeout';
+ }
+}
+
+/** The route's path and query for one conversation, as OAP defines them. */
+export function aiConversationViewPath(
+ conversation: string,
+ serviceName: string,
+ instanceName?: string,
+): string {
+ const qs = new URLSearchParams({ service: serviceName });
+ if (instanceName) qs.set('instance', instanceName);
+ return `/ai-agent/conversations/${encodeURIComponent(conversation)}/v1/view?${qs.toString()}`;
+}
+
+export function openAiConversationView(
+ opts: AiConversationViewOptions,
+ q: AiConversationViewRequest,
+): Promise<AiConversationViewUpstream> {
+ const url = new URL(
+ opts.queryUrl.replace(/\/$/, '') + aiConversationViewPath(q.conversation, q.serviceName, q.instanceName),
+ );
+ const lib = url.protocol === 'https:' ? https : http;
+ const headers: Record<string, string> = {
+ accept: q.format === 'yaml' ? ASZ_VIEW_YAML_MEDIA_TYPE : ASZ_VIEW_JSON_MEDIA_TYPE,
+ };
+ if (q.acceptEncoding) headers['accept-encoding'] = q.acceptEncoding;
+ if (opts.auth) headers.authorization = basicAuthHeader(opts.auth.username, opts.auth.password);
+
+ return new Promise((resolve, reject) => {
+ if (opts.signal?.aborted) {
+ reject(abortError());
+ return;
+ }
+ const req = lib.request(url, { method: 'GET', headers, timeout: opts.timeoutMs }, (res) => {
+ const flat: Record<string, string> = {};
+ for (const [k, v] of Object.entries(res.headers)) {
+ if (typeof v === 'string') flat[k.toLowerCase()] = v;
+ else if (Array.isArray(v)) flat[k.toLowerCase()] = v.join(', ');
+ }
+ resolve({
+ status: res.statusCode ?? 502,
+ headers: flat,
+ body: res,
+ abort: () => req.destroy(abortError()),
+ });
+ });
+ // `timeout` on the request is socket idleness, so it covers both the wait
+ // for the first byte and a stall mid-stream. Node only emits the event; the
+ // destroy is ours, and it rejects before a response or errors the body
+ // stream after one.
+ req.on('timeout', () => req.destroy(new AiConversationViewTimeout(opts.timeoutMs)));
+ req.on('error', (err) => reject(err));
+ opts.signal?.addEventListener('abort', () => req.destroy(abortError()), { once: true });
+ req.end();
+ });
+}
+
+function abortError(): Error {
+ const e = new Error('This operation was aborted');
+ e.name = 'AbortError';
+ return e;
+}
diff --git a/apps/bff/src/config/builtin-roles.ts b/apps/bff/src/config/builtin-roles.ts
index e2d8d0a3..6d2705df 100644
--- a/apps/bff/src/config/builtin-roles.ts
+++ b/apps/bff/src/config/builtin-roles.ts
@@ -38,6 +38,7 @@ export const BUILTIN_ROLES: Readonly<Record<string, string[]>> = {
'traces:read',
'logs:read',
'browser-errors:read',
+ 'ai-conversation:read',
'inspect:read',
'topology:read',
'profile:read',
@@ -56,6 +57,7 @@ export const BUILTIN_ROLES: Readonly<Record<string, string[]>> = {
'traces:read',
'logs:read',
'browser-errors:read',
+ 'ai-conversation:read',
'topology:read',
'profile:read',
'overview:read',
@@ -79,6 +81,7 @@ export const BUILTIN_ROLES: Readonly<Record<string, string[]>> = {
'traces:read',
'logs:read',
'browser-errors:read',
+ 'ai-conversation:read',
'source-map:write',
'topology:read',
'profile:read',
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index 860afe9a..cd87b4b3 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -894,6 +894,18 @@ const performanceSchema = z
})
.strict()
.default({}),
+ // AI agent conversations. `listLimit` counts the newest ROUNDS the list
+ // folds into rows, not rows — a smaller value lets one long conversation
+ // hide the short ones, so the default is OAP's ceiling (`maxListLimit`).
+ // `viewTimeoutMs` matches OAP's `viewRequestTimeout`: the first byte of a
+ // document arrives only after OAP has folded the whole chain.
+ aiConversation: z
+ .object({
+ listLimit: z.number().int().min(1).max(10_000).default(10_000),
+ viewTimeoutMs: z.number().int().positive().max(600_000).default(120_000),
+ })
+ .strict()
+ .default({}),
})
.strict()
.default({});
diff --git a/apps/bff/src/http/query/ai-conversation.test.ts b/apps/bff/src/http/query/ai-conversation.test.ts
new file mode 100644
index 00000000..3089a767
--- /dev/null
+++ b/apps/bff/src/http/query/ai-conversation.test.ts
@@ -0,0 +1,365 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
+import type { AddressInfo } from 'node:net';
+import { gzipSync } from 'node:zlib';
+import { afterEach, describe, expect, it } from 'vitest';
+import Fastify, { type FastifyInstance } from 'fastify';
+import cookie from '@fastify/cookie';
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+import { configSchema } from '../../config/schema.js';
+import type { ConfigSource } from '../../config/loader.js';
+import { SessionStore } from '../../user/sessions.js';
+import { makeRouteAuthHook } from '../../rbac/route-policy.js';
+import { registerColdStageHook } from '../../util/duration.js';
+import { fmtSecond } from '../../util/window.js';
+import {
+ clampLimit,
+ clampWindowMs,
+ passthroughHeaders,
+ registerAiConversationRoutes,
+ wantsYaml,
+} from './ai-conversation.js';
+
+const DAY_MS = 24 * 60 * 60_000;
+const NOW = 1_782_800_000_000;
+
+describe('clampWindowMs', () => {
+ it('defaults to a rolling week ending now', () => {
+ expect(clampWindowMs(undefined, undefined, NOW)).toEqual({ startMs: NOW - 7 * DAY_MS, endMs: NOW });
+ });
+ it('honours a rolling window in minutes, capped at 90 days', () => {
+ expect(clampWindowMs(60, undefined, NOW)).toEqual({ startMs: NOW - 60 * 60_000, endMs: NOW });
+ expect(clampWindowMs(365 * 24 * 60, undefined, NOW)).toEqual({ startMs: NOW - 90 * DAY_MS, endMs: NOW });
+ });
+ it('keeps an explicit range within the cap, and clamps a wider one to its newest 90 days', () => {
+ const explicit = { startMs: NOW - 30 * DAY_MS, endMs: NOW };
+ expect(clampWindowMs(undefined, explicit, NOW)).toEqual(explicit);
+ expect(clampWindowMs(undefined, { startMs: NOW - 400 * DAY_MS, endMs: NOW }, NOW)).toEqual({
+ startMs: NOW - 90 * DAY_MS,
+ endMs: NOW,
+ });
+ });
+ it('ignores an inverted explicit range and falls back to rolling', () => {
+ expect(clampWindowMs(undefined, { startMs: NOW, endMs: NOW - DAY_MS }, NOW)).toEqual({
+ startMs: NOW - 7 * DAY_MS,
+ endMs: NOW,
+ });
+ });
+});
+
+describe('clampLimit', () => {
+ it('sends the cap when the caller says nothing, or nonsense', () => {
+ expect(clampLimit(undefined, 10_000)).toBe(10_000);
+ expect(clampLimit(0, 10_000)).toBe(10_000);
+ expect(clampLimit(Number.NaN, 10_000)).toBe(10_000);
+ });
+ it('honours a smaller request and never exceeds the cap', () => {
+ expect(clampLimit(50, 10_000)).toBe(50);
+ expect(clampLimit(42.4, 10_000)).toBe(42);
+ expect(clampLimit(1_000_000, 10_000)).toBe(10_000);
+ });
+});
+
+describe('wantsYaml', () => {
+ it('reads Accept the way OAP does: any type naming yaml', () => {
+ expect(wantsYaml(undefined)).toBe(false);
+ expect(wantsYaml('application/json')).toBe(false);
+ expect(wantsYaml('application/vnd.skywalking.asz.view+yaml')).toBe(true);
+ expect(wantsYaml(['text/html', 'text/YAML'])).toBe(true);
+ });
+});
+
+describe('passthroughHeaders', () => {
+ it('keeps what names the bytes and drops the upstream hop', () => {
+ expect(
+ passthroughHeaders({
+ 'content-type': 'application/vnd.skywalking.asz.view+json; version=1.0',
+ 'content-encoding': 'gzip',
+ 'content-length': '1234',
+ vary: 'accept-encoding',
+ server: 'Armeria',
+ date: 'now',
+ connection: 'keep-alive',
+ 'transfer-encoding': 'chunked',
+ }),
+ ).toEqual({
+ 'content-type': 'application/vnd.skywalking.asz.view+json; version=1.0',
+ 'content-encoding': 'gzip',
+ 'content-length': '1234',
+ vary: 'accept-encoding',
+ });
+ });
+});
+
+/* ── the routes, through Fastify ─────────────────────────────────────── */
+
+interface Captured {
+ query: string;
+ variables: Record<string, unknown>;
+}
+
+function json(body: unknown): Response {
+ return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
+}
+
+const ROWS = [
+ { conversation: 'old', serviceInstanceId: 'i', serviceInstanceName: 'me@host', title: 'first', round: 2, talks: 1, steps: 5, streams: 1, segments: 1, unresolved: 0, from: 10, to: 100 },
+ { conversation: 'new', serviceInstanceId: 'i', serviceInstanceName: 'me@host', title: '', round: 9, talks: 4, steps: 40, streams: 2, segments: 1, unresolved: 0, from: 50, to: 900 },
+];
+
+/** An OAP that tells the time and answers the list, oldest row first so the
+ * route's ordering is proven rather than inherited. */
+function fakeOap(fail = false): { fetch: FetchLike; asked: (fragment: string) => Captured[] } {
+ const calls: Captured[] = [];
+ const fetch: FetchLike = async (_url, init) => {
+ const body = JSON.parse(String(init?.body ?? '{}')) as Captured;
+ calls.push({ query: body.query ?? '', variables: body.variables ?? {} });
+ if (body.query.includes('getTimeInfo')) return json({ data: { time: { timezone: '+0000' } } });
+ if (body.query.includes('listConversations')) {
+ if (fail) return new Response('boom', { status: 500 });
+ return json({ data: { listConversations: { errorReason: null, conversations: ROWS } } });
+ }
+ return json({ data: {} });
+ };
+ return { fetch, asked: (fragment) => calls.filter((c) => c.query.includes(fragment)) };
+}
+
+function fakeConfig(overrides: Record<string, unknown> = {}): ConfigSource {
+ const cfg = configSchema.parse(overrides);
+ return { current: cfg, current_: () => cfg, path: '', onChange: () => () => {}, close: async () => {} };
+}
+
+async function build(
+ fetchImpl: FetchLike,
+ overrides: Record<string, unknown> = {},
+ roles: string[] = ['admin'],
+): Promise<{ app: FastifyInstance; cookie: string }> {
+ const config = fakeConfig(overrides);
+ const sessions = new SessionStore({ ttlMinutes: 60 });
+ const app = Fastify();
+ await app.register(cookie);
+ // The server installs both hooks before any route; the cold-stage one is
+ // what turns the request header into `req.coldStage`.
+ registerColdStageHook(app);
+ app.addHook('onRoute', makeRouteAuthHook({ config, sessions }));
+ registerAiConversationRoutes(app, { config, sessions, fetch: fetchImpl });
+ await app.ready();
+ return { app, cookie: `horizon_sid=${sessions.create('op', roles).sid}` };
+}
+
+describe('POST /api/layer/:key/ai-conversations', () => {
+ it('refuses a request that names no service', async () => {
+ const { app, cookie: c } = await build(fakeOap().fetch);
+ const res = await app.inject({ method: 'POST', url: '/api/layer/ai_agent/ai-conversations', headers: { cookie: c }, payload: {} });
+ expect(res.statusCode).toBe(400);
+ expect(res.json()).toEqual({ error: 'service_required' });
+ });
+
+ it('asks OAP by service name at the ceiling limit, in SECOND precision, and answers newest activity first', async () => {
+ const oap = fakeOap();
+ const { app, cookie: c } = await build(oap.fetch);
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/layer/ai_agent/ai-conversations',
+ headers: { cookie: c },
+ payload: { service: 'Claude Code', startMs: NOW - DAY_MS, endMs: NOW },
+ });
+ expect(res.statusCode).toBe(200);
+ const sent = oap.asked('listConversations')[0]!.variables;
+ expect(sent.condition).toEqual({ service: { serviceName: 'Claude Code' }, limit: 10_000 });
+ // The fake OAP sits at +0000, so the strings are the epoch range rendered
+ // in UTC at SECOND precision.
+ expect(sent.duration).toEqual({ start: fmtSecond(NOW - DAY_MS, 0), end: fmtSecond(NOW, 0), step: 'SECOND' });
+ const body = res.json() as { reachable: boolean; limit: number; rows: Array<{ conversation: string }> };
+ expect(body.reachable).toBe(true);
+ expect(body.limit).toBe(10_000);
+ expect(body.rows.map((r) => r.conversation)).toEqual(['new', 'old']);
+ });
+
+ it('forwards the sender filter, a smaller limit, and the cold-stage flag', async () => {
+ const oap = fakeOap();
+ const { app, cookie: c } = await build(oap.fetch);
+ await app.inject({
+ method: 'POST',
+ url: '/api/layer/ai_agent/ai-conversations',
+ headers: { cookie: c, 'x-horizon-cold-stage': '1' },
+ payload: { service: 'Claude Code', instanceName: 'me@host', conversation: ' c1 ', title: ' audit ', limit: 25 },
+ });
+ const sent = oap.asked('listConversations')[0]!.variables;
+ expect(sent.condition).toEqual({
+ service: { serviceName: 'Claude Code' },
+ instance: { serviceName: 'Claude Code', instanceName: 'me@host' },
+ conversation: 'c1',
+ title: 'audit',
+ limit: 25,
+ });
+ expect((sent.duration as { coldStage?: boolean }).coldStage).toBe(true);
+ });
+
+ it('answers reachable: false with the reason instead of failing', async () => {
+ const { app, cookie: c } = await build(fakeOap(true).fetch);
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/layer/ai_agent/ai-conversations',
+ headers: { cookie: c },
+ payload: { service: 'Claude Code' },
+ });
+ expect(res.statusCode).toBe(200);
+ const body = res.json() as { reachable: boolean; rows: unknown[]; error?: string };
+ expect(body.reachable).toBe(false);
+ expect(body.rows).toEqual([]);
+ expect(body.error).toContain('500');
+ });
+
+ it('is gated on ai-conversation:read', async () => {
+ const { app, cookie: c } = await build(fakeOap().fetch, { rbac: { roles: { nobody: ['metrics:read'] } } }, ['nobody']);
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/layer/ai_agent/ai-conversations',
+ headers: { cookie: c },
+ payload: { service: 'Claude Code' },
+ });
+ expect(res.statusCode).toBe(403);
+ expect(res.json()).toEqual({ error: 'permission_denied', verb: 'ai-conversation:read' });
+ });
+});
+
+describe('GET /api/ai-conversation/:conversation/view', () => {
+ let server: Server | null = null;
+
+ afterEach(async () => {
+ if (!server) return;
+ server.closeAllConnections();
+ await new Promise<void>((resolve) => server!.close(() => resolve()));
+ server = null;
+ });
+
+ async function serveOap(handler: (req: IncomingMessage, res: ServerResponse) => void): Promise<string> {
+ server = createServer(handler);
+ await new Promise<void>((resolve) => server!.listen(0, '127.0.0.1', () => resolve()));
+ return `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
+ }
+
+ const DOC = JSON.stringify({
+ format: 'asz.view',
+ version: '1.0',
+ conversation: 'c1',
+ summary: { title: 'build }{ and check', state: 'verified', problems: [], talks: 3, steps: 21, streams: 2, segments: 1, rounds: 1, unresolved: 0 },
+ talks: [],
+ });
+ const DOC_GZIP = gzipSync(DOC);
+ const JSON_TYPE = 'application/vnd.skywalking.asz.view+json; version=1.0; charset=utf-8';
+
+ it('refuses a request that names no service', async () => {
+ const { app, cookie: c } = await build(fakeOap().fetch);
+ const res = await app.inject({ method: 'GET', url: '/api/ai-conversation/c1/view', headers: { cookie: c } });
+ expect(res.statusCode).toBe(400);
+ });
+
+ it('holds the document, then relays it compressed with its decoded size and summary ahead of the bytes', async () => {
+ let seenEncoding: string | undefined;
+ let path = '';
+ const url = await serveOap((req, res) => {
+ seenEncoding = req.headers['accept-encoding'];
+ path = req.url ?? '';
+ res.writeHead(200, { 'content-type': JSON_TYPE, 'content-encoding': 'gzip', 'vary': 'accept-encoding' });
+ res.end(DOC_GZIP);
+ });
+ const { app, cookie: c } = await build(fakeOap().fetch, { oap: { queryUrl: url } });
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/ai-conversation/c1/view?service=Claude%20Code&instance=me%40host',
+ headers: { cookie: c, 'accept-encoding': 'gzip, deflate, br' },
+ });
+ expect(path).toBe('/ai-agent/conversations/c1/v1/view?service=Claude+Code&instance=me%40host');
+ // Only gzip is asked of OAP: the BFF decodes what it holds, and brotli or
+ // zstd from OAP could not be counted.
+ expect(seenEncoding).toBe('gzip');
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe(JSON_TYPE);
+ expect(res.headers['content-encoding']).toBe('gzip');
+ expect(res.headers.vary).toBe('accept-encoding');
+ expect(res.headers.server).toBeUndefined();
+ expect(res.headers['content-length']).toBe(String(DOC_GZIP.length));
+ expect(res.headers['x-horizon-document-bytes']).toBe(String(Buffer.byteLength(DOC)));
+ expect(JSON.parse(String(res.headers['x-horizon-document-summary']))).toEqual({ talks: 3, steps: 21, streams: 2, segments: 1, rounds: 1, unresolved: 0 });
+ expect(res.rawPayload.equals(DOC_GZIP)).toBe(true);
+ });
+
+ it('asks OAP for plain bytes when the browser takes no gzip, and still states the size', async () => {
+ let seenEncoding: string | undefined;
+ const url = await serveOap((req, res) => {
+ seenEncoding = req.headers['accept-encoding'];
+ res.writeHead(200, { 'content-type': JSON_TYPE });
+ res.end(DOC);
+ });
+ const { app, cookie: c } = await build(fakeOap().fetch, { oap: { queryUrl: url } });
+ const res = await app.inject({ method: 'GET', url: '/api/ai-conversation/c1/view?service=x', headers: { cookie: c } });
+ expect(seenEncoding).toBe('identity');
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-encoding']).toBeUndefined();
+ expect(res.headers['content-length']).toBe(String(Buffer.byteLength(DOC)));
+ expect(res.headers['x-horizon-document-bytes']).toBe(String(Buffer.byteLength(DOC)));
+ expect(res.body).toBe(DOC);
+ });
+
+ it('answers 502 when OAP sends gzip that does not decode, rather than relaying it blind', async () => {
+ const url = await serveOap((_req, res) => {
+ res.writeHead(200, { 'content-type': JSON_TYPE, 'content-encoding': 'gzip' });
+ res.end(Buffer.from('not gzip'));
+ });
+ const { app, cookie: c } = await build(fakeOap().fetch, { oap: { queryUrl: url } });
+ const res = await app.inject({ method: 'GET', url: '/api/ai-conversation/c1/view?service=x', headers: { cookie: c, 'accept-encoding': 'gzip' } });
+ expect(res.statusCode).toBe(502);
+ expect(res.json().message).toContain('could not be decoded');
+ });
+
+ it('relays OAP problem+json with its own status', async () => {
+ const problem = { type: 'about:blank', title: 'Not Found', status: 404, detail: 'no round of conversation c1 is stored for this service' };
+ const url = await serveOap((_req, res) => {
+ res.writeHead(404, { 'content-type': 'application/problem+json; charset=utf-8' });
+ res.end(JSON.stringify(problem));
+ });
+ const { app, cookie: c } = await build(fakeOap().fetch, { oap: { queryUrl: url } });
+ const res = await app.inject({ method: 'GET', url: '/api/ai-conversation/c1/view?service=x', headers: { cookie: c } });
+ expect(res.statusCode).toBe(404);
+ expect(res.headers['content-type']).toContain('application/problem+json');
+ expect(res.json()).toEqual(problem);
+ });
+
+ it('answers 504 when OAP sends nothing within the configured view timeout', async () => {
+ const url = await serveOap(() => {
+ /* never answers */
+ });
+ const { app, cookie: c } = await build(fakeOap().fetch, {
+ oap: { queryUrl: url },
+ performance: { aiConversation: { viewTimeoutMs: 100 } },
+ });
+ const res = await app.inject({ method: 'GET', url: '/api/ai-conversation/c1/view?service=x', headers: { cookie: c } });
+ expect(res.statusCode).toBe(504);
+ expect(res.json()).toMatchObject({ error: 'oap_timeout' });
+ });
+
+ it('answers 502 when OAP cannot be reached', async () => {
+ const { app, cookie: c } = await build(fakeOap().fetch, { oap: { queryUrl: 'http://127.0.0.1:9' } });
+ const res = await app.inject({ method: 'GET', url: '/api/ai-conversation/c1/view?service=x', headers: { cookie: c } });
+ expect(res.statusCode).toBe(502);
+ expect(res.json()).toMatchObject({ error: 'oap_unreachable' });
+ });
+});
diff --git a/apps/bff/src/http/query/ai-conversation.ts b/apps/bff/src/http/query/ai-conversation.ts
new file mode 100644
index 00000000..16689668
--- /dev/null
+++ b/apps/bff/src/http/query/ai-conversation.ts
@@ -0,0 +1,269 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * AI agent conversations — the `AI_AGENT` layer, landed by the AI Sessionizer.
+ *
+ * POST /api/layer/:key/ai-conversations one row per conversation
+ * GET /api/ai-conversation/:conversation/view the whole conversation, one `asz.view` document
+ *
+ * The list wraps `listConversations`. Its `limit` counts ROUNDS, not rows: OAP
+ * reads the newest `limit` rounds in the window and folds them to one row per
+ * conversation, so a chatty conversation spends the budget of the quiet ones.
+ * Measured on a real corpus: at OAP's default of 1,000, one 865-round
+ * conversation pushed a 19-round one off the list. The BFF therefore sends the
+ * OAP ceiling by default (`performance.aiConversation.listLimit`) and names the
+ * limit in the response so the page can state the rule — OAP gives no
+ * truncation signal to relay.
+ *
+ * The view relays OAP's own route, still compressed: the largest real document
+ * is 61 MB, 13 MB as gzip, and its first byte arrives only after OAP has folded
+ * the whole chain (8.5 s measured). So the relay runs under
+ * `performance.aiConversation.viewTimeoutMs`, not `oap.timeoutMs`, and passes
+ * OAP's problem+json errors through with their status. It makes no other OAP
+ * call: the OAP doc says the page reads this route and nothing else.
+ *
+ * OAP streams the document without knowing its length, so the BFF holds the
+ * whole body (gzip, in memory, a fifth of the document) before answering, and
+ * sends the decoded size and the summary's counts ahead of the bytes — that is
+ * what lets the page say "12 of 61 MB". Only gzip is asked of OAP, because the
+ * BFF decodes what it holds to count it.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type {
+ AiConversationsQueryRequest,
+ AiConversationsResponse,
+ FetchLike,
+} from '@skywalking-horizon-ui/api-client';
+import type { AuthDeps } from '../../user/middleware.js';
+import { requireAuth } from '../../user/middleware.js';
+import {
+ AI_CONVERSATION_DOCUMENT_BYTES_HEADER,
+ AI_CONVERSATION_DOCUMENT_SUMMARY_HEADER,
+} from '@skywalking-horizon-ui/api-client';
+import { buildOapOpts } from '../../client/graphql.js';
+import { holdDocument } from '../../logic/ai-conversation/hold-document.js';
+import {
+ AiConversationViewTimeout,
+ listAiConversations,
+ openAiConversationView,
+ type AiConversationViewUpstream,
+} from '../../client/ai-conversation.js';
+import { clientGone } from '../client-gone.js';
+import { withColdStage } from '../../util/duration.js';
+import { fmtSecond, getServerOffsetMinutes } from '../../util/window.js';
+
+export interface AiConversationRouteDeps extends AuthDeps {
+ fetch?: FetchLike;
+}
+
+/** A conversation lives for days, and the list reads only round headers, so the
+ * window is wide where the trace and log feeds keep a week. */
+const DEFAULT_WINDOW_MIN = 60 * 24 * 7;
+const MAX_WINDOW_MIN = 60 * 24 * 90;
+const WINDOW_MIN_MS = 60_000;
+
+/** The window in epoch ms: an explicit range clamped to its newest 90 days, or
+ * a rolling one ending at `nowMs`. Mirrors the events feed's arithmetic so the
+ * three feeds cannot drift on what "a week" means. */
+export function clampWindowMs(
+ windowMinutes: number | undefined,
+ explicit: { startMs?: number; endMs?: number } | undefined,
+ nowMs = Date.now(),
+): { startMs: number; endMs: number } {
+ const maxMs = MAX_WINDOW_MIN * WINDOW_MIN_MS;
+ if (
+ typeof explicit?.startMs === 'number' &&
+ typeof explicit.endMs === 'number' &&
+ Number.isFinite(explicit.startMs) &&
+ Number.isFinite(explicit.endMs) &&
+ explicit.startMs < explicit.endMs
+ ) {
+ return { startMs: Math.max(explicit.startMs, explicit.endMs - maxMs), endMs: explicit.endMs };
+ }
+ const minutes =
+ Number.isFinite(windowMinutes) && (windowMinutes as number) > 0
+ ? Math.min(MAX_WINDOW_MIN, Math.round(windowMinutes as number))
+ : DEFAULT_WINDOW_MIN;
+ return { startMs: nowMs - minutes * WINDOW_MIN_MS, endMs: nowMs };
+}
+
+/** The `limit` sent to OAP: what the caller asked for, never above the
+ * configured cap, and the cap itself when the caller said nothing — the cap is
+ * the OAP ceiling by default, because a smaller number hides conversations. */
+export function clampLimit(requested: number | undefined, cap: number): number {
+ if (!Number.isFinite(requested as number) || (requested as number) < 1) return cap;
+ return Math.min(cap, Math.round(requested as number));
+}
+
+/** OAP reads `Accept` the same way: any type naming yaml gets the yaml twin. */
+export function wantsYaml(accept: string | string[] | undefined): boolean {
+ const v = Array.isArray(accept) ? accept.join(',') : accept ?? '';
+ return v.toLowerCase().includes('yaml');
+}
+
+/** The upstream headers the browser must see. `Content-Encoding` is the one
+ * that matters — the body is forwarded compressed — and `Content-Type` names
+ * the document and its version. Everything else about the upstream hop
+ * (server, date, connection) stays there. */
+export function passthroughHeaders(upstream: Record<string, string>): Record<string, string> {
+ const out: Record<string, string> = {};
+ for (const name of ['content-type', 'content-encoding', 'content-length', 'vary']) {
+ const v = upstream[name];
+ if (v) out[name] = v;
+ }
+ return out;
+}
+
+export function registerAiConversationRoutes(app: FastifyInstance, deps: AiConversationRouteDeps): void {
+ const auth = requireAuth(deps);
+
+ app.post(
+ '/api/layer/:key/ai-conversations',
+ { preHandler: auth },
+ async (req: FastifyRequest, reply: FastifyReply) => {
+ const { key } = req.params as { key: string };
+ if (!key || !/^[a-z0-9_]+$/i.test(key)) {
+ return reply.code(400).send({ error: 'invalid_layer_key' });
+ }
+ const body = (req.body ?? {}) as AiConversationsQueryRequest;
+ const serviceName = typeof body.service === 'string' ? body.service.trim() : '';
+ if (!serviceName) {
+ return reply.code(400).send({ error: 'service_required' });
+ }
+ const cfg = deps.config.current;
+ const signal = clientGone(reply);
+ const limit = clampLimit(body.limit, cfg.performance.aiConversation.listLimit);
+ const instanceName =
+ typeof body.instanceName === 'string' && body.instanceName.trim()
+ ? body.instanceName.trim()
+ : undefined;
+ const conversation =
+ typeof body.conversation === 'string' && body.conversation.trim() ? body.conversation.trim() : undefined;
+ const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim().slice(0, 512) : undefined;
+ const respond = (partial: Pick<AiConversationsResponse, 'rows' | 'reachable' | 'error'>) =>
+ reply.send({
+ generatedAt: Date.now(),
+ query: body,
+ limit,
+ ...partial,
+ } satisfies AiConversationsResponse);
+ try {
+ const offset = await getServerOffsetMinutes(deps.config, deps.fetch, signal);
+ const { startMs, endMs } = clampWindowMs(body.windowMinutes, {
+ startMs: body.startMs,
+ endMs: body.endMs,
+ });
+ const duration = withColdStage(req, {
+ start: fmtSecond(startMs, offset),
+ end: fmtSecond(endMs, offset),
+ step: 'SECOND' as const,
+ });
+ const { rows, errorReason } = await listAiConversations(
+ buildOapOpts(cfg, deps.fetch, signal),
+ { serviceName, instanceName, conversation, title, limit, duration },
+ );
+ rows.sort((a, b) => b.to - a.to);
+ return respond({ rows, reachable: true, ...(errorReason ? { error: errorReason } : {}) });
+ } catch (err) {
+ return respond({
+ rows: [],
+ reachable: false,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ },
+ );
+
+ app.get(
+ '/api/ai-conversation/:conversation/view',
+ { preHandler: auth },
+ async (req: FastifyRequest, reply: FastifyReply) => {
+ const { conversation } = req.params as { conversation: string };
+ const q = req.query as { service?: string; instance?: string };
+ const serviceName = typeof q.service === 'string' ? q.service.trim() : '';
+ if (!conversation || !serviceName) {
+ return reply.code(400).send({ error: 'service_required' });
+ }
+ const cfg = deps.config.current;
+ // Only what the BFF can decode is asked of OAP: gzip when the browser
+ // takes gzip, plain bytes otherwise. Forwarding the browser's list could
+ // bring back brotli or zstd, which would have to be relayed uncounted.
+ const acceptEncoding = req.headers['accept-encoding'];
+ const browserTakesGzip = typeof acceptEncoding === 'string' && /\bgzip\b/i.test(acceptEncoding);
+ const yaml = wantsYaml(req.headers.accept);
+ let upstream: AiConversationViewUpstream;
+ try {
+ upstream = await openAiConversationView(
+ {
+ queryUrl: cfg.oap.queryUrl,
+ auth: cfg.oap.auth,
+ timeoutMs: cfg.performance.aiConversation.viewTimeoutMs,
+ signal: clientGone(reply),
+ },
+ {
+ conversation,
+ serviceName,
+ instanceName: typeof q.instance === 'string' && q.instance ? q.instance : undefined,
+ format: yaml ? 'yaml' : 'json',
+ acceptEncoding: browserTakesGzip ? 'gzip' : 'identity',
+ },
+ );
+ } catch (err) {
+ if (err instanceof AiConversationViewTimeout) {
+ return reply.code(504).send({ error: 'oap_timeout', message: err.message });
+ }
+ if (err instanceof Error && err.name === 'AbortError') {
+ // The browser left before OAP answered; there is nobody to reply to.
+ return reply.code(499).send();
+ }
+ return reply
+ .code(502)
+ .send({ error: 'oap_unreachable', message: err instanceof Error ? err.message : String(err) });
+ }
+ if (upstream.status !== 200) {
+ // A problem document is small and complete; it goes as it came.
+ reply.code(upstream.status);
+ for (const [name, value] of Object.entries(passthroughHeaders(upstream.headers))) {
+ reply.header(name, value);
+ }
+ return reply.send(upstream.body);
+ }
+ let held;
+ try {
+ held = await holdDocument(upstream.body, upstream.headers['content-encoding'], { summary: !yaml });
+ } catch (err) {
+ if (reply.raw.destroyed || (err instanceof Error && err.name === 'AbortError')) {
+ return reply.code(499).send();
+ }
+ return reply
+ .code(502)
+ .send({ error: 'oap_unreachable', message: err instanceof Error ? err.message : String(err) });
+ }
+ reply.code(200);
+ const headers = passthroughHeaders(upstream.headers);
+ delete headers['content-length'];
+ for (const [name, value] of Object.entries(headers)) {
+ reply.header(name, value);
+ }
+ if (held.decodedBytes !== null) reply.header(AI_CONVERSATION_DOCUMENT_BYTES_HEADER, String(held.decodedBytes));
+ if (held.summary) reply.header(AI_CONVERSATION_DOCUMENT_SUMMARY_HEADER, JSON.stringify(held.summary));
+ return reply.send(held.body);
+ },
+ );
+}
diff --git a/apps/bff/src/http/query/instance.test.ts b/apps/bff/src/http/query/instance.test.ts
new file mode 100644
index 00000000..8d79bb58
--- /dev/null
+++ b/apps/bff/src/http/query/instance.test.ts
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { windowMinutesOf } from './instance.js';
+
+describe('windowMinutesOf', () => {
+ it('keeps the recent default when the caller says nothing, or nonsense', () => {
+ expect(windowMinutesOf(undefined)).toBe(60);
+ expect(windowMinutesOf('')).toBe(60);
+ expect(windowMinutesOf('soon')).toBe(60);
+ expect(windowMinutesOf('-5')).toBe(60);
+ });
+
+ it('honours a window of days and caps it at 90 days', () => {
+ expect(windowMinutesOf('10080')).toBe(10080);
+ expect(windowMinutesOf('129600')).toBe(129600);
+ expect(windowMinutesOf('999999')).toBe(129600);
+ expect(windowMinutesOf('61.9')).toBe(61);
+ });
+});
diff --git a/apps/bff/src/http/query/instance.ts b/apps/bff/src/http/query/instance.ts
index 1f46875f..3f9fd562 100644
--- a/apps/bff/src/http/query/instance.ts
+++ b/apps/bff/src/http/query/instance.ts
@@ -81,6 +81,14 @@ export interface InstancesResponse {
error?: string;
}
+/** The caller's window in minutes, or the default: whole, positive, and at
+ * most 90 days — the same ceiling the conversation list applies. */
+export function windowMinutesOf(raw: unknown): number {
+ const n = typeof raw === 'string' && raw.trim() ? Number(raw) : Number.NaN;
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_WINDOW_MIN;
+ return Math.min(Math.floor(n), 90 * 24 * 60);
+}
+
export function registerInstanceRoute(app: FastifyInstance, deps: InstanceRouteDeps): void {
const auth = requireAuth(deps);
app.get(
@@ -106,7 +114,10 @@ export function registerInstanceRoute(app: FastifyInstance, deps: InstanceRouteD
const signal = clientGone(reply);
const opts = buildOapOpts(cfgCurrent, deps.fetch, signal);
const offset = await getServerOffsetMinutes(deps.config, deps.fetch, signal);
- const window = defaultMinuteWindow(offset, DEFAULT_WINDOW_MIN);
+ // OAP lists the instances active in the window. A tab that owns a range
+ // of days (AI agent conversations: senders push hours apart) asks for it
+ // here; everyone else gets the recent default.
+ const window = defaultMinuteWindow(offset, windowMinutesOf((req.query as { windowMinutes?: string }).windowMinutes));
try {
const data = await graphqlPost<{ instances: OapInstance[] }>(opts, LIST_INSTANCES, {
serviceId,
diff --git a/apps/bff/src/http/query/menu-rows.regression.test.ts b/apps/bff/src/http/query/menu-rows.regression.test.ts
index 32626034..d8228486 100644
--- a/apps/bff/src/http/query/menu-rows.regression.test.ts
+++ b/apps/bff/src/http/query/menu-rows.regression.test.ts
@@ -115,13 +115,22 @@ function bundledLayers(): Array<{ key: string; legacy: Legacy; def: LayerDef }>
});
}
+/**
+ * Layers added AFTER the transcript, on a component the deleted code never
+ * knew. They have no "before" to compare against: the oracles resolve no row
+ * for them, which says nothing about a regression. Each is pinned on its own
+ * below, and a layer may only be added here together with that pin.
+ */
+const POST_TRANSCRIPT_LAYERS: ReadonlySet<string> = new Set(['AI_AGENT']);
+
describe('layer menu rows — bundled-template regression', () => {
it('covers every bundled layer', () => {
- expect(bundledLayers().length).toBe(44);
+ expect(bundledLayers().length).toBe(45);
});
it('resolves the same rows, in the same order, as the sidebar did', () => {
const diffs = bundledLayers()
+ .filter(({ key }) => !POST_TRANSCRIPT_LAYERS.has(key))
.map(({ key, legacy, def }) => ({
key,
was: oracleSidebarRows(legacy),
@@ -133,6 +142,7 @@ describe('layer menu rows — bundled-template regression', () => {
it('lands every layer on the tab it landed on before', () => {
const diffs = bundledLayers()
+ .filter(({ key }) => !POST_TRANSCRIPT_LAYERS.has(key))
.map(({ key, legacy, def }) => ({ key, was: oracleFirstTab(legacy), now: firstLayerMenuRow(def) }))
.filter((d) => d.was !== d.now);
expect(diffs).toEqual([]);
@@ -191,8 +201,8 @@ describe('default order vs. the component list', () => {
return resolveLayerMenuRows({ caps, slots: {}, traces: t.traces }).map((r) => r.path);
}
- it('covers all 16 components', () => {
- expect(COMPONENT_KEYS).toHaveLength(16);
+ it('covers all 17 components', () => {
+ expect(COMPONENT_KEYS).toHaveLength(17);
});
it('gives every component at least one row', () => {
@@ -206,7 +216,7 @@ describe('default order vs. the component list', () => {
});
it('is one row per component, plus the second trace row', () => {
- // 16 components → 17 rows: `traces` is the only one that resolves to
+ // 17 components → 18 rows: `traces` is the only one that resolves to
// two (native and Zipkin span formats get their own tabs).
expect(DEFAULT_LAYER_ROW_ORDER).toHaveLength(COMPONENT_KEYS.length + 1);
const multi = COMPONENT_KEYS.filter((k) => rowsForComponent(k).length > 1);
@@ -215,6 +225,23 @@ describe('default order vs. the component list', () => {
});
});
+describe('layers added after the transcript', () => {
+ it('lists exactly the post-transcript layers, so an addition here is deliberate', () => {
+ const keys = bundledLayers().map((l) => l.key);
+ for (const k of POST_TRANSCRIPT_LAYERS) expect(keys).toContain(k);
+ });
+
+ it('AI_AGENT is its Conversations tab: one row, the landing, and a direct link', () => {
+ const ai = bundledLayers().find((l) => l.key === 'AI_AGENT')!;
+ // The frozen sidebar had no row for it at all — which is why it is not a
+ // regression subject above.
+ expect(oracleSidebarRows(ai.legacy)).toEqual([]);
+ expect(resolveLayerMenuRows(ai.def).map((r) => r.path)).toEqual(['conversations']);
+ expect(firstLayerMenuRow(ai.def)).toBe('conversations');
+ expect(isSingleFeatureLayer(ai.def)).toBe(true);
+ });
+});
+
/**
* The four divergences the old code carried. None is reachable by a
* bundled layer — the suite above proves that — but a custom template can
diff --git a/apps/bff/src/http/query/menu.ts b/apps/bff/src/http/query/menu.ts
index 20aae56a..ff91d66c 100644
--- a/apps/bff/src/http/query/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -206,6 +206,8 @@ const LAYER_DEFAULTS: Record<string, { color: string; slots: LayerSlots; caps: L
VIRTUAL_CACHE: { color: 'var(--sw-warn)', slots: { services: 'Caches' }, caps: { dashboards: true } },
VIRTUAL_MQ: { color: 'var(--sw-ok)', slots: { services: 'Queues' }, caps: { dashboards: true } },
VIRTUAL_GENAI: { color: 'var(--sw-purple)', slots: { services: 'Providers', instances: 'Models' }, caps: { dashboards: true } },
+ // No metrics yet, so no Service page: the layer is its Conversations tab.
+ AI_AGENT: { color: 'var(--sw-purple)', slots: { services: 'Agent runtimes', instances: 'Senders' }, caps: { aiConversations: true } },
};
const DEFAULT_FOR_UNKNOWN_LAYER = {
diff --git a/apps/bff/src/logic/ai-conversation/hold-document.test.ts b/apps/bff/src/logic/ai-conversation/hold-document.test.ts
new file mode 100644
index 00000000..d3ccb153
--- /dev/null
+++ b/apps/bff/src/logic/ai-conversation/hold-document.test.ts
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Readable } from 'node:stream';
+import { gzipSync } from 'node:zlib';
+import { describe, expect, it } from 'vitest';
+import { holdDocument, summaryFromHead } from './hold-document';
+
+const SUMMARY = { title: 'a "quoted" title with } braces { and \\ slashes', state: 'verified', problems: ['round 2 } missing'], talks: 3, steps: 21, streams: 2, segments: 1, rounds: 1, unresolved: 0, kinds: { tool: 4 } };
+const DOC = JSON.stringify({ format: 'asz.view', version: '1.0', conversation: 'c1', sessions: ['s1'], head: { round: 1, digest: 'x' }, parser: 'v1', policy: 'v1', summary: SUMMARY, rounds: [], talks: [{ kind: 'talk', children: [] }] });
+
+describe('summaryFromHead', () => {
+ it('reads the counts off the top-level summary, past a title full of braces and quotes', () => {
+ expect(summaryFromHead(DOC)).toEqual({ talks: 3, steps: 21, streams: 2, segments: 1, rounds: 1, unresolved: 0 });
+ });
+
+ it('ignores a "summary" that is not a top-level key, and a head that cuts the object off', () => {
+ const nested = JSON.stringify({ format: 'asz.view', head: { summary: { talks: 1 } }, title: '"summary": {' });
+ expect(summaryFromHead(nested)).toBeNull();
+ expect(summaryFromHead(DOC.slice(0, DOC.indexOf('"steps"')))).toBeNull();
+ expect(summaryFromHead(JSON.stringify({ summary: { talks: 'three' } }))).toBeNull();
+ });
+});
+
+describe('holdDocument', () => {
+ it('holds a gzip body as sent, counts the decoded bytes, and reads the summary', async () => {
+ const gz = gzipSync(DOC);
+ const parts = [gz.subarray(0, 7), gz.subarray(7, 40), gz.subarray(40)];
+ const held = await holdDocument(Readable.from(parts), 'gzip', { summary: true });
+ expect(held.body.equals(gz)).toBe(true);
+ expect(held.decodedBytes).toBe(Buffer.byteLength(DOC));
+ expect(held.summary).toEqual({ talks: 3, steps: 21, streams: 2, segments: 1, rounds: 1, unresolved: 0 });
+ });
+
+ it('counts an identity body directly, and skips the summary when told to', async () => {
+ const held = await holdDocument(Readable.from([Buffer.from(DOC)]), undefined, { summary: false });
+ expect(held.decodedBytes).toBe(Buffer.byteLength(DOC));
+ expect(held.summary).toBeNull();
+ expect(held.body.toString('utf8')).toBe(DOC);
+ });
+
+ it('rejects a body that is not the gzip it claims to be, instead of crashing the process', async () => {
+ const parts = [Buffer.from('this is not'), Buffer.from(' gzip at all, however long it goes on')];
+ await expect(holdDocument(Readable.from(parts), 'gzip', { summary: true })).rejects.toThrow(/could not be decoded/);
+ });
+
+ it('holds an encoding it cannot decode without pretending to know its size', async () => {
+ const held = await holdDocument(Readable.from([Buffer.from('xx')]), 'br', { summary: true });
+ expect(held.decodedBytes).toBeNull();
+ expect(held.summary).toBeNull();
+ expect(held.body.toString()).toBe('xx');
+ });
+});
diff --git a/apps/bff/src/logic/ai-conversation/hold-document.ts b/apps/bff/src/logic/ai-conversation/hold-document.ts
new file mode 100644
index 00000000..e1814693
--- /dev/null
+++ b/apps/bff/src/logic/ai-conversation/hold-document.ts
@@ -0,0 +1,184 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { once } from 'node:events';
+import type { Readable } from 'node:stream';
+import { finished } from 'node:stream/promises';
+import { createGunzip } from 'node:zlib';
+import type { AiConversationDocumentSummary } from '@skywalking-horizon-ui/api-client';
+
+/**
+ * Holds a conversation document as OAP streams it, so the browser can be told
+ * its size before the first byte. OAP writes the document as it walks it and
+ * never knows the length; the BFF is the first party that does. What is held
+ * is the wire body (gzip, a fifth of the document), decoded once only to count
+ * and to read the summary off the head.
+ */
+export interface HeldDocument {
+ /** The body as OAP sent it, still encoded. */
+ body: Buffer;
+ /** Decoded length, or null when the encoding is one this cannot decode. */
+ decodedBytes: number | null;
+ summary: AiConversationDocumentSummary | null;
+}
+
+/** How much of the decoded head is kept for the summary scan. The summary sits
+ * after a handful of short fields; a title is the only free text before it. */
+const HEAD_BYTES = 256 * 1024;
+
+export async function holdDocument(
+ body: Readable,
+ contentEncoding: string | undefined,
+ opts: { summary: boolean },
+): Promise<HeldDocument> {
+ const encoding = (contentEncoding ?? '').trim().toLowerCase();
+ const decoder = encoding === 'gzip' ? createGunzip() : null;
+ const decodable = decoder !== null || encoding === '' || encoding === 'identity';
+ const chunks: Buffer[] = [];
+ const head: Buffer[] = [];
+ let raw = 0;
+ let decoded = 0;
+ let headLen = 0;
+ const onDecoded = (d: Buffer): void => {
+ decoded += d.length;
+ if (headLen < HEAD_BYTES) {
+ head.push(d);
+ headLen += d.length;
+ }
+ };
+ // The decoder's failure is caught the moment it can happen: a bad gzip
+ // rejects `finished` while the loop is still awaiting the body, and an
+ // unhandled rejection there takes the process down.
+ const failed: { error: Error | null } = { error: null };
+ let decoderDone: Promise<void> | null = null;
+ if (decoder) {
+ decoder.on('data', onDecoded);
+ decoderDone = finished(decoder).catch((err: unknown) => {
+ failed.error = err instanceof Error ? err : new Error(String(err));
+ });
+ }
+ for await (const chunk of body) {
+ const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
+ chunks.push(b);
+ raw += b.length;
+ if (decoder) {
+ if (failed.error || decoder.destroyed) break;
+ if (!decoder.write(b)) await once(decoder, 'drain').catch(() => undefined);
+ } else if (decodable) {
+ onDecoded(b);
+ }
+ }
+ if (decoder) {
+ if (!decoder.destroyed) decoder.end();
+ await decoderDone;
+ if (failed.error) throw new Error(`the document could not be decoded: ${failed.error.message}`);
+ }
+ const summary = opts.summary && decodable ? summaryFromHead(Buffer.concat(head, headLen).toString('utf8')) : null;
+ return { body: Buffer.concat(chunks, raw), decodedBytes: decodable ? decoded : null, summary };
+}
+
+const COUNTS = ['talks', 'steps', 'streams', 'segments', 'rounds', 'unresolved'] as const;
+
+/** The document's top-level `summary` object, read off its head without
+ * parsing the whole. Null when the head does not hold the complete object. */
+export function summaryFromHead(text: string): AiConversationDocumentSummary | null {
+ const start = topLevelKey(text, 'summary');
+ if (start < 0) return null;
+ const obj = objectAt(text, start);
+ if (!obj) return null;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(obj);
+ } catch {
+ return null;
+ }
+ if (!parsed || typeof parsed !== 'object') return null;
+ const s = parsed as Record<string, unknown>;
+ const out: Partial<AiConversationDocumentSummary> = {};
+ for (const k of COUNTS) {
+ const v = s[k];
+ if (typeof v !== 'number' || !Number.isFinite(v)) return null;
+ out[k] = v;
+ }
+ return out as AiConversationDocumentSummary;
+}
+
+/** Index of the `{` that opens the value of `key` at depth 1, or -1. Strings
+ * are skipped, so a title that happens to contain the key cannot match. */
+function topLevelKey(text: string, key: string): number {
+ let depth = 0;
+ let i = 0;
+ while (i < text.length) {
+ const c = text[i];
+ if (c === '"') {
+ const end = stringEnd(text, i);
+ if (end < 0) return -1;
+ if (depth === 1 && text.slice(i + 1, end) === key) {
+ let j = end + 1;
+ while (j < text.length && /\s/.test(text[j]!)) j++;
+ if (text[j] === ':') {
+ j++;
+ while (j < text.length && /\s/.test(text[j]!)) j++;
+ if (text[j] === '{') return j;
+ }
+ }
+ i = end + 1;
+ continue;
+ }
+ if (c === '{' || c === '[') depth++;
+ else if (c === '}' || c === ']') depth--;
+ i++;
+ }
+ return -1;
+}
+
+/** The balanced object starting at `start`, or null when it is cut off. */
+function objectAt(text: string, start: number): string | null {
+ let depth = 0;
+ let i = start;
+ while (i < text.length) {
+ const c = text[i];
+ if (c === '"') {
+ const end = stringEnd(text, i);
+ if (end < 0) return null;
+ i = end + 1;
+ continue;
+ }
+ if (c === '{') depth++;
+ else if (c === '}') {
+ depth--;
+ if (depth === 0) return text.slice(start, i + 1);
+ }
+ i++;
+ }
+ return null;
+}
+
+/** Index of the closing quote of the JSON string opening at `at`, or -1. */
+function stringEnd(text: string, at: number): number {
+ let i = at + 1;
+ while (i < text.length) {
+ const c = text[i];
+ if (c === '\\') {
+ i += 2;
+ continue;
+ }
+ if (c === '"') return i;
+ i++;
+ }
+ return -1;
+}
diff --git a/apps/bff/src/logic/layers/caps.ts b/apps/bff/src/logic/layers/caps.ts
index a223007f..1a17d952 100644
--- a/apps/bff/src/logic/layers/caps.ts
+++ b/apps/bff/src/logic/layers/caps.ts
@@ -60,6 +60,7 @@ export function componentsToCaps(components: LayerComponentFlags): LayerCaps {
pprofProfiling: !!components.pprofProfiling,
continuousProfiling: !!components.continuousProfiling,
podLogs: !!components.podLogs,
+ aiConversations: !!components.aiConversations,
events: false,
};
}
diff --git a/apps/bff/src/logic/layers/loader.ts b/apps/bff/src/logic/layers/loader.ts
index 27ae119f..c4144d21 100644
--- a/apps/bff/src/logic/layers/loader.ts
+++ b/apps/bff/src/logic/layers/loader.ts
@@ -84,6 +84,9 @@ export interface LayerComponentFlags {
* within one service. Opt-in; the tab also requires a
* `deployment` config block. */
deployment?: boolean;
+ /** AI agent conversations — the conversations the AI Sessionizer pushed
+ * under this layer (`AI_AGENT`), listed per agent runtime. */
+ aiConversations?: boolean;
}
export interface LayerSlotsConfig {
diff --git a/apps/bff/src/logic/oap/capabilities.test.ts b/apps/bff/src/logic/oap/capabilities.test.ts
index 51e63882..55647f6a 100644
--- a/apps/bff/src/logic/oap/capabilities.test.ts
+++ b/apps/bff/src/logic/oap/capabilities.test.ts
@@ -57,6 +57,7 @@ describe('OAP capability probe', () => {
await expect(getOapCapabilities(config, oap.fetch)).resolves.toEqual({
queryAlarms: true,
logKeywords: true,
+ aiConversations: false,
});
});
@@ -83,6 +84,7 @@ describe('OAP capability probe', () => {
await expect(getOapCapabilities(config, fetch)).resolves.toEqual({
queryAlarms: false,
logKeywords: false,
+ aiConversations: false,
});
});
@@ -95,7 +97,7 @@ describe('OAP capability probe', () => {
const all = await Promise.all(
Array.from({ length: 6 }, () => getOapCapabilities(config, oap.fetch)),
);
- for (const caps of all) expect(caps).toEqual({ queryAlarms: true, logKeywords: true });
+ for (const caps of all) expect(caps).toEqual({ queryAlarms: true, logKeywords: true, aiConversations: false });
expect(oap.asks(), 'each caller ran its own keyword probe').toBe(1);
});
@@ -110,6 +112,7 @@ describe('OAP capability probe', () => {
await expect(getOapCapabilities(config, oap.fetch)).resolves.toEqual({
queryAlarms: true,
logKeywords: true,
+ aiConversations: false,
});
});
diff --git a/apps/bff/src/logic/oap/capabilities.ts b/apps/bff/src/logic/oap/capabilities.ts
index 920c4cb1..9e3b6644 100644
--- a/apps/bff/src/logic/oap/capabilities.ts
+++ b/apps/bff/src/logic/oap/capabilities.ts
@@ -45,6 +45,8 @@ export interface OapCapabilities {
* Decided by the storage, not the OAP version — ElasticSearch yes, the
* others no — so two OAPs on the same build can disagree. */
logKeywords: boolean;
+ /** `Query.listConversations` — AI agent conversations (OAP 11.1.0+). */
+ aiConversations: boolean;
}
const INTROSPECTION_QUERY = /* GraphQL */ `
@@ -135,7 +137,7 @@ async function probeCapabilities(
INTROSPECTION_QUERY,
);
} catch {
- const conservative: OapCapabilities = { queryAlarms: false, logKeywords: false };
+ const conservative: OapCapabilities = { queryAlarms: false, logKeywords: false, aiConversations: false };
cache = { result: conservative, fetchedAt: now - CAPS_TTL_MS + CAPS_FAILURE_TTL_MS };
return conservative;
}
@@ -163,6 +165,7 @@ async function probeCapabilities(
const result: OapCapabilities = {
queryAlarms: fieldSet.has('queryAlarms'),
logKeywords,
+ aiConversations: fieldSet.has('listConversations'),
};
// A false that came from a TIMEOUT expires on the short TTL, not the long
// one: caching it for the full window would hide content search on a
diff --git a/apps/bff/src/logic/templates/bundled-schema.ts b/apps/bff/src/logic/templates/bundled-schema.ts
index 00d8535d..62f2b830 100644
--- a/apps/bff/src/logic/templates/bundled-schema.ts
+++ b/apps/bff/src/logic/templates/bundled-schema.ts
@@ -127,6 +127,7 @@ export const componentsSchema = z
continuousProfiling: z.boolean().optional(),
podLogs: z.boolean().optional(),
deployment: z.boolean().optional(),
+ aiConversations: z.boolean().optional(),
})
.strict();
diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts
index ca597a04..b497e955 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -165,6 +165,9 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
'POST /api/events': 'events:read',
+ 'POST /api/layer/:key/ai-conversations': 'ai-conversation:read',
+ 'GET /api/ai-conversation/:conversation/view': 'ai-conversation:read',
+
'POST /api/explore/query': 'inspect:read',
'GET /api/layer/:key/topology': 'topology:read',
diff --git a/apps/bff/src/rbac/verbs.ts b/apps/bff/src/rbac/verbs.ts
index 3b220cea..b1804e26 100644
--- a/apps/bff/src/rbac/verbs.ts
+++ b/apps/bff/src/rbac/verbs.ts
@@ -29,6 +29,13 @@ export const VERBS = {
tracesRead: 'traces:read',
logsRead: 'logs:read',
browserErrorsRead: 'browser-errors:read',
+ /** AI AGENT conversations — the `AI_AGENT` layer's transcripts, landed by the
+ * AI Sessionizer. Its own area, not `ai:*`: `ai:read` is the ASSISTANT chat,
+ * where Horizon sends a conversation to a model provider; this is reading
+ * stored transcripts of other agents, and a deployment may allow either
+ * without the other. Under `*:read` like `logs:read`, whose payloads it
+ * resembles. */
+ aiConversationRead: 'ai-conversation:read',
eventsRead: 'events:read',
topologyRead: 'topology:read',
profileRead: 'profile:read',
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 470bc143..80beecd5 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -51,6 +51,7 @@ import { registerZipkinRoutes } from './http/query/zipkin.js';
import { registerLogRoute } from './http/query/log.js';
import { registerBrowserErrorsRoute } from './http/query/browser-errors.js';
import { registerEventsRoute } from './http/query/events.js';
+import { registerAiConversationRoutes } from './http/query/ai-conversation.js';
import { registerExploreRoutes } from './http/query/explore.js';
import { registerPodLogRoutes } from './http/query/pod-log.js';
import { registerDashboardQueryRoute } from './http/query/dashboard.js';
@@ -359,6 +360,7 @@ registerZipkinRoutes(app, { ...authDeps });
registerLogRoute(app, { ...authDeps });
registerBrowserErrorsRoute(app, { ...authDeps });
registerEventsRoute(app, { ...authDeps });
+registerAiConversationRoutes(app, { ...authDeps });
registerExploreRoutes(app, { ...authDeps });
registerPodLogRoutes(app, { ...authDeps });
registerDashboardQueryRoute(app, {
diff --git a/apps/ui/package.json b/apps/ui/package.json
index 6a75c0e6..b0591cc2 100644
--- a/apps/ui/package.json
+++ b/apps/ui/package.json
@@ -20,6 +20,7 @@
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@skywalking-horizon-ui/api-client": "workspace:*",
+ "@skywalking-horizon-ui/conversation-view": "workspace:*",
"@skywalking-horizon-ui/design-tokens": "workspace:*",
"@tanstack/vue-query": "^5.101.4",
"@tresjs/cientos": "^5.8.1",
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 75101036..935ba336 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -83,6 +83,7 @@ import { TraceApi } from './scopes/trace';
import { ZipkinApi } from './scopes/zipkin';
import { LogApi } from './scopes/log';
import { BrowserErrorsApi } from './scopes/browser-errors';
+import { AiConversationApi } from './scopes/ai-conversation';
import { EventsApi } from './scopes/events';
import { ProfileApi } from './scopes/profile';
import { EbpfApi } from './scopes/ebpf';
@@ -106,6 +107,11 @@ import { AdminUsersApi } from './scopes/admin-users';
import { TemplateSyncApi } from './scopes/template-sync';
import { AiApi } from './scopes/ai';
export type { AiConfigResponse } from './scopes/ai';
+export type {
+ AiConversationRow,
+ AiConversationsQueryRequest,
+ AiConversationsResponse,
+} from '@skywalking-horizon-ui/api-client';
export type { ConsentRequest } from './scopes/oauth';
export type { SsoProvider } from './scopes/oidc';
@@ -1012,6 +1018,7 @@ export class BffClient {
readonly zipkin = new ZipkinApi(this);
readonly log = new LogApi(this);
readonly browserErrors = new BrowserErrorsApi(this);
+ readonly aiConversation = new AiConversationApi(this);
readonly events = new EventsApi(this);
readonly profile = new ProfileApi(this);
readonly continuousProfiling = new ContinuousProfilingApi(this);
diff --git a/apps/ui/src/api/scopes/ai-conversation.test.ts b/apps/ui/src/api/scopes/ai-conversation.test.ts
new file mode 100644
index 00000000..25ca0c65
--- /dev/null
+++ b/apps/ui/src/api/scopes/ai-conversation.test.ts
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+// The scope imports `withBase` from the client, and the client constructs every
+// scope at load: the client has to be the module that starts the cycle.
+import '../client';
+import { AiConversationApi, AiConversationViewError } from './ai-conversation';
+import type { BffClient } from '../client';
+
+function api(): { api: AiConversationApi; unauthorized: ReturnType<typeof vi.fn> } {
+ const unauthorized = vi.fn();
+ const bff = { handleUnauthorized: unauthorized, request: vi.fn() } as unknown as BffClient;
+ return { api: new AiConversationApi(bff), unauthorized };
+}
+
+function streamOf(parts: string[]): ReadableStream<Uint8Array> {
+ const enc = new TextEncoder();
+ return new ReadableStream({
+ start(controller) {
+ for (const p of parts) controller.enqueue(enc.encode(p));
+ controller.close();
+ },
+ });
+}
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe('bff.aiConversation.view', () => {
+ it('asks for the JSON document with the session cookie and reports the bytes as they stream', async () => {
+ const doc = { format: 'asz.view', version: '1.0', conversation: 'c1', talks: [] };
+ const text = JSON.stringify(doc);
+ const summary = { talks: 3, steps: 21, streams: 2, segments: 1, rounds: 1, unresolved: 0 };
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(streamOf([text.slice(0, 10), text.slice(10)]), {
+ status: 200,
+ headers: {
+ 'content-type': 'application/vnd.skywalking.asz.view+json; version=1.0',
+ 'x-horizon-document-bytes': String(text.length),
+ 'x-horizon-document-summary': JSON.stringify(summary),
+ },
+ }),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+ const seen: Array<[string, number]> = [];
+ const { api: a } = api();
+ const r = await a.view('c1', { service: 'Claude Code', instance: 'me@host' }, { onProgress: (p) => seen.push([p.phase, p.bytes]) });
+ expect(r.document).toEqual(doc);
+ expect(r.bytes).toBe(text.length);
+ expect(r.total).toBe(text.length);
+ expect(r.summary).toEqual(summary);
+ expect(seen).toEqual([['receiving', 0], ['receiving', 10], ['receiving', text.length], ['parsing', text.length]]);
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(url).toBe('/api/ai-conversation/c1/view?service=Claude+Code&instance=me%40host');
+ expect(init.credentials).toBe('include');
+ expect((init.headers as Record<string, string>).accept).toBe('application/vnd.skywalking.asz.view+json');
+ });
+
+ it('reads a document from a BFF that states no size, with nothing to show against', async () => {
+ const text = JSON.stringify({ format: 'asz.view', version: '1.0' });
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(streamOf([text]), { status: 200 })));
+ const seen: Array<number | null> = [];
+ const r = await api().api.view('c1', { service: 's' }, { onProgress: (p) => seen.push(p.total) });
+ expect(r.total).toBeNull();
+ expect(r.summary).toBeNull();
+ expect(seen.every((t) => t === null)).toBe(true);
+ });
+
+ it("turns OAP's problem document into a typed failure carrying its detail", async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ title: 'Not Found', status: 404, detail: 'no round of conversation c9 for service Claude Code' }), {
+ status: 404,
+ headers: { 'content-type': 'application/problem+json' },
+ }),
+ ),
+ );
+ const { api: a } = api();
+ const err = await a.view('c9', { service: 'Claude Code' }).catch((e: unknown) => e);
+ expect(err).toBeInstanceOf(AiConversationViewError);
+ expect((err as AiConversationViewError).kind).toBe('not_found');
+ expect((err as AiConversationViewError).detail).toContain('no round of conversation c9');
+ });
+
+ it('tells a 404 from an OAP without the route apart from a conversation OAP does not hold', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } })));
+ const err = (await api().api.view('c1', { service: 's' }).catch((e: unknown) => e)) as AiConversationViewError;
+ expect(err.kind).toBe('not_served');
+ expect(err.status).toBe(404);
+ });
+
+ it("names the BFF's own refusals: a timeout, an unreachable OAP, a missing permission", async () => {
+ const cases: Array<[number, Record<string, unknown>, string]> = [
+ [504, { error: 'oap_timeout', message: 'no first byte within 120000 ms' }, 'timeout'],
+ [502, { error: 'oap_unreachable', message: 'ECONNREFUSED' }, 'unreachable'],
+ [403, { error: 'permission_denied', verb: 'ai-conversation:read' }, 'forbidden'],
+ ];
+ for (const [status, body, kind] of cases) {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })));
+ const { api: a } = api();
+ const err = (await a.view('c1', { service: 's' }).catch((e: unknown) => e)) as AiConversationViewError;
+ expect(err.kind).toBe(kind);
+ expect(err.status).toBe(status);
+ }
+ });
+
+ it('ends the session on 401 like every other call', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 401 })));
+ const { api: a, unauthorized } = api();
+ const err = (await a.view('c1', { service: 's' }).catch((e: unknown) => e)) as AiConversationViewError;
+ expect(err.kind).toBe('unauthenticated');
+ expect(unauthorized).toHaveBeenCalledTimes(1);
+ });
+
+ it('refuses a document of another format or major version, and a body that is not JSON', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ format: 'asz.view', version: '2.0' }), { status: 200 })));
+ let err = (await api().api.view('c1', { service: 's' }).catch((e: unknown) => e)) as AiConversationViewError;
+ expect(err.kind).toBe('unsupported');
+ expect(err.message).toContain('asz.view 2.0');
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('<html>', { status: 200 })));
+ err = (await api().api.view('c1', { service: 's' }).catch((e: unknown) => e)) as AiConversationViewError;
+ expect(err.kind).toBe('unsupported');
+ });
+});
diff --git a/apps/ui/src/api/scopes/ai-conversation.ts b/apps/ui/src/api/scopes/ai-conversation.ts
new file mode 100644
index 00000000..3a946265
--- /dev/null
+++ b/apps/ui/src/api/scopes/ai-conversation.ts
@@ -0,0 +1,240 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ AI_CONVERSATION_DOCUMENT_BYTES_HEADER,
+ AI_CONVERSATION_DOCUMENT_SUMMARY_HEADER,
+ ASZ_VIEW_FORMAT,
+ ASZ_VIEW_JSON_MEDIA_TYPE,
+ ASZ_VIEW_MAJOR_VERSION,
+ type AiConversationDocumentSummary,
+ type AiConversationsQueryRequest,
+ type AiConversationsResponse,
+} from '@skywalking-horizon-ui/api-client';
+import type { BffClient } from '../client';
+import { withBase } from '../client';
+import { pushEvent } from '@/controls/eventLog';
+
+/** Why a conversation document could not be read. `not_found` and
+ * `bad_request` are OAP's own answers (RFC 9457 problems); `timeout` and
+ * `unreachable` are the BFF's, when OAP took longer than its budget or was
+ * not there; `unsupported` is a document of another format or major version. */
+export type AiConversationViewFailure =
+ | 'bad_request'
+ | 'not_found'
+ /** A 404 with no problem document: OAP has no conversation route at all,
+ * which is an OAP older than 11.1.0. */
+ | 'not_served'
+ | 'forbidden'
+ | 'unauthenticated'
+ | 'timeout'
+ | 'unreachable'
+ | 'unsupported'
+ | 'network'
+ | 'http';
+
+export class AiConversationViewError extends Error {
+ constructor(
+ readonly kind: AiConversationViewFailure,
+ readonly status: number,
+ message: string,
+ /** The problem's `detail`, or the BFF's `message`, when either was sent. */
+ readonly detail: string | null = null,
+ ) {
+ super(message);
+ this.name = 'AiConversationViewError';
+ }
+}
+
+export interface AiConversationViewProgress {
+ /** `receiving` from the first byte on; `parsing` once every byte is in and
+ * the document is being read. */
+ phase: 'receiving' | 'parsing';
+ /** Decoded bytes received so far. */
+ bytes: number;
+ /** The document's decoded size, as the BFF states it ahead of the bytes;
+ * null when it did not (an older BFF, or an encoding it could not count). */
+ total: number | null;
+ /** The document's counts, read by the BFF off its head; null when absent. */
+ summary: AiConversationDocumentSummary | null;
+}
+
+export interface AiConversationViewResult {
+ /** The parsed document. Its `format` and major `version` are checked here;
+ * the page narrows the type with the renderer's own guard. */
+ document: unknown;
+ bytes: number;
+ total: number | null;
+ summary: AiConversationDocumentSummary | null;
+ elapsedMs: number;
+}
+
+/** `bff.aiConversation` — the AI agent conversations of an `AI_AGENT` layer
+ * service: the list, one row per conversation, and the conversation document
+ * itself. The document is a streamed relay of up to tens of megabytes whose
+ * first byte arrives only once OAP has folded the whole chain, so it bypasses
+ * the JSON façade to report progress while the body streams in. */
+export class AiConversationApi {
+ constructor(private readonly bff: BffClient) {}
+
+ list(
+ layerKey: string,
+ body: AiConversationsQueryRequest,
+ signal?: AbortSignal,
+ ): Promise<AiConversationsResponse> {
+ return this.bff.request<AiConversationsResponse>(
+ 'POST',
+ `/api/layer/${encodeURIComponent(layerKey)}/ai-conversations`,
+ body,
+ undefined,
+ signal,
+ );
+ }
+
+ /** The BFF path of one conversation document, as a link a browser can open. */
+ viewPath(conversation: string, q: { service: string; instance?: string }): string {
+ const params = new URLSearchParams({ service: q.service });
+ if (q.instance) params.set('instance', q.instance);
+ return `/api/ai-conversation/${encodeURIComponent(conversation)}/view?${params}`;
+ }
+
+ async view(
+ conversation: string,
+ q: { service: string; instance?: string },
+ opts: { signal?: AbortSignal; onProgress?: (p: AiConversationViewProgress) => void } = {},
+ ): Promise<AiConversationViewResult> {
+ const path = this.viewPath(conversation, q);
+ const started = performance.now();
+ let res: Response;
+ try {
+ res = await fetch(withBase(path), {
+ credentials: 'include',
+ headers: { accept: ASZ_VIEW_JSON_MEDIA_TYPE },
+ ...(opts.signal ? { signal: opts.signal } : {}),
+ });
+ } catch (err) {
+ if (err instanceof Error && err.name === 'AbortError') throw err;
+ const detail = err instanceof Error ? err.message : String(err);
+ pushEvent('api', 'err', `GET ${path} · network ${detail}`);
+ throw new AiConversationViewError('network', 0, `Cannot reach the server — the BFF is unreachable (${detail}).`);
+ }
+ if (res.status === 401) {
+ this.bff.handleUnauthorized();
+ pushEvent('api', 'info', `GET ${path} · 401 (re-auth)`);
+ throw new AiConversationViewError('unauthenticated', 401, 'unauthenticated');
+ }
+ if (!res.ok) {
+ throw await failureOf(res, path);
+ }
+ const total = sizeHeader(res.headers.get(AI_CONVERSATION_DOCUMENT_BYTES_HEADER));
+ const summary = summaryHeader(res.headers.get(AI_CONVERSATION_DOCUMENT_SUMMARY_HEADER));
+ const chunks: Uint8Array[] = [];
+ let bytes = 0;
+ // The headers are the first byte: the page can leave its waiting state now.
+ opts.onProgress?.({ phase: 'receiving', bytes, total, summary });
+ if (res.body) {
+ const reader = res.body.getReader();
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ chunks.push(value);
+ bytes += value.byteLength;
+ opts.onProgress?.({ phase: 'receiving', bytes, total, summary });
+ }
+ } else {
+ const one = new Uint8Array(await res.arrayBuffer());
+ chunks.push(one);
+ bytes = one.byteLength;
+ opts.onProgress?.({ phase: 'receiving', bytes, total, summary });
+ }
+ opts.onProgress?.({ phase: 'parsing', bytes, total, summary });
+ const whole = new Uint8Array(bytes);
+ let at = 0;
+ for (const c of chunks) {
+ whole.set(c, at);
+ at += c.byteLength;
+ }
+ let document: unknown;
+ try {
+ document = JSON.parse(new TextDecoder().decode(whole));
+ } catch (err) {
+ pushEvent('api', 'err', `GET ${path} · body is not JSON`);
+ throw new AiConversationViewError('unsupported', res.status, 'The conversation document is not JSON.', err instanceof Error ? err.message : null);
+ }
+ const head = document as { format?: unknown; version?: unknown } | null;
+ const major = typeof head?.version === 'string' ? Number(head.version.split('.')[0]) : NaN;
+ if (!head || head.format !== ASZ_VIEW_FORMAT || major !== ASZ_VIEW_MAJOR_VERSION) {
+ throw new AiConversationViewError(
+ 'unsupported',
+ res.status,
+ `Unsupported conversation document: ${String(head?.format)} ${String(head?.version)}.`,
+ );
+ }
+ return { document, bytes, total, summary, elapsedMs: Math.round(performance.now() - started) };
+ }
+}
+
+function sizeHeader(v: string | null): number | null {
+ const n = v ? Number(v) : NaN;
+ return Number.isFinite(n) && n > 0 ? n : null;
+}
+
+function summaryHeader(v: string | null): AiConversationDocumentSummary | null {
+ if (!v) return null;
+ try {
+ const s = JSON.parse(v) as Record<string, unknown>;
+ const keys = ['talks', 'steps', 'streams', 'segments', 'rounds', 'unresolved'] as const;
+ if (keys.every((k) => typeof s[k] === 'number')) return s as unknown as AiConversationDocumentSummary;
+ } catch {
+ /* a header this BFF did not write */
+ }
+ return null;
+}
+
+/** OAP answers with an RFC 9457 problem (`title`, `detail`, `status`); the
+ * BFF's own refusals carry `{ error, message }`. Either way the operator gets
+ * the sentence the server wrote, not just the number. */
+async function failureOf(res: Response, path: string): Promise<AiConversationViewError> {
+ const raw = await res.text().catch(() => '');
+ let body: Record<string, unknown> | null = null;
+ try {
+ const parsed: unknown = raw ? JSON.parse(raw) : null;
+ if (parsed && typeof parsed === 'object') body = parsed as Record<string, unknown>;
+ } catch {
+ body = null;
+ }
+ const str = (k: string): string | null => (typeof body?.[k] === 'string' ? (body[k] as string) : null);
+ const detail = str('detail') ?? str('message');
+ const title = str('title') ?? str('error') ?? `HTTP ${res.status}`;
+ const problem = str('title') !== null || str('detail') !== null;
+ const kind: AiConversationViewFailure =
+ res.status === 400
+ ? 'bad_request'
+ : res.status === 403
+ ? 'forbidden'
+ : res.status === 404
+ ? problem
+ ? 'not_found'
+ : 'not_served'
+ : res.status === 504 || str('error') === 'oap_timeout'
+ ? 'timeout'
+ : res.status === 502 || str('error') === 'oap_unreachable'
+ ? 'unreachable'
+ : 'http';
+ pushEvent('api', 'err', `GET ${path} · ${res.status} ${title}`);
+ return new AiConversationViewError(kind, res.status, title, detail);
+}
diff --git a/apps/ui/src/api/scopes/layer.test.ts b/apps/ui/src/api/scopes/layer.test.ts
index 6912623e..20ecb6c3 100644
--- a/apps/ui/src/api/scopes/layer.test.ts
+++ b/apps/ui/src/api/scopes/layer.test.ts
@@ -228,6 +228,14 @@ describe('LayerApi.endpoints / instances — entity query params', () => {
expect(qs.get('serviceId')).toBe(REVIEWS.id);
expect(qs.get('service')).toBe(REVIEWS.name);
});
+
+ it('instances carries the window a tab owns, and nothing when none is given', async () => {
+ const { bff, calls } = makeStub();
+ await new LayerApi(bff).instances('ai_agent', REVIEWS, { windowMinutes: 129600 });
+ expect(String(calls[0][1])).toContain('windowMinutes=129600');
+ await new LayerApi(bff).instances('ai_agent', REVIEWS);
+ expect(String(calls[1][1])).not.toContain('windowMinutes');
+ });
});
describe('LayerApi.topology / endpointDependency', () => {
diff --git a/apps/ui/src/api/scopes/layer.ts b/apps/ui/src/api/scopes/layer.ts
index 884ed6f5..c04467f6 100644
--- a/apps/ui/src/api/scopes/layer.ts
+++ b/apps/ui/src/api/scopes/layer.ts
@@ -196,6 +196,9 @@ export class LayerApi {
instances(
layerKey: string,
service: ServiceRef,
+ /** How far back an instance may have been active, in minutes. Omitted
+ * means the BFF's recent default. */
+ opts: { windowMinutes?: number } = {},
): Promise<{
layer: string;
service: string;
@@ -210,6 +213,7 @@ export class LayerApi {
error?: string;
}> {
const qs = new URLSearchParams(serviceRefFields(service));
+ if (opts.windowMinutes) qs.set('windowMinutes', String(opts.windowMinutes));
return this.bff.request(
'GET',
`/api/layer/${encodeURIComponent(layerKey)}/instances?${qs.toString()}`,
diff --git a/apps/ui/src/components/primitives/ProgressBar.vue b/apps/ui/src/components/primitives/ProgressBar.vue
new file mode 100644
index 00000000..437c85fd
--- /dev/null
+++ b/apps/ui/src/components/primitives/ProgressBar.vue
@@ -0,0 +1,58 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!--
+ A thin progress bar on the accent colour. `value` is the fraction done
+ (0–1); with no value the bar is indeterminate and a highlight travels along
+ it, which tells the reader the wait is alive when nothing can be counted.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+
+const props = defineProps<{
+ value?: number | null;
+ label?: string;
+}>();
+
+const fraction = computed<number | null>(() =>
+ typeof props.value === 'number' && Number.isFinite(props.value) ? Math.min(1, Math.max(0, props.value)) : null,
+);
+</script>
+
+<template>
+ <div
+ class="sw-progress"
+ :class="{ indeterminate: fraction === null }"
+ role="progressbar"
+ :aria-label="label"
+ :aria-valuemin="0"
+ :aria-valuemax="100"
+ :aria-valuenow="fraction === null ? undefined : Math.round(fraction * 100)"
+ >
+ <i :style="fraction === null ? undefined : { width: `${fraction * 100}%` }" />
+ </div>
+</template>
+
+<style scoped>
+.sw-progress { position: relative; height: 6px; overflow: hidden; border-radius: 3px; background: color-mix(in srgb, var(--sw-fg-0) 10%, transparent); }
+.sw-progress > i { position: absolute; top: 0; bottom: 0; left: 0; border-radius: 3px; background: var(--sw-accent); transition: width 0.25s ease-out; }
+.sw-progress.indeterminate > i { width: 28%; background: linear-gradient(90deg, transparent, var(--sw-accent), transparent); animation: sw-progress-slide 1.4s linear infinite; }
+@keyframes sw-progress-slide { from { left: -28%; } to { left: 100%; } }
+@media (prefers-reduced-motion: reduce) {
+ .sw-progress.indeterminate > i { animation: none; left: 36%; }
+}
+</style>
diff --git a/apps/ui/src/features/admin/roles/RolesView.vue b/apps/ui/src/features/admin/roles/RolesView.vue
index c7c95a0d..d03b1642 100644
--- a/apps/ui/src/features/admin/roles/RolesView.vue
+++ b/apps/ui/src/features/admin/roles/RolesView.vue
@@ -191,6 +191,7 @@ const VERB_LABELS = computed<Record<string, { label: string; hint?: string }>>((
'ttl:read': { label: t('See data retention settings') },
'config:read': { label: t('See the OAP runtime configuration') },
'browser-errors:read': { label: t('See browser error logs') },
+ 'ai-conversation:read': { label: t('See AI agent conversations') },
'source-map:write': { label: t('Upload and remove source maps') },
'ai:read': { label: t('Use the AI assistant') },
'mcp:read': { label: t('Connect an external agent over MCP') },
@@ -256,8 +257,9 @@ const VERB_GROUPS = computed<VerbGroup[]>(() => [
{ label: t('3D infrastructure map'), icon: '⬡' },
{ label: t('Alarms'), icon: '!' },
{ label: t('Service events'), icon: '◔' },
+ { label: t('AI agent conversations'), icon: '◍' },
],
- verbs: ['metrics:read', 'alarms:read', 'events:read', 'traces:read', 'logs:read', 'topology:read', 'profile:read', 'infra-3d:read'],
+ verbs: ['metrics:read', 'alarms:read', 'events:read', 'traces:read', 'logs:read', 'topology:read', 'profile:read', 'infra-3d:read', 'ai-conversation:read'],
},
{
title: t('Platform monitoring'),
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index e124c311..dde7fbba 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -2921,5 +2921,224 @@
"JSON": "JSON",
"(empty — type an expression to run it)": "(leer – geben Sie einen Ausdruck ein, um ihn auszuführen)",
"Could not read the instances of this service.": "Die Instanzen dieses Services konnten nicht gelesen werden.",
- "Could not read the endpoints of this service.": "Die Endpunkte dieses Services konnten nicht gelesen werden."
+ "Could not read the endpoints of this service.": "Die Endpunkte dieses Services konnten nicht gelesen werden.",
+ "See AI agent conversations": "KI-Agenten-Konversationen ansehen",
+ "AI agent conversations": "KI-Agenten-Konversationen",
+ "Conversations": "Konversationen",
+ "Sender": "Absender",
+ "The conversation list could not be read.": "Die Konversationsliste konnte nicht gelesen werden.",
+ "{n} conversations": "{n} Konversationen",
+ "Listed from the newest {n} rounds in the window; a conversation whose newest round is older than that is not shown.": "Aus den neuesten {n} Runden im Zeitraum aufgelistet; eine Konversation, deren neueste Runde älter ist, wird nicht angezeigt.",
+ "No conversations in this window.": "Keine Konversationen in diesem Zeitraum.",
+ "Talks": "Gespräche",
+ "Steps": "Schritte",
+ "Streams": "Streams",
+ "Unresolved": "Ungelöst",
+ "Last activity": "Letzte Aktivität",
+ "(untitled)": "(ohne Titel)",
+ "The link is incomplete: it names no agent runtime.": "Der Link ist unvollständig: Er nennt keine Agent-Laufzeit.",
+ "OAP holds no round of this conversation for this runtime.": "OAP hat für diese Laufzeit keine Runde dieser Konversation.",
+ "Your role lacks the ai-conversation:read permission.": "Ihrer Rolle fehlt die Berechtigung ai-conversation:read.",
+ "OAP did not answer within its time budget. The conversation may be very large; try again.": "OAP hat nicht innerhalb seines Zeitbudgets geantwortet. Die Konversation ist möglicherweise sehr groß; versuchen Sie es erneut.",
+ "This Horizon cannot read the document OAP sent.": "Dieses Horizon kann das von OAP gesendete Dokument nicht lesen.",
+ "The conversation could not be read.": "Die Konversation konnte nicht gelesen werden.",
+ "AI agent conversation": "KI-Agent-Konversation",
+ "All conversations": "Alle Konversationen",
+ "Reading the conversation…": "Konversation wird gelesen…",
+ "{mb} MB received": "{mb} MB empfangen",
+ "Try again": "Erneut versuchen",
+ "unavailable": "nicht verfügbar",
+ "Open the conversation in a new tab": "Konversation in neuem Tab öffnen",
+ "round": "Runde",
+ "streams": "Streams",
+ "talks": "Gespräche",
+ "unresolved": "ungelöst",
+ "Session": "Sitzung",
+ "runs": "Läufe",
+ "Child streams": "Kind-Streams",
+ "subagents, each its own context": "Subagenten, jeder mit eigenem Kontext",
+ "Relations": "Beziehungen",
+ "activity windows": "Aktivitätsfenster",
+ "verified": "verifiziert",
+ "incomplete": "unvollständig",
+ "mismatch": "Abweichung",
+ "problems": "Probleme",
+ "rounds verified": "Runden verifiziert",
+ "No talk matches.": "Kein Gespräch passt.",
+ "Talk": "Gespräch",
+ "main stream, child work folded": "Haupt-Stream, Kind-Arbeit eingeklappt",
+ "child stream": "Kind-Stream",
+ "tools": "Tools",
+ "External · Input": "Extern · Eingabe",
+ "no opening line": "kein Eröffnungssatz",
+ "delegated work": "delegierte Arbeit",
+ "show what the agent did": "zeigen, was der Agent getan hat",
+ "hide what the agent did": "verbergen, was der Agent getan hat",
+ "quiet": "Ruhe",
+ "Main agent": "Haupt-Agent",
+ "Child agent": "Kind-Agent",
+ "Response": "Antwort",
+ "Stream output": "Stream-Ausgabe",
+ "Client-made message · not from the provider": "Vom Client erzeugte Nachricht · nicht vom Anbieter",
+ "Context put in": "Eingebrachter Kontext",
+ "Agent call": "Agent-Aufruf",
+ "Runtime notification": "Laufzeit-Benachrichtigung",
+ "Launch acknowledged": "Start bestätigt",
+ "Model call": "Modellaufruf",
+ "Reasoning": "Überlegung",
+ "Tool": "Tool",
+ "call": "Aufruf",
+ "turn": "Zug",
+ "input": "Eingabe",
+ "result": "Ergebnis",
+ "failed": "fehlgeschlagen",
+ "to return": "bis zur Rückgabe",
+ "Open": "Öffnen",
+ "Independent stream context. Its messages are not merged into the parent.": "Eigenständiger Stream-Kontext. Seine Nachrichten werden nicht in den Eltern-Stream übernommen.",
+ "Opened from": "Geöffnet von",
+ "No relation records what opened it.": "Keine Beziehung hält fest, was es geöffnet hat.",
+ "← Back to the step that opened this": "← Zurück zum Schritt, der dies geöffnet hat",
+ "No talks in this stream.": "Keine Gespräche in diesem Stream.",
+ "Outside any talk": "Außerhalb jedes Gesprächs",
+ "Steps the fold could not place under a talk — a child’s output whose stream opened no talk, for instance. The document holds them so nothing is lost.": "Schritte, die beim Einklappen keinem Gespräch zugeordnet werden konnten — etwa die Ausgabe eines Kindes, dessen Stream kein Gespräch eröffnet hat. Das Dokument bewahrt sie, damit nichts verloren geht.",
+ "child streams…": "Kind-Streams…",
+ "child streams": "Kind-Streams",
+ "Back to": "Zurück zu",
+ "Show this talk in the flow timeline": "Dieses Gespräch in der Ablauf-Zeitleiste zeigen",
+ "Show this talk’s answer in the flow timeline": "Die Antwort dieses Gesprächs in der Ablauf-Zeitleiste zeigen",
+ "Locate in the flow timeline": "In der Ablauf-Zeitleiste finden",
+ "Flow timeline": "Ablauf-Zeitleiste",
+ "What is the flow timeline?": "Was ist die Ablauf-Zeitleiste?",
+ "← Parent timeline": "← Übergeordnete Zeitleiste",
+ "Fade unrelated": "Unbeteiligtes ausblenden",
+ "Fade the steps that the selected one does not touch": "Schritte abblenden, die der ausgewählte nicht berührt",
+ "Zoom": "Zoom",
+ "Center selected": "Auswahl zentrieren",
+ "Scroll the timeline to the selected step": "Zeitleiste zum ausgewählten Schritt scrollen",
+ "events": "Ereignisse",
+ "nested": "verschachtelt",
+ "External input": "Externe Eingabe",
+ "Responses": "Antworten",
+ "Model calls": "Modellaufrufe",
+ "Tools": "Tools",
+ "Agent activity": "Agent-Aktivität",
+ "Runtime notices": "Laufzeit-Hinweise",
+ "Nested streams": "Verschachtelte Streams",
+ "Agent response": "Agent-Antwort",
+ "Agent / nested stream": "Agent / verschachtelter Stream",
+ "Context / annotation": "Kontext / Anmerkung",
+ "Emitted by (ownership)": "Erzeugt von (Zugehörigkeit)",
+ "Exact join": "Exakte Verknüpfung",
+ "Strong inference": "Starke Ableitung",
+ "agents created": "Agenten erzeugt",
+ "This call started {n} child agents at nearly the same moment. Click, then pick one in the inspector.": "Dieser Aufruf hat {n} Kind-Agenten nahezu gleichzeitig gestartet. Klicken Sie und wählen Sie dann einen im Inspektor.",
+ "It reported finishing in this talk.": "Es hat in diesem Gespräch seinen Abschluss gemeldet.",
+ "Click to see what opened it.": "Klicken, um zu sehen, was es geöffnet hat.",
+ "what it shows": "was sie zeigt",
+ "One talk, on a time axis. Each row is a lane: the messages, the model calls, the tools it ran, its agent activity, and the nested streams it started. The line above the lanes is the whole conversation, one cell per segment.": "Ein Gespräch auf einer Zeitachse. Jede Zeile ist eine Spur: die Nachrichten, die Modellaufrufe, die ausgeführten Tools, die Agent-Aktivität und die gestarteten verschachtelten Streams. Die Zeile über den Spuren ist die ganze Konversation, eine Zelle pro Segment.",
+ "the heading": "die Überschrift",
+ "Which stream the lanes belong to, how many steps are in this talk, and how many nested streams it touches.": "Zu welchem Stream die Spuren gehören, wie viele Schritte dieses Gespräch hat und wie viele verschachtelte Streams es berührt.",
+ "the axis": "die Achse",
+ "Busy stretches take the width. A long pause is cut to a short gap marked with how long it was, so a talk with an hour of waiting still reads.": "Geschäftige Abschnitte nehmen die Breite ein. Eine lange Pause wird auf eine kurze Lücke mit ihrer Dauer gekürzt, sodass ein Gespräch mit einer Stunde Wartezeit lesbar bleibt.",
+ "links": "Verbindungen",
+ "The lines between lanes, drawn for the step you select. A solid line is an exact join, a dashed one was inferred, and a faint curve is ownership: the model call that produced a step.": "Die Linien zwischen den Spuren, gezeichnet für den ausgewählten Schritt. Eine durchgezogene Linie ist eine exakte Verknüpfung, eine gestrichelte wurde abgeleitet, und eine blasse Kurve ist Zugehörigkeit: der Modellaufruf, der einen Schritt erzeugt hat.",
+ "fade unrelated": "Unbeteiligtes ausblenden",
+ "After you click a step, fade everything it does not touch.": "Nach dem Klick auf einen Schritt wird alles abgeblendet, was er nicht berührt.",
+ "Inspector": "Inspektor",
+ "Evidence": "Belege",
+ "Select a step.": "Wählen Sie einen Schritt.",
+ "nested stream": "verschachtelter Stream",
+ "Stream": "Stream",
+ "Opened by": "Geöffnet durch",
+ "Reported here by": "Hier gemeldet von",
+ "this talk hears the ending. The call that started it is in an earlier talk, because a launch and its completion land in different turns.": "dieses Gespräch hört das Ende. Der Aufruf, der es gestartet hat, liegt in einem früheren Gespräch, weil Start und Abschluss in verschiedenen Zügen landen.",
+ "Join quality": "Verknüpfungsqualität",
+ "Opened at": "Geöffnet um",
+ "Its answer": "Seine Antwort",
+ "not in this stream. A child’s output belongs to the child, so the parent holds a boundary that refers to it, never a copy. Dive in to read it.": "nicht in diesem Stream. Die Ausgabe eines Kindes gehört dem Kind, daher hält der Eltern-Stream nur eine Grenze, die darauf verweist, nie eine Kopie. Tauchen Sie ein, um sie zu lesen.",
+ "Opens in turn": "Öffnet seinerseits",
+ "nothing — it opened no stream of its own": "nichts — es hat keinen eigenen Stream geöffnet",
+ "and {n} more": "und {n} weitere",
+ "this call started {n} streams": "dieser Aufruf hat {n} Streams gestartet",
+ "A workflow call starts a run, and the run starts a pool of agents. They are all its children, not competing guesses at one child.": "Ein Workflow-Aufruf startet einen Lauf, und der Lauf startet einen Pool von Agenten. Sie sind alle seine Kinder, keine konkurrierenden Vermutungen über ein einzelnes Kind.",
+ "Dive in →": "Eintauchen →",
+ "Enter to dive in · Escape to clear": "Enter zum Eintauchen · Escape zum Aufheben",
+ "Node kind": "Knotenart",
+ "Lane": "Spur",
+ "Segment": "Segment",
+ "Direct parent": "Direkter Elternknoten",
+ "Observed at": "Beobachtet um",
+ "Request to result": "Anfrage bis Ergebnis",
+ "unavailable — the runtime does not report how long this ran": "nicht verfügbar — die Laufzeit meldet nicht, wie lange dies lief",
+ "What \"request to result\" is": "Was „Anfrage bis Ergebnis“ bedeutet",
+ "The time between the record carrying the request and the record carrying the result. An exact identifier ties those two records together, so the interval belongs to this call. It is not how long the tool ran, and the runtime does not report that.": "Die Zeit zwischen dem Datensatz mit der Anfrage und dem Datensatz mit dem Ergebnis. Ein exakter Bezeichner verbindet die beiden Datensätze, daher gehört das Intervall zu diesem Aufruf. Es ist nicht die Laufzeit des Tools; die Laufzeit meld [...]
+ "yes": "ja",
+ "Content state": "Inhaltszustand",
+ "Content bytes": "Inhaltsbytes",
+ "Tokens": "Tokens",
+ "in {in} · out {out} · cache read {cacheRead} · cache write {cacheWrite}": "ein {in} · aus {out} · Cache gelesen {cacheRead} · Cache geschrieben {cacheWrite}",
+ "Content {state}": "Inhalt {state}",
+ "The source did not supply readable content for this step. It is reported as it was found, not filled in.": "Die Quelle hat für diesen Schritt keinen lesbaren Inhalt geliefert. Er wird so gemeldet, wie er vorgefunden wurde, nicht ergänzt.",
+ "1 child agent": "1 Kind-Agent",
+ "{n} child agents": "{n} Kind-Agenten",
+ "open the Relations tab →": "Tab „Beziehungen“ öffnen →",
+ "← Back to parent stream": "← Zurück zum Eltern-Stream",
+ "No relation touches this step.": "Keine Beziehung berührt diesen Schritt.",
+ "{n} agents created by this call": "{n} Agenten durch diesen Aufruf erzeugt",
+ "A workflow call starts a run, and the run starts a pool of agents. Every one of them is a child of this call, not a competing guess at one child. Pick the one to read.": "Ein Workflow-Aufruf startet einen Lauf, und der Lauf startet einen Pool von Agenten. Jeder von ihnen ist ein Kind dieses Aufrufs, keine konkurrierende Vermutung über ein einzelnes Kind. Wählen Sie den, den Sie lesen möchten.",
+ "outgoing": "ausgehend",
+ "incoming": "eingehend",
+ "joined on": "verknüpft über",
+ "named from its run journal": "benannt nach seinem Laufjournal",
+ "the other end is outside this stream": "das andere Ende liegt außerhalb dieses Streams",
+ "This step was derived by assembly. It cites no landed record.": "Dieser Schritt wurde beim Zusammensetzen abgeleitet. Er verweist auf keinen gelandeten Datensatz.",
+ "landed positions": "gelandete Positionen",
+ "request": "Anfrage",
+ "part": "Teil",
+ "the text as the document carries it": "der Text, wie ihn das Dokument enthält",
+ "clipped: {shown} of {total} bytes": "gekürzt: {shown} von {total} Bytes",
+ "Load the landed record": "Gelandeten Datensatz laden",
+ "reading…": "wird gelesen…",
+ "The record could not be read.": "Der Datensatz konnte nicht gelesen werden.",
+ "the landed record": "der gelandete Datensatz",
+ "dropped": "verworfen",
+ "vocabulary": "Vokabular",
+ "the model’s own word": "das eigene Wort des Modells",
+ "asz term": "asz-Begriff",
+ "runtime word": "Laufzeit-Begriff",
+ "none — this is derived here, the runtime has no word for it": "keiner — dies wird hier abgeleitet, die Laufzeit hat kein Wort dafür",
+ "where to look": "wo nachsehen",
+ "nowhere in the source; it is produced by assembly": "nirgends in der Quelle; es entsteht beim Zusammensetzen",
+ "read it carefully": "aufmerksam lesen",
+ "dialect": "Dialekt",
+ "a field of the landed record": "ein Feld des gelandeten Datensatzes",
+ "asz field": "asz-Feld",
+ "what it is": "was es ist",
+ "not applicable — this describes the landed record, not the source": "nicht anwendbar — dies beschreibt den gelandeten Datensatz, nicht die Quelle",
+ "What does {key} mean?": "Was bedeutet {key}?",
+ "Selected {what}": "{what} ausgewählt",
+ "Selected nested stream {name}, opened by {kind}. Press Enter to dive in, Escape to clear.": "Verschachtelter Stream {name} ausgewählt, geöffnet durch {kind}. Enter zum Eintauchen, Escape zum Aufheben.",
+ "Selection cleared.": "Auswahl aufgehoben.",
+ "Drawing the conversation…": "Konversation wird gezeichnet…",
+ "Laying out {talks} talks and {steps} steps.": "{talks} Gespräche und {steps} Schritte werden angeordnet.",
+ "Nothing arrives until OAP has folded the whole chain.": "Nichts kommt an, bevor OAP die ganze Kette gefaltet hat.",
+ "OAP is assembling the conversation": "OAP setzt die Konversation zusammen",
+ "Pick a time range, then click Run query.": "Zeitraum wählen, dann auf „Abfrage ausführen“ klicken.",
+ "about {s} s left": "noch etwa {s} s",
+ "drawing": "zeichnen",
+ "parsing": "parsen",
+ "{n} of {m} MB received": "{n} von {m} MB empfangen",
+ "{rate} MB/s": "{rate} MB/s",
+ "{s} s": "{s} s",
+ "{talks} talks · {steps} steps · {streams} streams, as OAP counted them.": "{talks} Gespräche · {steps} Schritte · {streams} Streams, wie von OAP gezählt.",
+ "Title contains": "Titel enthält",
+ "Conversation id": "Konversations-ID",
+ "OAP answered with an error; the rows below may be incomplete.": "OAP hat mit einem Fehler geantwortet; die Zeilen unten sind möglicherweise unvollständig.",
+ "segments (activity windows)": "Abschnitte",
+ "span (elapsed)": "Dauer",
+ "Segment (activity window)": "Abschnitt",
+ "Segments (activity windows)": "Abschnitte",
+ "Span (elapsed)": "Dauer",
+ "Nothing of this conversation could be folded, so there is nothing to read. The problems listed above say why.": "Von dieser Konversation ließ sich nichts falten, daher gibt es nichts zu lesen. Die oben aufgeführten Probleme nennen den Grund.",
+ "This OAP does not serve conversation documents; the AI agent conversation module needs OAP 11.1.0 or later.": "Dieses OAP liefert keine Konversationsdokumente; das Modul für KI-Agent-Konversationen braucht OAP 11.1.0 oder neuer."
}
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index e713cb1a..db86d871 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -2921,5 +2921,224 @@
"JSON": "JSON",
"(empty — type an expression to run it)": "(empty — type an expression to run it)",
"Could not read the instances of this service.": "Could not read the instances of this service.",
- "Could not read the endpoints of this service.": "Could not read the endpoints of this service."
+ "Could not read the endpoints of this service.": "Could not read the endpoints of this service.",
+ "See AI agent conversations": "See AI agent conversations",
+ "AI agent conversations": "AI agent conversations",
+ "Conversations": "Conversations",
+ "Sender": "Sender",
+ "The conversation list could not be read.": "The conversation list could not be read.",
+ "{n} conversations": "{n} conversations",
+ "Listed from the newest {n} rounds in the window; a conversation whose newest round is older than that is not shown.": "Listed from the newest {n} rounds in the window; a conversation whose newest round is older than that is not shown.",
+ "No conversations in this window.": "No conversations in this window.",
+ "Talks": "Talks",
+ "Steps": "Steps",
+ "Streams": "Streams",
+ "Unresolved": "Unresolved",
+ "Last activity": "Last activity",
+ "(untitled)": "(untitled)",
+ "The link is incomplete: it names no agent runtime.": "The link is incomplete: it names no agent runtime.",
+ "OAP holds no round of this conversation for this runtime.": "OAP holds no round of this conversation for this runtime.",
+ "Your role lacks the ai-conversation:read permission.": "Your role lacks the ai-conversation:read permission.",
+ "OAP did not answer within its time budget. The conversation may be very large; try again.": "OAP did not answer within its time budget. The conversation may be very large; try again.",
+ "This Horizon cannot read the document OAP sent.": "This Horizon cannot read the document OAP sent.",
+ "The conversation could not be read.": "The conversation could not be read.",
+ "AI agent conversation": "AI agent conversation",
+ "All conversations": "All conversations",
+ "Reading the conversation…": "Reading the conversation…",
+ "{mb} MB received": "{mb} MB received",
+ "Try again": "Try again",
+ "unavailable": "unavailable",
+ "Open the conversation in a new tab": "Open the conversation in a new tab",
+ "round": "round",
+ "streams": "streams",
+ "talks": "talks",
+ "unresolved": "unresolved",
+ "Session": "Session",
+ "runs": "runs",
+ "Child streams": "Child streams",
+ "subagents, each its own context": "subagents, each its own context",
+ "Relations": "Relations",
+ "activity windows": "activity windows",
+ "verified": "verified",
+ "incomplete": "incomplete",
+ "mismatch": "mismatch",
+ "problems": "problems",
+ "rounds verified": "rounds verified",
+ "No talk matches.": "No talk matches.",
+ "Talk": "Talk",
+ "main stream, child work folded": "main stream, child work folded",
+ "child stream": "child stream",
+ "tools": "tools",
+ "External · Input": "External · Input",
+ "no opening line": "no opening line",
+ "delegated work": "delegated work",
+ "show what the agent did": "show what the agent did",
+ "hide what the agent did": "hide what the agent did",
+ "quiet": "quiet",
+ "Main agent": "Main agent",
+ "Child agent": "Child agent",
+ "Response": "Response",
+ "Stream output": "Stream output",
+ "Client-made message · not from the provider": "Client-made message · not from the provider",
+ "Context put in": "Context put in",
+ "Agent call": "Agent call",
+ "Runtime notification": "Runtime notification",
+ "Launch acknowledged": "Launch acknowledged",
+ "Model call": "Model call",
+ "Reasoning": "Reasoning",
+ "Tool": "Tool",
+ "call": "call",
+ "turn": "turn",
+ "input": "input",
+ "result": "result",
+ "failed": "failed",
+ "to return": "to return",
+ "Open": "Open",
+ "Independent stream context. Its messages are not merged into the parent.": "Independent stream context. Its messages are not merged into the parent.",
+ "Opened from": "Opened from",
+ "No relation records what opened it.": "No relation records what opened it.",
+ "← Back to the step that opened this": "← Back to the step that opened this",
+ "No talks in this stream.": "No talks in this stream.",
+ "Outside any talk": "Outside any talk",
+ "Steps the fold could not place under a talk — a child’s output whose stream opened no talk, for instance. The document holds them so nothing is lost.": "Steps the fold could not place under a talk — a child’s output whose stream opened no talk, for instance. The document holds them so nothing is lost.",
+ "child streams…": "child streams…",
+ "child streams": "child streams",
+ "Back to": "Back to",
+ "Show this talk in the flow timeline": "Show this talk in the flow timeline",
+ "Show this talk’s answer in the flow timeline": "Show this talk’s answer in the flow timeline",
+ "Locate in the flow timeline": "Locate in the flow timeline",
+ "Flow timeline": "Flow timeline",
+ "What is the flow timeline?": "What is the flow timeline?",
+ "← Parent timeline": "← Parent timeline",
+ "Fade unrelated": "Fade unrelated",
+ "Fade the steps that the selected one does not touch": "Fade the steps that the selected one does not touch",
+ "Zoom": "Zoom",
+ "Center selected": "Center selected",
+ "Scroll the timeline to the selected step": "Scroll the timeline to the selected step",
+ "events": "events",
+ "nested": "nested",
+ "External input": "External input",
+ "Responses": "Responses",
+ "Model calls": "Model calls",
+ "Tools": "Tools",
+ "Agent activity": "Agent activity",
+ "Runtime notices": "Runtime notices",
+ "Nested streams": "Nested streams",
+ "Agent response": "Agent response",
+ "Agent / nested stream": "Agent / nested stream",
+ "Context / annotation": "Context / annotation",
+ "Emitted by (ownership)": "Emitted by (ownership)",
+ "Exact join": "Exact join",
+ "Strong inference": "Strong inference",
+ "agents created": "agents created",
+ "This call started {n} child agents at nearly the same moment. Click, then pick one in the inspector.": "This call started {n} child agents at nearly the same moment. Click, then pick one in the inspector.",
+ "It reported finishing in this talk.": "It reported finishing in this talk.",
+ "Click to see what opened it.": "Click to see what opened it.",
+ "what it shows": "what it shows",
+ "One talk, on a time axis. Each row is a lane: the messages, the model calls, the tools it ran, its agent activity, and the nested streams it started. The line above the lanes is the whole conversation, one cell per segment.": "One talk, on a time axis. Each row is a lane: the messages, the model calls, the tools it ran, its agent activity, and the nested streams it started. The line above the lanes is the whole conversation, one cell per segment.",
+ "the heading": "the heading",
+ "Which stream the lanes belong to, how many steps are in this talk, and how many nested streams it touches.": "Which stream the lanes belong to, how many steps are in this talk, and how many nested streams it touches.",
+ "the axis": "the axis",
+ "Busy stretches take the width. A long pause is cut to a short gap marked with how long it was, so a talk with an hour of waiting still reads.": "Busy stretches take the width. A long pause is cut to a short gap marked with how long it was, so a talk with an hour of waiting still reads.",
+ "links": "links",
+ "The lines between lanes, drawn for the step you select. A solid line is an exact join, a dashed one was inferred, and a faint curve is ownership: the model call that produced a step.": "The lines between lanes, drawn for the step you select. A solid line is an exact join, a dashed one was inferred, and a faint curve is ownership: the model call that produced a step.",
+ "fade unrelated": "fade unrelated",
+ "After you click a step, fade everything it does not touch.": "After you click a step, fade everything it does not touch.",
+ "Inspector": "Inspector",
+ "Evidence": "Evidence",
+ "Select a step.": "Select a step.",
+ "nested stream": "nested stream",
+ "Stream": "Stream",
+ "Opened by": "Opened by",
+ "Reported here by": "Reported here by",
+ "this talk hears the ending. The call that started it is in an earlier talk, because a launch and its completion land in different turns.": "this talk hears the ending. The call that started it is in an earlier talk, because a launch and its completion land in different turns.",
+ "Join quality": "Join quality",
+ "Opened at": "Opened at",
+ "Its answer": "Its answer",
+ "not in this stream. A child’s output belongs to the child, so the parent holds a boundary that refers to it, never a copy. Dive in to read it.": "not in this stream. A child’s output belongs to the child, so the parent holds a boundary that refers to it, never a copy. Dive in to read it.",
+ "Opens in turn": "Opens in turn",
+ "nothing — it opened no stream of its own": "nothing — it opened no stream of its own",
+ "and {n} more": "and {n} more",
+ "this call started {n} streams": "this call started {n} streams",
+ "A workflow call starts a run, and the run starts a pool of agents. They are all its children, not competing guesses at one child.": "A workflow call starts a run, and the run starts a pool of agents. They are all its children, not competing guesses at one child.",
+ "Dive in →": "Dive in →",
+ "Enter to dive in · Escape to clear": "Enter to dive in · Escape to clear",
+ "Node kind": "Node kind",
+ "Lane": "Lane",
+ "Segment": "Segment",
+ "Direct parent": "Direct parent",
+ "Observed at": "Observed at",
+ "Request to result": "Request to result",
+ "unavailable — the runtime does not report how long this ran": "unavailable — the runtime does not report how long this ran",
+ "What \"request to result\" is": "What \"request to result\" is",
+ "The time between the record carrying the request and the record carrying the result. An exact identifier ties those two records together, so the interval belongs to this call. It is not how long the tool ran, and the runtime does not report that.": "The time between the record carrying the request and the record carrying the result. An exact identifier ties those two records together, so the interval belongs to this call. It is not how long the tool ran, and the runtime does not repor [...]
+ "yes": "yes",
+ "Content state": "Content state",
+ "Content bytes": "Content bytes",
+ "Tokens": "Tokens",
+ "in {in} · out {out} · cache read {cacheRead} · cache write {cacheWrite}": "in {in} · out {out} · cache read {cacheRead} · cache write {cacheWrite}",
+ "Content {state}": "Content {state}",
+ "The source did not supply readable content for this step. It is reported as it was found, not filled in.": "The source did not supply readable content for this step. It is reported as it was found, not filled in.",
+ "1 child agent": "1 child agent",
+ "{n} child agents": "{n} child agents",
+ "open the Relations tab →": "open the Relations tab →",
+ "← Back to parent stream": "← Back to parent stream",
+ "No relation touches this step.": "No relation touches this step.",
+ "{n} agents created by this call": "{n} agents created by this call",
+ "A workflow call starts a run, and the run starts a pool of agents. Every one of them is a child of this call, not a competing guess at one child. Pick the one to read.": "A workflow call starts a run, and the run starts a pool of agents. Every one of them is a child of this call, not a competing guess at one child. Pick the one to read.",
+ "outgoing": "outgoing",
+ "incoming": "incoming",
+ "joined on": "joined on",
+ "named from its run journal": "named from its run journal",
+ "the other end is outside this stream": "the other end is outside this stream",
+ "This step was derived by assembly. It cites no landed record.": "This step was derived by assembly. It cites no landed record.",
+ "landed positions": "landed positions",
+ "request": "request",
+ "part": "part",
+ "the text as the document carries it": "the text as the document carries it",
+ "clipped: {shown} of {total} bytes": "clipped: {shown} of {total} bytes",
+ "Load the landed record": "Load the landed record",
+ "reading…": "reading…",
+ "The record could not be read.": "The record could not be read.",
+ "the landed record": "the landed record",
+ "dropped": "dropped",
+ "vocabulary": "vocabulary",
+ "the model’s own word": "the model’s own word",
+ "asz term": "asz term",
+ "runtime word": "runtime word",
+ "none — this is derived here, the runtime has no word for it": "none — this is derived here, the runtime has no word for it",
+ "where to look": "where to look",
+ "nowhere in the source; it is produced by assembly": "nowhere in the source; it is produced by assembly",
+ "read it carefully": "read it carefully",
+ "dialect": "dialect",
+ "a field of the landed record": "a field of the landed record",
+ "asz field": "asz field",
+ "what it is": "what it is",
+ "not applicable — this describes the landed record, not the source": "not applicable — this describes the landed record, not the source",
+ "What does {key} mean?": "What does {key} mean?",
+ "Selected {what}": "Selected {what}",
+ "Selected nested stream {name}, opened by {kind}. Press Enter to dive in, Escape to clear.": "Selected nested stream {name}, opened by {kind}. Press Enter to dive in, Escape to clear.",
+ "Selection cleared.": "Selection cleared.",
+ "Drawing the conversation…": "Drawing the conversation…",
+ "Laying out {talks} talks and {steps} steps.": "Laying out {talks} talks and {steps} steps.",
+ "Nothing arrives until OAP has folded the whole chain.": "Nothing arrives until OAP has folded the whole chain.",
+ "OAP is assembling the conversation": "OAP is assembling the conversation",
+ "Pick a time range, then click Run query.": "Pick a time range, then click Run query.",
+ "about {s} s left": "about {s} s left",
+ "drawing": "drawing",
+ "parsing": "parsing",
+ "{n} of {m} MB received": "{n} of {m} MB received",
+ "{rate} MB/s": "{rate} MB/s",
+ "{s} s": "{s} s",
+ "{talks} talks · {steps} steps · {streams} streams, as OAP counted them.": "{talks} talks · {steps} steps · {streams} streams, as OAP counted them.",
+ "Title contains": "Title contains",
+ "Conversation id": "Conversation id",
+ "OAP answered with an error; the rows below may be incomplete.": "OAP answered with an error; the rows below may be incomplete.",
+ "segments (activity windows)": "segments",
+ "span (elapsed)": "span",
+ "Segment (activity window)": "Segment",
+ "Segments (activity windows)": "Segments",
+ "Span (elapsed)": "Span",
+ "Nothing of this conversation could be folded, so there is nothing to read. The problems listed above say why.": "Nothing of this conversation could be folded, so there is nothing to read. The problems listed above say why.",
+ "This OAP does not serve conversation documents; the AI agent conversation module needs OAP 11.1.0 or later.": "This OAP does not serve conversation documents; the AI agent conversation module needs OAP 11.1.0 or later."
}
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 6d63e27d..056d6076 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -2921,5 +2921,224 @@
"JSON": "JSON",
"(empty — type an expression to run it)": "(vacío: escribe una expresión para ejecutarla)",
"Could not read the instances of this service.": "No se pudieron leer las instancias de este servicio.",
- "Could not read the endpoints of this service.": "No se pudieron leer los endpoints de este servicio."
+ "Could not read the endpoints of this service.": "No se pudieron leer los endpoints de este servicio.",
+ "See AI agent conversations": "Ver conversaciones de agentes de IA",
+ "AI agent conversations": "Conversaciones de agentes de IA",
+ "Conversations": "Conversaciones",
+ "Sender": "Remitente",
+ "The conversation list could not be read.": "No se pudo leer la lista de conversaciones.",
+ "{n} conversations": "{n} conversaciones",
+ "Listed from the newest {n} rounds in the window; a conversation whose newest round is older than that is not shown.": "Listado a partir de las {n} rondas más recientes del intervalo; una conversación cuya ronda más reciente sea anterior no se muestra.",
+ "No conversations in this window.": "No hay conversaciones en este intervalo.",
+ "Talks": "Intercambios",
+ "Steps": "Pasos",
+ "Streams": "Flujos",
+ "Unresolved": "Sin resolver",
+ "Last activity": "Última actividad",
+ "(untitled)": "(sin título)",
+ "The link is incomplete: it names no agent runtime.": "El enlace está incompleto: no indica ningún runtime de agente.",
+ "OAP holds no round of this conversation for this runtime.": "OAP no tiene ninguna ronda de esta conversación para este runtime.",
+ "Your role lacks the ai-conversation:read permission.": "Su rol no tiene el permiso ai-conversation:read.",
+ "OAP did not answer within its time budget. The conversation may be very large; try again.": "OAP no respondió dentro de su presupuesto de tiempo. La conversación puede ser muy grande; inténtelo de nuevo.",
+ "This Horizon cannot read the document OAP sent.": "Este Horizon no puede leer el documento que envió OAP.",
+ "The conversation could not be read.": "No se pudo leer la conversación.",
+ "AI agent conversation": "Conversación de agente de IA",
+ "All conversations": "Todas las conversaciones",
+ "Reading the conversation…": "Leyendo la conversación…",
+ "{mb} MB received": "{mb} MB recibidos",
+ "Try again": "Reintentar",
+ "unavailable": "no disponible",
+ "Open the conversation in a new tab": "Abrir la conversación en una pestaña nueva",
+ "round": "ronda",
+ "streams": "flujos",
+ "talks": "charlas",
+ "unresolved": "sin resolver",
+ "Session": "Sesión",
+ "runs": "ejecuciones",
+ "Child streams": "Flujos hijos",
+ "subagents, each its own context": "subagentes, cada uno con su propio contexto",
+ "Relations": "Relaciones",
+ "activity windows": "ventanas de actividad",
+ "verified": "verificado",
+ "incomplete": "incompleto",
+ "mismatch": "discrepancia",
+ "problems": "problemas",
+ "rounds verified": "rondas verificadas",
+ "No talk matches.": "Ninguna charla coincide.",
+ "Talk": "Charla",
+ "main stream, child work folded": "flujo principal, trabajo de los hijos plegado",
+ "child stream": "flujo hijo",
+ "tools": "herramientas",
+ "External · Input": "Externo · Entrada",
+ "no opening line": "sin frase inicial",
+ "delegated work": "trabajo delegado",
+ "show what the agent did": "mostrar lo que hizo el agente",
+ "hide what the agent did": "ocultar lo que hizo el agente",
+ "quiet": "silencio",
+ "Main agent": "Agente principal",
+ "Child agent": "Agente hijo",
+ "Response": "Respuesta",
+ "Stream output": "Salida del flujo",
+ "Client-made message · not from the provider": "Mensaje creado por el cliente · no por el proveedor",
+ "Context put in": "Contexto introducido",
+ "Agent call": "Llamada a agente",
+ "Runtime notification": "Notificación del runtime",
+ "Launch acknowledged": "Lanzamiento confirmado",
+ "Model call": "Llamada al modelo",
+ "Reasoning": "Razonamiento",
+ "Tool": "Herramienta",
+ "call": "llamada",
+ "turn": "turno",
+ "input": "entrada",
+ "result": "resultado",
+ "failed": "fallido",
+ "to return": "hasta devolver",
+ "Open": "Abrir",
+ "Independent stream context. Its messages are not merged into the parent.": "Contexto de flujo independiente. Sus mensajes no se fusionan en el padre.",
+ "Opened from": "Abierto desde",
+ "No relation records what opened it.": "Ninguna relación registra qué lo abrió.",
+ "← Back to the step that opened this": "← Volver al paso que abrió esto",
+ "No talks in this stream.": "No hay charlas en este flujo.",
+ "Outside any talk": "Fuera de cualquier charla",
+ "Steps the fold could not place under a talk — a child’s output whose stream opened no talk, for instance. The document holds them so nothing is lost.": "Pasos que el plegado no pudo colocar bajo ninguna charla — por ejemplo, la salida de un hijo cuyo flujo no abrió ninguna charla. El documento los conserva para que nada se pierda.",
+ "child streams…": "flujos hijos…",
+ "child streams": "flujos hijos",
+ "Back to": "Volver a",
+ "Show this talk in the flow timeline": "Mostrar esta charla en la línea de tiempo",
+ "Show this talk’s answer in the flow timeline": "Mostrar la respuesta de esta charla en la línea de tiempo",
+ "Locate in the flow timeline": "Localizar en la línea de tiempo",
+ "Flow timeline": "Línea de tiempo del flujo",
+ "What is the flow timeline?": "¿Qué es la línea de tiempo del flujo?",
+ "← Parent timeline": "← Línea de tiempo padre",
+ "Fade unrelated": "Atenuar lo no relacionado",
+ "Fade the steps that the selected one does not touch": "Atenuar los pasos que el seleccionado no toca",
+ "Zoom": "Zoom",
+ "Center selected": "Centrar la selección",
+ "Scroll the timeline to the selected step": "Desplazar la línea de tiempo hasta el paso seleccionado",
+ "events": "eventos",
+ "nested": "anidados",
+ "External input": "Entrada externa",
+ "Responses": "Respuestas",
+ "Model calls": "Llamadas al modelo",
+ "Tools": "Herramientas",
+ "Agent activity": "Actividad de agentes",
+ "Runtime notices": "Avisos del runtime",
+ "Nested streams": "Flujos anidados",
+ "Agent response": "Respuesta del agente",
+ "Agent / nested stream": "Agente / flujo anidado",
+ "Context / annotation": "Contexto / anotación",
+ "Emitted by (ownership)": "Emitido por (pertenencia)",
+ "Exact join": "Unión exacta",
+ "Strong inference": "Inferencia fuerte",
+ "agents created": "agentes creados",
+ "This call started {n} child agents at nearly the same moment. Click, then pick one in the inspector.": "Esta llamada inició {n} agentes hijos casi en el mismo instante. Haga clic y luego elija uno en el inspector.",
+ "It reported finishing in this talk.": "Informó de su finalización en esta charla.",
+ "Click to see what opened it.": "Haga clic para ver qué lo abrió.",
+ "what it shows": "qué muestra",
+ "One talk, on a time axis. Each row is a lane: the messages, the model calls, the tools it ran, its agent activity, and the nested streams it started. The line above the lanes is the whole conversation, one cell per segment.": "Una charla sobre un eje de tiempo. Cada fila es una pista: los mensajes, las llamadas al modelo, las herramientas ejecutadas, la actividad de los agentes y los flujos anidados que inició. La línea sobre las pistas es la conversación completa, una celda por segmento.",
+ "the heading": "el encabezado",
+ "Which stream the lanes belong to, how many steps are in this talk, and how many nested streams it touches.": "A qué flujo pertenecen las pistas, cuántos pasos tiene esta charla y cuántos flujos anidados toca.",
+ "the axis": "el eje",
+ "Busy stretches take the width. A long pause is cut to a short gap marked with how long it was, so a talk with an hour of waiting still reads.": "Los tramos de actividad ocupan el ancho. Una pausa larga se reduce a un hueco corto marcado con su duración, de modo que una charla con una hora de espera sigue siendo legible.",
+ "links": "enlaces",
+ "The lines between lanes, drawn for the step you select. A solid line is an exact join, a dashed one was inferred, and a faint curve is ownership: the model call that produced a step.": "Las líneas entre pistas, dibujadas para el paso seleccionado. Una línea continua es una unión exacta, una discontinua fue inferida y una curva tenue es pertenencia: la llamada al modelo que produjo un paso.",
+ "fade unrelated": "atenuar lo no relacionado",
+ "After you click a step, fade everything it does not touch.": "Tras hacer clic en un paso, se atenúa todo lo que no toca.",
+ "Inspector": "Inspector",
+ "Evidence": "Evidencia",
+ "Select a step.": "Seleccione un paso.",
+ "nested stream": "flujo anidado",
+ "Stream": "Flujo",
+ "Opened by": "Abierto por",
+ "Reported here by": "Informado aquí por",
+ "this talk hears the ending. The call that started it is in an earlier talk, because a launch and its completion land in different turns.": "esta charla escucha el final. La llamada que lo inició está en una charla anterior, porque un lanzamiento y su finalización caen en turnos distintos.",
+ "Join quality": "Calidad de la unión",
+ "Opened at": "Abierto a las",
+ "Its answer": "Su respuesta",
+ "not in this stream. A child’s output belongs to the child, so the parent holds a boundary that refers to it, never a copy. Dive in to read it.": "no está en este flujo. La salida de un hijo pertenece al hijo, así que el padre guarda un límite que la referencia, nunca una copia. Sumérjase para leerla.",
+ "Opens in turn": "Abre a su vez",
+ "nothing — it opened no stream of its own": "nada — no abrió ningún flujo propio",
+ "and {n} more": "y {n} más",
+ "this call started {n} streams": "esta llamada inició {n} flujos",
+ "A workflow call starts a run, and the run starts a pool of agents. They are all its children, not competing guesses at one child.": "Una llamada de flujo de trabajo inicia una ejecución, y la ejecución inicia un grupo de agentes. Todos son sus hijos, no conjeturas rivales sobre un único hijo.",
+ "Dive in →": "Sumergirse →",
+ "Enter to dive in · Escape to clear": "Intro para sumergirse · Esc para limpiar",
+ "Node kind": "Tipo de nodo",
+ "Lane": "Pista",
+ "Segment": "Segmento",
+ "Direct parent": "Padre directo",
+ "Observed at": "Observado a las",
+ "Request to result": "De la solicitud al resultado",
+ "unavailable — the runtime does not report how long this ran": "no disponible — el runtime no informa cuánto duró esto",
+ "What \"request to result\" is": "Qué es «de la solicitud al resultado»",
+ "The time between the record carrying the request and the record carrying the result. An exact identifier ties those two records together, so the interval belongs to this call. It is not how long the tool ran, and the runtime does not report that.": "El tiempo entre el registro que lleva la solicitud y el registro que lleva el resultado. Un identificador exacto une esos dos registros, por lo que el intervalo pertenece a esta llamada. No es cuánto tardó la herramienta; el runtime no inf [...]
+ "yes": "sí",
+ "Content state": "Estado del contenido",
+ "Content bytes": "Bytes del contenido",
+ "Tokens": "Tokens",
+ "in {in} · out {out} · cache read {cacheRead} · cache write {cacheWrite}": "entrada {in} · salida {out} · caché leída {cacheRead} · caché escrita {cacheWrite}",
+ "Content {state}": "Contenido {state}",
+ "The source did not supply readable content for this step. It is reported as it was found, not filled in.": "La fuente no proporcionó contenido legible para este paso. Se informa tal como se encontró, sin completarlo.",
+ "1 child agent": "1 agente hijo",
+ "{n} child agents": "{n} agentes hijos",
+ "open the Relations tab →": "abrir la pestaña Relaciones →",
+ "← Back to parent stream": "← Volver al flujo padre",
+ "No relation touches this step.": "Ninguna relación toca este paso.",
+ "{n} agents created by this call": "{n} agentes creados por esta llamada",
+ "A workflow call starts a run, and the run starts a pool of agents. Every one of them is a child of this call, not a competing guess at one child. Pick the one to read.": "Una llamada de flujo de trabajo inicia una ejecución, y la ejecución inicia un grupo de agentes. Cada uno de ellos es hijo de esta llamada, no una conjetura rival sobre un único hijo. Elija el que quiera leer.",
+ "outgoing": "saliente",
+ "incoming": "entrante",
+ "joined on": "unido por",
+ "named from its run journal": "nombrado a partir de su diario de ejecución",
+ "the other end is outside this stream": "el otro extremo está fuera de este flujo",
+ "This step was derived by assembly. It cites no landed record.": "Este paso se derivó durante el ensamblaje. No cita ningún registro recibido.",
+ "landed positions": "posiciones recibidas",
+ "request": "solicitud",
+ "part": "parte",
+ "the text as the document carries it": "el texto tal como lo lleva el documento",
+ "clipped: {shown} of {total} bytes": "recortado: {shown} de {total} bytes",
+ "Load the landed record": "Cargar el registro recibido",
+ "reading…": "leyendo…",
+ "The record could not be read.": "No se pudo leer el registro.",
+ "the landed record": "el registro recibido",
+ "dropped": "descartado",
+ "vocabulary": "vocabulario",
+ "the model’s own word": "la palabra del propio modelo",
+ "asz term": "término asz",
+ "runtime word": "palabra del runtime",
+ "none — this is derived here, the runtime has no word for it": "ninguna — esto se deriva aquí; el runtime no tiene palabra para ello",
+ "where to look": "dónde mirar",
+ "nowhere in the source; it is produced by assembly": "en ningún lugar de la fuente; lo produce el ensamblaje",
+ "read it carefully": "léalo con atención",
+ "dialect": "dialecto",
+ "a field of the landed record": "un campo del registro recibido",
+ "asz field": "campo asz",
+ "what it is": "qué es",
+ "not applicable — this describes the landed record, not the source": "no aplicable — esto describe el registro recibido, no la fuente",
+ "What does {key} mean?": "¿Qué significa {key}?",
+ "Selected {what}": "Seleccionado {what}",
+ "Selected nested stream {name}, opened by {kind}. Press Enter to dive in, Escape to clear.": "Seleccionado el flujo anidado {name}, abierto por {kind}. Pulse Intro para sumergirse, Esc para limpiar.",
+ "Selection cleared.": "Selección limpiada.",
+ "Drawing the conversation…": "Dibujando la conversación…",
+ "Laying out {talks} talks and {steps} steps.": "Disponiendo {talks} charlas y {steps} pasos.",
+ "Nothing arrives until OAP has folded the whole chain.": "No llega nada hasta que OAP ha plegado toda la cadena.",
+ "OAP is assembling the conversation": "OAP está ensamblando la conversación",
+ "Pick a time range, then click Run query.": "Elija un intervalo de tiempo y haga clic en Ejecutar consulta.",
+ "about {s} s left": "quedan unos {s} s",
+ "drawing": "dibujando",
+ "parsing": "analizando",
+ "{n} of {m} MB received": "{n} de {m} MB recibidos",
+ "{rate} MB/s": "{rate} MB/s",
+ "{s} s": "{s} s",
+ "{talks} talks · {steps} steps · {streams} streams, as OAP counted them.": "{talks} charlas · {steps} pasos · {streams} flujos, según los contó OAP.",
+ "Title contains": "El título contiene",
+ "Conversation id": "Id de conversación",
+ "OAP answered with an error; the rows below may be incomplete.": "OAP respondió con un error; las filas siguientes pueden estar incompletas.",
+ "segments (activity windows)": "segmentos",
+ "span (elapsed)": "duración",
+ "Segment (activity window)": "Segmento",
+ "Segments (activity windows)": "Segmentos",
+ "Span (elapsed)": "Duración",
+ "Nothing of this conversation could be folded, so there is nothing to read. The problems listed above say why.": "No se pudo plegar nada de esta conversación, así que no hay nada que leer. Los problemas listados arriba explican por qué.",
+ "This OAP does not serve conversation documents; the AI agent conversation module needs OAP 11.1.0 or later.": "Este OAP no sirve documentos de conversación; el módulo de conversaciones de agente de IA requiere OAP 11.1.0 o posterior."
}
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 51fb7fe8..199830e3 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -2921,5 +2921,224 @@
"JSON": "JSON",
"(empty — type an expression to run it)": "(vide — saisissez une expression pour l'exécuter)",
"Could not read the instances of this service.": "Impossible de lire les instances de ce service.",
- "Could not read the endpoints of this service.": "Impossible de lire les endpoints de ce service."
+ "Could not read the endpoints of this service.": "Impossible de lire les endpoints de ce service.",
+ "See AI agent conversations": "Voir les conversations des agents IA",
+ "AI agent conversations": "Conversations des agents IA",
+ "Conversations": "Conversations",
+ "Sender": "Expéditeur",
+ "The conversation list could not be read.": "La liste des conversations n'a pas pu être lue.",
+ "{n} conversations": "{n} conversations",
+ "Listed from the newest {n} rounds in the window; a conversation whose newest round is older than that is not shown.": "Liste établie à partir des {n} rounds les plus récents de la plage ; une conversation dont le round le plus récent est plus ancien n'apparaît pas.",
+ "No conversations in this window.": "Aucune conversation dans cette plage.",
+ "Talks": "Échanges",
+ "Steps": "Étapes",
+ "Streams": "Flux",
+ "Unresolved": "Non résolus",
+ "Last activity": "Dernière activité",
+ "(untitled)": "(sans titre)",
+ "The link is incomplete: it names no agent runtime.": "Le lien est incomplet : il ne nomme aucun runtime d’agent.",
+ "OAP holds no round of this conversation for this runtime.": "OAP ne détient aucun round de cette conversation pour ce runtime.",
+ "Your role lacks the ai-conversation:read permission.": "Votre rôle n’a pas la permission ai-conversation:read.",
+ "OAP did not answer within its time budget. The conversation may be very large; try again.": "OAP n’a pas répondu dans son budget de temps. La conversation est peut-être très volumineuse ; réessayez.",
+ "This Horizon cannot read the document OAP sent.": "Ce Horizon ne peut pas lire le document envoyé par OAP.",
+ "The conversation could not be read.": "La conversation n’a pas pu être lue.",
+ "AI agent conversation": "Conversation d’agent IA",
+ "All conversations": "Toutes les conversations",
+ "Reading the conversation…": "Lecture de la conversation…",
+ "{mb} MB received": "{mb} Mo reçus",
+ "Try again": "Réessayer",
+ "unavailable": "indisponible",
+ "Open the conversation in a new tab": "Ouvrir la conversation dans un nouvel onglet",
+ "round": "round",
+ "streams": "flux",
+ "talks": "échanges",
+ "unresolved": "non résolus",
+ "Session": "Session",
+ "runs": "exécutions",
+ "Child streams": "Flux enfants",
+ "subagents, each its own context": "sous-agents, chacun avec son propre contexte",
+ "Relations": "Relations",
+ "activity windows": "fenêtres d’activité",
+ "verified": "vérifié",
+ "incomplete": "incomplet",
+ "mismatch": "discordance",
+ "problems": "problèmes",
+ "rounds verified": "rounds vérifiés",
+ "No talk matches.": "Aucun échange ne correspond.",
+ "Talk": "Échange",
+ "main stream, child work folded": "flux principal, travail des enfants replié",
+ "child stream": "flux enfant",
+ "tools": "outils",
+ "External · Input": "Externe · Entrée",
+ "no opening line": "pas de phrase d’ouverture",
+ "delegated work": "travail délégué",
+ "show what the agent did": "montrer ce que l’agent a fait",
+ "hide what the agent did": "masquer ce que l’agent a fait",
+ "quiet": "silence",
+ "Main agent": "Agent principal",
+ "Child agent": "Agent enfant",
+ "Response": "Réponse",
+ "Stream output": "Sortie du flux",
+ "Client-made message · not from the provider": "Message créé par le client · pas par le fournisseur",
+ "Context put in": "Contexte injecté",
+ "Agent call": "Appel d’agent",
+ "Runtime notification": "Notification du runtime",
+ "Launch acknowledged": "Lancement confirmé",
+ "Model call": "Appel du modèle",
+ "Reasoning": "Raisonnement",
+ "Tool": "Outil",
+ "call": "appel",
+ "turn": "tour",
+ "input": "entrée",
+ "result": "résultat",
+ "failed": "échoué",
+ "to return": "avant retour",
+ "Open": "Ouvrir",
+ "Independent stream context. Its messages are not merged into the parent.": "Contexte de flux indépendant. Ses messages ne sont pas fusionnés dans le parent.",
+ "Opened from": "Ouvert depuis",
+ "No relation records what opened it.": "Aucune relation n’indique ce qui l’a ouvert.",
+ "← Back to the step that opened this": "← Retour à l’étape qui a ouvert ceci",
+ "No talks in this stream.": "Aucun échange dans ce flux.",
+ "Outside any talk": "Hors de tout échange",
+ "Steps the fold could not place under a talk — a child’s output whose stream opened no talk, for instance. The document holds them so nothing is lost.": "Étapes que le repli n’a pu placer sous aucun échange — par exemple la sortie d’un enfant dont le flux n’a ouvert aucun échange. Le document les conserve : rien n’est perdu.",
+ "child streams…": "flux enfants…",
+ "child streams": "flux enfants",
+ "Back to": "Retour à",
+ "Show this talk in the flow timeline": "Montrer cet échange dans la chronologie",
+ "Show this talk’s answer in the flow timeline": "Montrer la réponse de cet échange dans la chronologie",
+ "Locate in the flow timeline": "Localiser dans la chronologie",
+ "Flow timeline": "Chronologie du flux",
+ "What is the flow timeline?": "Qu’est-ce que la chronologie du flux ?",
+ "← Parent timeline": "← Chronologie parente",
+ "Fade unrelated": "Estomper le reste",
+ "Fade the steps that the selected one does not touch": "Estomper les étapes que l’étape sélectionnée ne touche pas",
+ "Zoom": "Zoom",
+ "Center selected": "Centrer la sélection",
+ "Scroll the timeline to the selected step": "Faire défiler la chronologie jusqu’à l’étape sélectionnée",
+ "events": "événements",
+ "nested": "imbriqués",
+ "External input": "Entrée externe",
+ "Responses": "Réponses",
+ "Model calls": "Appels du modèle",
+ "Tools": "Outils",
+ "Agent activity": "Activité des agents",
+ "Runtime notices": "Avis du runtime",
+ "Nested streams": "Flux imbriqués",
+ "Agent response": "Réponse de l’agent",
+ "Agent / nested stream": "Agent / flux imbriqué",
+ "Context / annotation": "Contexte / annotation",
+ "Emitted by (ownership)": "Émis par (appartenance)",
+ "Exact join": "Jointure exacte",
+ "Strong inference": "Inférence forte",
+ "agents created": "agents créés",
+ "This call started {n} child agents at nearly the same moment. Click, then pick one in the inspector.": "Cet appel a démarré {n} agents enfants presque au même instant. Cliquez, puis choisissez-en un dans l’inspecteur.",
+ "It reported finishing in this talk.": "Il a signalé sa fin dans cet échange.",
+ "Click to see what opened it.": "Cliquez pour voir ce qui l’a ouvert.",
+ "what it shows": "ce qu’elle montre",
+ "One talk, on a time axis. Each row is a lane: the messages, the model calls, the tools it ran, its agent activity, and the nested streams it started. The line above the lanes is the whole conversation, one cell per segment.": "Un échange, sur un axe temporel. Chaque ligne est une voie : les messages, les appels du modèle, les outils exécutés, l’activité des agents et les flux imbriqués démarrés. La ligne au-dessus des voies est la conversation entière, une cellule par segment.",
+ "the heading": "l’en-tête",
+ "Which stream the lanes belong to, how many steps are in this talk, and how many nested streams it touches.": "À quel flux appartiennent les voies, combien d’étapes compte cet échange et combien de flux imbriqués il touche.",
+ "the axis": "l’axe",
+ "Busy stretches take the width. A long pause is cut to a short gap marked with how long it was, so a talk with an hour of waiting still reads.": "Les périodes actives prennent la largeur. Une longue pause est réduite à un court intervalle annoté de sa durée, si bien qu’un échange avec une heure d’attente reste lisible.",
+ "links": "liens",
+ "The lines between lanes, drawn for the step you select. A solid line is an exact join, a dashed one was inferred, and a faint curve is ownership: the model call that produced a step.": "Les lignes entre les voies, tracées pour l’étape sélectionnée. Une ligne pleine est une jointure exacte, une ligne tiretée a été inférée, et une courbe pâle est l’appartenance : l’appel du modèle qui a produit une étape.",
+ "fade unrelated": "estomper le reste",
+ "After you click a step, fade everything it does not touch.": "Après un clic sur une étape, tout ce qu’elle ne touche pas est estompé.",
+ "Inspector": "Inspecteur",
+ "Evidence": "Preuves",
+ "Select a step.": "Sélectionnez une étape.",
+ "nested stream": "flux imbriqué",
+ "Stream": "Flux",
+ "Opened by": "Ouvert par",
+ "Reported here by": "Signalé ici par",
+ "this talk hears the ending. The call that started it is in an earlier talk, because a launch and its completion land in different turns.": "cet échange entend la fin. L’appel qui l’a démarré se trouve dans un échange antérieur, car un lancement et son achèvement tombent dans des tours différents.",
+ "Join quality": "Qualité de la jointure",
+ "Opened at": "Ouvert à",
+ "Its answer": "Sa réponse",
+ "not in this stream. A child’s output belongs to the child, so the parent holds a boundary that refers to it, never a copy. Dive in to read it.": "pas dans ce flux. La sortie d’un enfant appartient à l’enfant ; le parent ne détient qu’une frontière qui y renvoie, jamais une copie. Plongez pour la lire.",
+ "Opens in turn": "Ouvre à son tour",
+ "nothing — it opened no stream of its own": "rien — il n’a ouvert aucun flux propre",
+ "and {n} more": "et {n} de plus",
+ "this call started {n} streams": "cet appel a démarré {n} flux",
+ "A workflow call starts a run, and the run starts a pool of agents. They are all its children, not competing guesses at one child.": "Un appel de workflow démarre une exécution, et l’exécution démarre un groupe d’agents. Ce sont tous ses enfants, non des hypothèses concurrentes sur un seul enfant.",
+ "Dive in →": "Plonger →",
+ "Enter to dive in · Escape to clear": "Entrée pour plonger · Échap pour effacer",
+ "Node kind": "Type de nœud",
+ "Lane": "Voie",
+ "Segment": "Segment",
+ "Direct parent": "Parent direct",
+ "Observed at": "Observé à",
+ "Request to result": "De la requête au résultat",
+ "unavailable — the runtime does not report how long this ran": "indisponible — le runtime n’indique pas la durée de cette exécution",
+ "What \"request to result\" is": "Ce qu’est « de la requête au résultat »",
+ "The time between the record carrying the request and the record carrying the result. An exact identifier ties those two records together, so the interval belongs to this call. It is not how long the tool ran, and the runtime does not report that.": "Le temps entre l’enregistrement portant la requête et celui portant le résultat. Un identifiant exact lie ces deux enregistrements, l’intervalle appartient donc à cet appel. Ce n’est pas la durée d’exécution de l’outil, que le runtime n’in [...]
+ "yes": "oui",
+ "Content state": "État du contenu",
+ "Content bytes": "Octets du contenu",
+ "Tokens": "Jetons",
+ "in {in} · out {out} · cache read {cacheRead} · cache write {cacheWrite}": "entrée {in} · sortie {out} · cache lu {cacheRead} · cache écrit {cacheWrite}",
+ "Content {state}": "Contenu {state}",
+ "The source did not supply readable content for this step. It is reported as it was found, not filled in.": "La source n’a fourni aucun contenu lisible pour cette étape. Il est rapporté tel que trouvé, sans être complété.",
+ "1 child agent": "1 agent enfant",
+ "{n} child agents": "{n} agents enfants",
+ "open the Relations tab →": "ouvrir l’onglet Relations →",
+ "← Back to parent stream": "← Retour au flux parent",
+ "No relation touches this step.": "Aucune relation ne touche cette étape.",
+ "{n} agents created by this call": "{n} agents créés par cet appel",
+ "A workflow call starts a run, and the run starts a pool of agents. Every one of them is a child of this call, not a competing guess at one child. Pick the one to read.": "Un appel de workflow démarre une exécution, et l’exécution démarre un groupe d’agents. Chacun d’eux est un enfant de cet appel, non une hypothèse concurrente sur un seul enfant. Choisissez celui à lire.",
+ "outgoing": "sortant",
+ "incoming": "entrant",
+ "joined on": "joint sur",
+ "named from its run journal": "nommé d’après son journal d’exécution",
+ "the other end is outside this stream": "l’autre extrémité est hors de ce flux",
+ "This step was derived by assembly. It cites no landed record.": "Cette étape a été dérivée par l’assemblage. Elle ne cite aucun enregistrement reçu.",
+ "landed positions": "positions reçues",
+ "request": "requête",
+ "part": "partie",
+ "the text as the document carries it": "le texte tel que le document le porte",
+ "clipped: {shown} of {total} bytes": "tronqué : {shown} octets sur {total}",
+ "Load the landed record": "Charger l’enregistrement reçu",
+ "reading…": "lecture…",
+ "The record could not be read.": "L’enregistrement n’a pas pu être lu.",
+ "the landed record": "l’enregistrement reçu",
+ "dropped": "abandonné",
+ "vocabulary": "vocabulaire",
+ "the model’s own word": "le mot du modèle lui-même",
+ "asz term": "terme asz",
+ "runtime word": "mot du runtime",
+ "none — this is derived here, the runtime has no word for it": "aucun — ceci est dérivé ici, le runtime n’a pas de mot pour cela",
+ "where to look": "où regarder",
+ "nowhere in the source; it is produced by assembly": "nulle part dans la source ; produit par l’assemblage",
+ "read it carefully": "à lire attentivement",
+ "dialect": "dialecte",
+ "a field of the landed record": "un champ de l’enregistrement reçu",
+ "asz field": "champ asz",
+ "what it is": "ce que c’est",
+ "not applicable — this describes the landed record, not the source": "sans objet — ceci décrit l’enregistrement reçu, pas la source",
+ "What does {key} mean?": "Que signifie {key} ?",
+ "Selected {what}": "{what} sélectionné",
+ "Selected nested stream {name}, opened by {kind}. Press Enter to dive in, Escape to clear.": "Flux imbriqué {name} sélectionné, ouvert par {kind}. Entrée pour plonger, Échap pour effacer.",
+ "Selection cleared.": "Sélection effacée.",
+ "Drawing the conversation…": "Dessin de la conversation…",
+ "Laying out {talks} talks and {steps} steps.": "Mise en page de {talks} échanges et {steps} étapes.",
+ "Nothing arrives until OAP has folded the whole chain.": "Rien n’arrive tant qu’OAP n’a pas replié toute la chaîne.",
+ "OAP is assembling the conversation": "OAP assemble la conversation",
+ "Pick a time range, then click Run query.": "Choisissez une période, puis cliquez sur Exécuter la requête.",
+ "about {s} s left": "environ {s} s restantes",
+ "drawing": "dessin",
+ "parsing": "analyse",
+ "{n} of {m} MB received": "{n} sur {m} Mo reçus",
+ "{rate} MB/s": "{rate} Mo/s",
+ "{s} s": "{s} s",
+ "{talks} talks · {steps} steps · {streams} streams, as OAP counted them.": "{talks} échanges · {steps} étapes · {streams} flux, tels qu’OAP les a comptés.",
+ "Title contains": "Le titre contient",
+ "Conversation id": "Identifiant de conversation",
+ "OAP answered with an error; the rows below may be incomplete.": "OAP a répondu par une erreur ; les lignes ci-dessous peuvent être incomplètes.",
+ "segments (activity windows)": "segments",
+ "span (elapsed)": "durée",
+ "Segment (activity window)": "Segment",
+ "Segments (activity windows)": "Segments",
+ "Span (elapsed)": "Durée",
+ "Nothing of this conversation could be folded, so there is nothing to read. The problems listed above say why.": "Rien de cette conversation n’a pu être replié, il n’y a donc rien à lire. Les problèmes listés ci-dessus en donnent la raison.",
+ "This OAP does not serve conversation documents; the AI agent conversation module needs OAP 11.1.0 or later.": "Cet OAP ne sert pas de documents de conversation ; le module de conversations d’agent IA requiert OAP 11.1.0 ou plus récent."
}
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 4a7e9833..8f384cb3 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -2301,7 +2301,7 @@
"next entity": "次のエンティティ",
"next ›": "次 ›",
"next ↻ {countdown}": "次回 ↻ {countdown}",
- "no": "no",
+ "no": "いいえ",
"no LAL records from this node": "このノードからの LAL レコードはありません",
"no MAL records from this node": "このノードには MAL レコードがありません",
"no body returned": "本文が返されませんでした",
@@ -2921,5 +2921,224 @@
"JSON": "JSON",
"(empty — type an expression to run it)": "(空欄 — 実行する式を入力してください)",
"Could not read the instances of this service.": "このサービスのインスタンスを取得できませんでした。",
- "Could not read the endpoints of this service.": "このサービスのエンドポイントを取得できませんでした。"
+ "Could not read the endpoints of this service.": "このサービスのエンドポイントを取得できませんでした。",
+ "See AI agent conversations": "AI エージェントの会話を表示",
+ "AI agent conversations": "AI エージェントの会話",
+ "Conversations": "会話",
+ "Sender": "送信元",
+ "The conversation list could not be read.": "会話一覧を読み取れませんでした。",
+ "{n} conversations": "{n} 件の会話",
+ "Listed from the newest {n} rounds in the window; a conversation whose newest round is older than that is not shown.": "期間内の最新 {n} ラウンドから一覧化しています。最新ラウンドがそれより古い会話は表示されません。",
+ "No conversations in this window.": "この期間に会話はありません。",
+ "Talks": "トーク",
+ "Steps": "ステップ",
+ "Streams": "ストリーム",
+ "Unresolved": "未解決",
+ "Last activity": "最終アクティビティ",
+ "(untitled)": "(無題)",
+ "The link is incomplete: it names no agent runtime.": "リンクが不完全です。Agent ランタイムが指定されていません。",
+ "OAP holds no round of this conversation for this runtime.": "OAP にはこのランタイムのこの会話のラウンドがありません。",
+ "Your role lacks the ai-conversation:read permission.": "あなたのロールには ai-conversation:read 権限がありません。",
+ "OAP did not answer within its time budget. The conversation may be very large; try again.": "OAP が時間予算内に応答しませんでした。会話が非常に大きい可能性があります。もう一度お試しください。",
+ "This Horizon cannot read the document OAP sent.": "この Horizon は OAP が送信したドキュメントを読めません。",
+ "The conversation could not be read.": "会話を読み込めませんでした。",
+ "AI agent conversation": "AI Agent 会話",
+ "All conversations": "すべての会話",
+ "Reading the conversation…": "会話を読み込んでいます…",
+ "{mb} MB received": "{mb} MB 受信",
+ "Try again": "再試行",
+ "unavailable": "利用不可",
+ "Open the conversation in a new tab": "会話を新しいタブで開く",
+ "round": "ラウンド",
+ "streams": "ストリーム",
+ "talks": "トーク",
+ "unresolved": "未解決",
+ "Session": "セッション",
+ "runs": "実行",
+ "Child streams": "子ストリーム",
+ "subagents, each its own context": "サブエージェント、それぞれ独立したコンテキスト",
+ "Relations": "関係",
+ "activity windows": "アクティビティ期間",
+ "verified": "検証済み",
+ "incomplete": "不完全",
+ "mismatch": "不一致",
+ "problems": "問題",
+ "rounds verified": "ラウンド検証済み",
+ "No talk matches.": "一致するトークはありません。",
+ "Talk": "トーク",
+ "main stream, child work folded": "メインストリーム、子の作業は折りたたみ",
+ "child stream": "子ストリーム",
+ "tools": "ツール",
+ "External · Input": "外部 · 入力",
+ "no opening line": "冒頭の一文なし",
+ "delegated work": "委任された作業",
+ "show what the agent did": "Agent が行ったことを表示",
+ "hide what the agent did": "Agent が行ったことを隠す",
+ "quiet": "静止",
+ "Main agent": "メイン Agent",
+ "Child agent": "子 Agent",
+ "Response": "応答",
+ "Stream output": "ストリーム出力",
+ "Client-made message · not from the provider": "クライアント生成メッセージ · プロバイダーからではありません",
+ "Context put in": "投入されたコンテキスト",
+ "Agent call": "Agent 呼び出し",
+ "Runtime notification": "ランタイム通知",
+ "Launch acknowledged": "起動確認済み",
+ "Model call": "モデル呼び出し",
+ "Reasoning": "推論",
+ "Tool": "ツール",
+ "call": "呼び出し",
+ "turn": "ターン",
+ "input": "入力",
+ "result": "結果",
+ "failed": "失敗",
+ "to return": "返却まで",
+ "Open": "開く",
+ "Independent stream context. Its messages are not merged into the parent.": "独立したストリームコンテキスト。そのメッセージは親にマージされません。",
+ "Opened from": "開いた元",
+ "No relation records what opened it.": "何がこれを開いたかを記録する関係はありません。",
+ "← Back to the step that opened this": "← これを開いたステップへ戻る",
+ "No talks in this stream.": "このストリームにトークはありません。",
+ "Outside any talk": "どのトークにも属さない",
+ "Steps the fold could not place under a talk — a child’s output whose stream opened no talk, for instance. The document holds them so nothing is lost.": "折りたたみでどのトークにも置けなかったステップ。たとえば、ストリームがトークを開かなかった子の出力です。ドキュメントはそれらを保持し、何も失われません。",
+ "child streams…": "子ストリーム…",
+ "child streams": "子ストリーム",
+ "Back to": "戻る:",
+ "Show this talk in the flow timeline": "このトークをフロータイムラインに表示",
+ "Show this talk’s answer in the flow timeline": "このトークの回答をフロータイムラインに表示",
+ "Locate in the flow timeline": "フロータイムラインで位置を示す",
+ "Flow timeline": "フロータイムライン",
+ "What is the flow timeline?": "フロータイムラインとは?",
+ "← Parent timeline": "← 親のタイムライン",
+ "Fade unrelated": "無関係を薄く",
+ "Fade the steps that the selected one does not touch": "選択したステップに関わらないステップを薄く表示",
+ "Zoom": "ズーム",
+ "Center selected": "選択を中央へ",
+ "Scroll the timeline to the selected step": "タイムラインを選択したステップまでスクロール",
+ "events": "イベント",
+ "nested": "ネスト",
+ "External input": "外部入力",
+ "Responses": "応答",
+ "Model calls": "モデル呼び出し",
+ "Tools": "ツール",
+ "Agent activity": "Agent アクティビティ",
+ "Runtime notices": "ランタイム通知",
+ "Nested streams": "ネストされたストリーム",
+ "Agent response": "Agent の応答",
+ "Agent / nested stream": "Agent / ネストされたストリーム",
+ "Context / annotation": "コンテキスト / 注釈",
+ "Emitted by (ownership)": "生成元(所有)",
+ "Exact join": "厳密な結合",
+ "Strong inference": "強い推論",
+ "agents created": "Agent が作成されました",
+ "This call started {n} child agents at nearly the same moment. Click, then pick one in the inspector.": "この呼び出しはほぼ同時に {n} 個の子 Agent を起動しました。クリックしてインスペクターで 1 つを選んでください。",
+ "It reported finishing in this talk.": "このトークで完了を報告しました。",
+ "Click to see what opened it.": "クリックして何がこれを開いたかを確認します。",
+ "what it shows": "表示内容",
+ "One talk, on a time axis. Each row is a lane: the messages, the model calls, the tools it ran, its agent activity, and the nested streams it started. The line above the lanes is the whole conversation, one cell per segment.": "1 つのトークを時間軸に沿って表示します。各行はレーンです:メッセージ、モデル呼び出し、実行したツール、Agent アクティビティ、起動したネストされたストリーム。レーンの上の行は会話全体で、セグメントごとに 1 セルです。",
+ "the heading": "見出し",
+ "Which stream the lanes belong to, how many steps are in this talk, and how many nested streams it touches.": "レーンがどのストリームに属するか、このトークにいくつステップがあるか、いくつのネストされたストリームに触れるか。",
+ "the axis": "軸",
+ "Busy stretches take the width. A long pause is cut to a short gap marked with how long it was, so a talk with an hour of waiting still reads.": "忙しい期間が幅を占めます。長い休止は長さを記した短いギャップに縮められるため、1 時間待ったトークでも読みやすいままです。",
+ "links": "リンク",
+ "The lines between lanes, drawn for the step you select. A solid line is an exact join, a dashed one was inferred, and a faint curve is ownership: the model call that produced a step.": "レーン間の線は、選択したステップについて描かれます。実線は厳密な結合、破線は推論、淡い曲線は所有、つまりそのステップを生成したモデル呼び出しです。",
+ "fade unrelated": "無関係を薄く",
+ "After you click a step, fade everything it does not touch.": "ステップをクリックすると、それに関わらないものすべてを薄く表示します。",
+ "Inspector": "インスペクター",
+ "Evidence": "証拠",
+ "Select a step.": "ステップを選択してください。",
+ "nested stream": "ネストされたストリーム",
+ "Stream": "ストリーム",
+ "Opened by": "開いた主体",
+ "Reported here by": "ここで報告した主体",
+ "this talk hears the ending. The call that started it is in an earlier talk, because a launch and its completion land in different turns.": "このトークは終わりを聞いています。これを開始した呼び出しは以前のトークにあります。起動と完了は別のターンに落ちるためです。",
+ "Join quality": "結合の品質",
+ "Opened at": "開いた時刻",
+ "Its answer": "その回答",
+ "not in this stream. A child’s output belongs to the child, so the parent holds a boundary that refers to it, never a copy. Dive in to read it.": "このストリームにはありません。子の出力は子に属するため、親はそれを参照する境界だけを持ち、コピーは持ちません。潜って読んでください。",
+ "Opens in turn": "順に開く",
+ "nothing — it opened no stream of its own": "なし — 自身のストリームを開きませんでした",
+ "and {n} more": "ほか {n} 件",
+ "this call started {n} streams": "この呼び出しは {n} 個のストリームを開始しました",
+ "A workflow call starts a run, and the run starts a pool of agents. They are all its children, not competing guesses at one child.": "ワークフロー呼び出しは実行を開始し、実行は Agent のプールを開始します。それらはすべてその子であり、1 つの子に対する競合する推測ではありません。",
+ "Dive in →": "潜る →",
+ "Enter to dive in · Escape to clear": "Enter で潜る · Escape で解除",
+ "Node kind": "ノードの種類",
+ "Lane": "レーン",
+ "Segment": "セグメント",
+ "Direct parent": "直接の親",
+ "Observed at": "観測時刻",
+ "Request to result": "要求から結果まで",
+ "unavailable — the runtime does not report how long this ran": "利用不可 — ランタイムはこれの実行時間を報告しません",
+ "What \"request to result\" is": "「要求から結果まで」とは",
+ "The time between the record carrying the request and the record carrying the result. An exact identifier ties those two records together, so the interval belongs to this call. It is not how long the tool ran, and the runtime does not report that.": "要求を運ぶレコードと結果を運ぶレコードの間の時間です。厳密な識別子がその 2 つのレコードを結び付けるため、この間隔はこの呼び出しに属します。ツールの実行時間ではなく、ランタイムはそれを報告しません。",
+ "yes": "はい",
+ "Content state": "コンテンツの状態",
+ "Content bytes": "コンテンツのバイト数",
+ "Tokens": "トークン",
+ "in {in} · out {out} · cache read {cacheRead} · cache write {cacheWrite}": "入力 {in} · 出力 {out} · キャッシュ読み取り {cacheRead} · キャッシュ書き込み {cacheWrite}",
+ "Content {state}": "コンテンツ {state}",
+ "The source did not supply readable content for this step. It is reported as it was found, not filled in.": "ソースはこのステップの読める内容を提供しませんでした。見つかったとおりに報告し、補完はしていません。",
+ "1 child agent": "子 Agent 1 個",
+ "{n} child agents": "子 Agent {n} 個",
+ "open the Relations tab →": "関係タブを開く →",
+ "← Back to parent stream": "← 親ストリームへ戻る",
+ "No relation touches this step.": "このステップに関わる関係はありません。",
+ "{n} agents created by this call": "この呼び出しで作成された Agent {n} 個",
+ "A workflow call starts a run, and the run starts a pool of agents. Every one of them is a child of this call, not a competing guess at one child. Pick the one to read.": "ワークフロー呼び出しは実行を開始し、実行は Agent のプールを開始します。そのすべてがこの呼び出しの子であり、1 つの子に対する競合する推測ではありません。読むものを選んでください。",
+ "outgoing": "送信",
+ "incoming": "受信",
+ "joined on": "結合キー",
+ "named from its run journal": "実行ジャーナルから命名",
+ "the other end is outside this stream": "相手側はこのストリームの外にあります",
+ "This step was derived by assembly. It cites no landed record.": "このステップは組み立てで導出されました。着地レコードは引用していません。",
+ "landed positions": "着地位置",
+ "request": "要求",
+ "part": "部分",
+ "the text as the document carries it": "ドキュメントが運ぶままのテキスト",
+ "clipped: {shown} of {total} bytes": "切り詰め:{total} バイト中 {shown} バイト",
+ "Load the landed record": "着地レコードを読み込む",
+ "reading…": "読み込み中…",
+ "The record could not be read.": "レコードを読み込めませんでした。",
+ "the landed record": "着地レコード",
+ "dropped": "破棄",
+ "vocabulary": "用語",
+ "the model’s own word": "モデル自身の言葉",
+ "asz term": "asz 用語",
+ "runtime word": "ランタイムの言葉",
+ "none — this is derived here, the runtime has no word for it": "なし — ここで導出されたもので、ランタイムには対応する言葉がありません",
+ "where to look": "参照先",
+ "nowhere in the source; it is produced by assembly": "ソースにはありません。組み立てで生成されます",
+ "read it carefully": "注意して読んでください",
+ "dialect": "方言",
+ "a field of the landed record": "着地レコードのフィールド",
+ "asz field": "asz フィールド",
+ "what it is": "それは何か",
+ "not applicable — this describes the landed record, not the source": "該当なし — これは着地レコードの説明であり、ソースのものではありません",
+ "What does {key} mean?": "{key} とは何ですか?",
+ "Selected {what}": "{what} を選択しました",
+ "Selected nested stream {name}, opened by {kind}. Press Enter to dive in, Escape to clear.": "{kind} が開いたネストされたストリーム {name} を選択しました。Enter で潜り、Escape で解除します。",
+ "Selection cleared.": "選択を解除しました。",
+ "Drawing the conversation…": "会話を描画しています…",
+ "Laying out {talks} talks and {steps} steps.": "{talks} 件のトークと {steps} 件のステップを配置しています。",
+ "Nothing arrives until OAP has folded the whole chain.": "OAP がチェーン全体を折りたたむまで何も届きません。",
+ "OAP is assembling the conversation": "OAP が会話を組み立てています",
+ "Pick a time range, then click Run query.": "期間を選び、「クエリを実行」をクリックしてください。",
+ "about {s} s left": "残り約 {s} 秒",
+ "drawing": "描画中",
+ "parsing": "解析中",
+ "{n} of {m} MB received": "{m} MB 中 {n} MB 受信",
+ "{rate} MB/s": "{rate} MB/s",
+ "{s} s": "{s} 秒",
+ "{talks} talks · {steps} steps · {streams} streams, as OAP counted them.": "トーク {talks} 件 · ステップ {steps} 件 · ストリーム {streams} 件(OAP 集計)",
+ "Title contains": "タイトルに含む",
+ "Conversation id": "会話 ID",
+ "OAP answered with an error; the rows below may be incomplete.": "OAP がエラーを返しました。以下の行は不完全な可能性があります。",
+ "segments (activity windows)": "セグメント",
+ "span (elapsed)": "経過",
+ "Segment (activity window)": "セグメント",
+ "Segments (activity windows)": "セグメント",
+ "Span (elapsed)": "経過時間",
+ "Nothing of this conversation could be folded, so there is nothing to read. The problems listed above say why.": "この会話は何も折りたためなかったため、読めるものがありません。上に挙げた問題がその理由です。",
+ "This OAP does not serve conversation documents; the AI agent conversation module needs OAP 11.1.0 or later.": "この OAP は会話ドキュメントを提供しません。AI エージェント会話モジュールには OAP 11.1.0 以降が必要です。"
}
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 8f1c3e34..a7042c01 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -2921,5 +2921,224 @@
"JSON": "JSON",
"(empty — type an expression to run it)": "(비어 있음 — 실행할 표현식을 입력하세요)",
"Could not read the instances of this service.": "이 서비스의 인스턴스를 읽을 수 없습니다.",
- "Could not read the endpoints of this service.": "이 서비스의 엔드포인트를 읽을 수 없습니다."
+ "Could not read the endpoints of this service.": "이 서비스의 엔드포인트를 읽을 수 없습니다.",
+ "See AI agent conversations": "AI 에이전트 대화 보기",
+ "AI agent conversations": "AI 에이전트 대화",
+ "Conversations": "대화",
+ "Sender": "발신자",
+ "The conversation list could not be read.": "대화 목록을 읽을 수 없습니다.",
+ "{n} conversations": "대화 {n}개",
+ "Listed from the newest {n} rounds in the window; a conversation whose newest round is older than that is not shown.": "기간 내 최신 {n}개 라운드에서 나열됩니다. 최신 라운드가 그보다 오래된 대화는 표시되지 않습니다.",
+ "No conversations in this window.": "이 기간에 대화가 없습니다.",
+ "Talks": "대화 턴",
+ "Steps": "단계",
+ "Streams": "스트림",
+ "Unresolved": "미해결",
+ "Last activity": "마지막 활동",
+ "(untitled)": "(제목 없음)",
+ "The link is incomplete: it names no agent runtime.": "링크가 불완전합니다. Agent 런타임이 지정되지 않았습니다.",
+ "OAP holds no round of this conversation for this runtime.": "OAP에 이 런타임의 이 대화 라운드가 없습니다.",
+ "Your role lacks the ai-conversation:read permission.": "현재 역할에 ai-conversation:read 권한이 없습니다.",
+ "OAP did not answer within its time budget. The conversation may be very large; try again.": "OAP가 시간 예산 안에 응답하지 않았습니다. 대화가 매우 클 수 있으니 다시 시도하세요.",
+ "This Horizon cannot read the document OAP sent.": "이 Horizon은 OAP가 보낸 문서를 읽을 수 없습니다.",
+ "The conversation could not be read.": "대화를 읽을 수 없습니다.",
+ "AI agent conversation": "AI Agent 대화",
+ "All conversations": "모든 대화",
+ "Reading the conversation…": "대화를 읽는 중…",
+ "{mb} MB received": "{mb} MB 수신",
+ "Try again": "다시 시도",
+ "unavailable": "사용 불가",
+ "Open the conversation in a new tab": "새 탭에서 대화 열기",
+ "round": "라운드",
+ "streams": "스트림",
+ "talks": "토크",
+ "unresolved": "미해결",
+ "Session": "세션",
+ "runs": "실행",
+ "Child streams": "자식 스트림",
+ "subagents, each its own context": "서브에이전트, 각각 독립된 컨텍스트",
+ "Relations": "관계",
+ "activity windows": "활동 구간",
+ "verified": "검증됨",
+ "incomplete": "불완전",
+ "mismatch": "불일치",
+ "problems": "문제",
+ "rounds verified": "라운드 검증됨",
+ "No talk matches.": "일치하는 토크가 없습니다.",
+ "Talk": "토크",
+ "main stream, child work folded": "메인 스트림, 자식 작업 접힘",
+ "child stream": "자식 스트림",
+ "tools": "도구",
+ "External · Input": "외부 · 입력",
+ "no opening line": "첫 문장 없음",
+ "delegated work": "위임된 작업",
+ "show what the agent did": "Agent가 한 일 보기",
+ "hide what the agent did": "Agent가 한 일 숨기기",
+ "quiet": "정지",
+ "Main agent": "메인 Agent",
+ "Child agent": "자식 Agent",
+ "Response": "응답",
+ "Stream output": "스트림 출력",
+ "Client-made message · not from the provider": "클라이언트가 만든 메시지 · 제공자에서 온 것이 아님",
+ "Context put in": "투입된 컨텍스트",
+ "Agent call": "Agent 호출",
+ "Runtime notification": "런타임 알림",
+ "Launch acknowledged": "시작 확인됨",
+ "Model call": "모델 호출",
+ "Reasoning": "추론",
+ "Tool": "도구",
+ "call": "호출",
+ "turn": "턴",
+ "input": "입력",
+ "result": "결과",
+ "failed": "실패",
+ "to return": "반환까지",
+ "Open": "열기",
+ "Independent stream context. Its messages are not merged into the parent.": "독립된 스트림 컨텍스트입니다. 이 메시지는 부모에 병합되지 않습니다.",
+ "Opened from": "열린 위치",
+ "No relation records what opened it.": "무엇이 이것을 열었는지 기록한 관계가 없습니다.",
+ "← Back to the step that opened this": "← 이것을 연 단계로 돌아가기",
+ "No talks in this stream.": "이 스트림에 토크가 없습니다.",
+ "Outside any talk": "어느 토크에도 속하지 않음",
+ "Steps the fold could not place under a talk — a child’s output whose stream opened no talk, for instance. The document holds them so nothing is lost.": "접기가 어느 토크 아래에도 놓을 수 없었던 단계입니다. 예를 들어 스트림이 토크를 열지 않은 자식의 출력입니다. 문서가 이를 보존하므로 잃는 것은 없습니다.",
+ "child streams…": "자식 스트림…",
+ "child streams": "자식 스트림",
+ "Back to": "돌아가기:",
+ "Show this talk in the flow timeline": "이 토크를 흐름 타임라인에 표시",
+ "Show this talk’s answer in the flow timeline": "이 토크의 답을 흐름 타임라인에 표시",
+ "Locate in the flow timeline": "흐름 타임라인에서 찾기",
+ "Flow timeline": "흐름 타임라인",
+ "What is the flow timeline?": "흐름 타임라인이란?",
+ "← Parent timeline": "← 부모 타임라인",
+ "Fade unrelated": "무관한 항목 흐리게",
+ "Fade the steps that the selected one does not touch": "선택한 단계와 관련 없는 단계를 흐리게 표시",
+ "Zoom": "확대/축소",
+ "Center selected": "선택 항목 가운데로",
+ "Scroll the timeline to the selected step": "타임라인을 선택한 단계까지 스크롤",
+ "events": "이벤트",
+ "nested": "중첩",
+ "External input": "외부 입력",
+ "Responses": "응답",
+ "Model calls": "모델 호출",
+ "Tools": "도구",
+ "Agent activity": "Agent 활동",
+ "Runtime notices": "런타임 알림",
+ "Nested streams": "중첩 스트림",
+ "Agent response": "Agent 응답",
+ "Agent / nested stream": "Agent / 중첩 스트림",
+ "Context / annotation": "컨텍스트 / 주석",
+ "Emitted by (ownership)": "생성 주체(소유)",
+ "Exact join": "정확한 결합",
+ "Strong inference": "강한 추론",
+ "agents created": "Agent 생성됨",
+ "This call started {n} child agents at nearly the same moment. Click, then pick one in the inspector.": "이 호출은 거의 같은 순간에 {n}개의 자식 Agent를 시작했습니다. 클릭한 뒤 인스펙터에서 하나를 선택하세요.",
+ "It reported finishing in this talk.": "이 토크에서 완료를 보고했습니다.",
+ "Click to see what opened it.": "클릭하여 무엇이 이것을 열었는지 확인합니다.",
+ "what it shows": "표시 내용",
+ "One talk, on a time axis. Each row is a lane: the messages, the model calls, the tools it ran, its agent activity, and the nested streams it started. The line above the lanes is the whole conversation, one cell per segment.": "토크 하나를 시간 축 위에 놓습니다. 각 행은 레인입니다: 메시지, 모델 호출, 실행한 도구, Agent 활동, 그리고 시작한 중첩 스트림. 레인 위의 줄은 전체 대화이며 세그먼트마다 한 칸입니다.",
+ "the heading": "제목",
+ "Which stream the lanes belong to, how many steps are in this talk, and how many nested streams it touches.": "레인이 어느 스트림에 속하는지, 이 토크에 단계가 몇 개인지, 중첩 스트림을 몇 개 건드리는지.",
+ "the axis": "축",
+ "Busy stretches take the width. A long pause is cut to a short gap marked with how long it was, so a talk with an hour of waiting still reads.": "바쁜 구간이 너비를 차지합니다. 긴 멈춤은 길이를 표시한 짧은 간격으로 줄어들어, 한 시간을 기다린 토크도 읽기 쉽습니다.",
+ "links": "연결선",
+ "The lines between lanes, drawn for the step you select. A solid line is an exact join, a dashed one was inferred, and a faint curve is ownership: the model call that produced a step.": "레인 사이의 선은 선택한 단계에 대해 그려집니다. 실선은 정확한 결합, 점선은 추론, 흐린 곡선은 소유, 즉 그 단계를 만든 모델 호출입니다.",
+ "fade unrelated": "무관한 항목 흐리게",
+ "After you click a step, fade everything it does not touch.": "단계를 클릭하면 그것과 관련 없는 모든 것을 흐리게 합니다.",
+ "Inspector": "인스펙터",
+ "Evidence": "증거",
+ "Select a step.": "단계를 선택하세요.",
+ "nested stream": "중첩 스트림",
+ "Stream": "스트림",
+ "Opened by": "연 주체",
+ "Reported here by": "여기서 보고한 주체",
+ "this talk hears the ending. The call that started it is in an earlier talk, because a launch and its completion land in different turns.": "이 토크는 끝을 듣습니다. 이를 시작한 호출은 이전 토크에 있으며, 시작과 완료가 서로 다른 턴에 놓이기 때문입니다.",
+ "Join quality": "결합 품질",
+ "Opened at": "열린 시각",
+ "Its answer": "그 답",
+ "not in this stream. A child’s output belongs to the child, so the parent holds a boundary that refers to it, never a copy. Dive in to read it.": "이 스트림에 없습니다. 자식의 출력은 자식에 속하므로 부모는 복사본이 아니라 그것을 가리키는 경계만 가집니다. 들어가서 읽으세요.",
+ "Opens in turn": "차례로 여는 것",
+ "nothing — it opened no stream of its own": "없음 — 자체 스트림을 열지 않았습니다",
+ "and {n} more": "외 {n}개",
+ "this call started {n} streams": "이 호출은 {n}개의 스트림을 시작했습니다",
+ "A workflow call starts a run, and the run starts a pool of agents. They are all its children, not competing guesses at one child.": "워크플로 호출은 실행을 시작하고, 실행은 Agent 풀을 시작합니다. 모두 그 자식이며, 하나의 자식에 대한 경쟁하는 추측이 아닙니다.",
+ "Dive in →": "들어가기 →",
+ "Enter to dive in · Escape to clear": "Enter로 들어가기 · Escape로 해제",
+ "Node kind": "노드 종류",
+ "Lane": "레인",
+ "Segment": "세그먼트",
+ "Direct parent": "직접 부모",
+ "Observed at": "관측 시각",
+ "Request to result": "요청부터 결과까지",
+ "unavailable — the runtime does not report how long this ran": "사용 불가 — 런타임이 이것의 실행 시간을 보고하지 않습니다",
+ "What \"request to result\" is": "“요청부터 결과까지”란",
+ "The time between the record carrying the request and the record carrying the result. An exact identifier ties those two records together, so the interval belongs to this call. It is not how long the tool ran, and the runtime does not report that.": "요청을 담은 레코드와 결과를 담은 레코드 사이의 시간입니다. 정확한 식별자가 두 레코드를 묶으므로 이 간격은 이 호출에 속합니다. 도구가 실행된 시간이 아니며, 런타임은 그것을 보고하지 않습니다.",
+ "yes": "예",
+ "Content state": "콘텐츠 상태",
+ "Content bytes": "콘텐츠 바이트",
+ "Tokens": "토큰",
+ "in {in} · out {out} · cache read {cacheRead} · cache write {cacheWrite}": "입력 {in} · 출력 {out} · 캐시 읽기 {cacheRead} · 캐시 쓰기 {cacheWrite}",
+ "Content {state}": "콘텐츠 {state}",
+ "The source did not supply readable content for this step. It is reported as it was found, not filled in.": "소스가 이 단계에 대해 읽을 수 있는 콘텐츠를 제공하지 않았습니다. 발견된 대로 보고하며 채워 넣지 않습니다.",
+ "1 child agent": "자식 Agent 1개",
+ "{n} child agents": "자식 Agent {n}개",
+ "open the Relations tab →": "관계 탭 열기 →",
+ "← Back to parent stream": "← 부모 스트림으로 돌아가기",
+ "No relation touches this step.": "이 단계에 관련된 관계가 없습니다.",
+ "{n} agents created by this call": "이 호출로 생성된 Agent {n}개",
+ "A workflow call starts a run, and the run starts a pool of agents. Every one of them is a child of this call, not a competing guess at one child. Pick the one to read.": "워크플로 호출은 실행을 시작하고, 실행은 Agent 풀을 시작합니다. 모두 이 호출의 자식이며, 하나의 자식에 대한 경쟁하는 추측이 아닙니다. 읽을 것을 선택하세요.",
+ "outgoing": "나가는",
+ "incoming": "들어오는",
+ "joined on": "결합 기준",
+ "named from its run journal": "실행 저널에서 이름 지정",
+ "the other end is outside this stream": "다른 끝은 이 스트림 밖에 있습니다",
+ "This step was derived by assembly. It cites no landed record.": "이 단계는 조립으로 도출되었습니다. 인용하는 착지 레코드가 없습니다.",
+ "landed positions": "착지 위치",
+ "request": "요청",
+ "part": "부분",
+ "the text as the document carries it": "문서가 담고 있는 그대로의 텍스트",
+ "clipped: {shown} of {total} bytes": "잘림: {total}바이트 중 {shown}바이트",
+ "Load the landed record": "착지 레코드 불러오기",
+ "reading…": "읽는 중…",
+ "The record could not be read.": "레코드를 읽을 수 없습니다.",
+ "the landed record": "착지 레코드",
+ "dropped": "삭제됨",
+ "vocabulary": "용어",
+ "the model’s own word": "모델 자신의 말",
+ "asz term": "asz 용어",
+ "runtime word": "런타임 용어",
+ "none — this is derived here, the runtime has no word for it": "없음 — 여기서 도출된 것으로 런타임에는 해당하는 말이 없습니다",
+ "where to look": "찾아볼 곳",
+ "nowhere in the source; it is produced by assembly": "소스에는 없음; 조립으로 생성됨",
+ "read it carefully": "주의해서 읽으세요",
+ "dialect": "방언",
+ "a field of the landed record": "착지 레코드의 필드",
+ "asz field": "asz 필드",
+ "what it is": "무엇인지",
+ "not applicable — this describes the landed record, not the source": "해당 없음 — 이는 소스가 아니라 착지 레코드를 설명합니다",
+ "What does {key} mean?": "{key}은(는) 무슨 뜻인가요?",
+ "Selected {what}": "{what} 선택됨",
+ "Selected nested stream {name}, opened by {kind}. Press Enter to dive in, Escape to clear.": "{kind}이(가) 연 중첩 스트림 {name}을(를) 선택했습니다. Enter로 들어가고 Escape로 해제합니다.",
+ "Selection cleared.": "선택이 해제되었습니다.",
+ "Drawing the conversation…": "대화를 그리는 중…",
+ "Laying out {talks} talks and {steps} steps.": "토크 {talks}개와 단계 {steps}개를 배치하는 중입니다.",
+ "Nothing arrives until OAP has folded the whole chain.": "OAP가 전체 체인을 접기 전까지는 아무것도 도착하지 않습니다.",
+ "OAP is assembling the conversation": "OAP가 대화를 조립하는 중",
+ "Pick a time range, then click Run query.": "시간 범위를 고른 뒤 쿼리 실행을(를) 클릭하세요.",
+ "about {s} s left": "약 {s}초 남음",
+ "drawing": "그리는 중",
+ "parsing": "파싱 중",
+ "{n} of {m} MB received": "{m} MB 중 {n} MB 수신",
+ "{rate} MB/s": "{rate} MB/s",
+ "{s} s": "{s}초",
+ "{talks} talks · {steps} steps · {streams} streams, as OAP counted them.": "토크 {talks}개 · 단계 {steps}개 · 스트림 {streams}개, OAP 집계 기준",
+ "Title contains": "제목 포함",
+ "Conversation id": "대화 ID",
+ "OAP answered with an error; the rows below may be incomplete.": "OAP가 오류를 반환했습니다. 아래 행은 불완전할 수 있습니다.",
+ "segments (activity windows)": "구간",
+ "span (elapsed)": "경과",
+ "Segment (activity window)": "구간",
+ "Segments (activity windows)": "구간",
+ "Span (elapsed)": "경과 시간",
+ "Nothing of this conversation could be folded, so there is nothing to read. The problems listed above say why.": "이 대화에서 접을 수 있는 것이 없어 읽을 내용이 없습니다. 위에 나열된 문제가 그 이유입니다.",
+ "This OAP does not serve conversation documents; the AI agent conversation module needs OAP 11.1.0 or later.": "이 OAP는 대화 문서를 제공하지 않습니다. AI 에이전트 대화 모듈에는 OAP 11.1.0 이상이 필요합니다."
}
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index a38e1ec8..bffbdf09 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -2921,5 +2921,224 @@
"JSON": "JSON",
"(empty — type an expression to run it)": "(vazio — digite uma expressão para executá-la)",
"Could not read the instances of this service.": "Não foi possível ler as instâncias deste serviço.",
- "Could not read the endpoints of this service.": "Não foi possível ler os endpoints deste serviço."
+ "Could not read the endpoints of this service.": "Não foi possível ler os endpoints deste serviço.",
+ "See AI agent conversations": "Ver conversas de agentes de IA",
+ "AI agent conversations": "Conversas de agentes de IA",
+ "Conversations": "Conversas",
+ "Sender": "Remetente",
+ "The conversation list could not be read.": "Não foi possível ler a lista de conversas.",
+ "{n} conversations": "{n} conversas",
+ "Listed from the newest {n} rounds in the window; a conversation whose newest round is older than that is not shown.": "Listado a partir dos {n} rounds mais recentes do intervalo; uma conversa cujo round mais recente é anterior não é mostrada.",
+ "No conversations in this window.": "Nenhuma conversa neste intervalo.",
+ "Talks": "Trocas",
+ "Steps": "Passos",
+ "Streams": "Fluxos",
+ "Unresolved": "Não resolvidos",
+ "Last activity": "Última atividade",
+ "(untitled)": "(sem título)",
+ "The link is incomplete: it names no agent runtime.": "O link está incompleto: não indica nenhum runtime de agente.",
+ "OAP holds no round of this conversation for this runtime.": "O OAP não tem nenhuma rodada desta conversa para este runtime.",
+ "Your role lacks the ai-conversation:read permission.": "Seu papel não tem a permissão ai-conversation:read.",
+ "OAP did not answer within its time budget. The conversation may be very large; try again.": "O OAP não respondeu dentro do seu orçamento de tempo. A conversa pode ser muito grande; tente novamente.",
+ "This Horizon cannot read the document OAP sent.": "Este Horizon não consegue ler o documento que o OAP enviou.",
+ "The conversation could not be read.": "Não foi possível ler a conversa.",
+ "AI agent conversation": "Conversa de agente de IA",
+ "All conversations": "Todas as conversas",
+ "Reading the conversation…": "Lendo a conversa…",
+ "{mb} MB received": "{mb} MB recebidos",
+ "Try again": "Tentar novamente",
+ "unavailable": "indisponível",
+ "Open the conversation in a new tab": "Abrir a conversa em uma nova aba",
+ "round": "rodada",
+ "streams": "fluxos",
+ "talks": "diálogos",
+ "unresolved": "não resolvidos",
+ "Session": "Sessão",
+ "runs": "execuções",
+ "Child streams": "Fluxos filhos",
+ "subagents, each its own context": "subagentes, cada um com seu próprio contexto",
+ "Relations": "Relações",
+ "activity windows": "janelas de atividade",
+ "verified": "verificado",
+ "incomplete": "incompleto",
+ "mismatch": "divergência",
+ "problems": "problemas",
+ "rounds verified": "rodadas verificadas",
+ "No talk matches.": "Nenhum diálogo corresponde.",
+ "Talk": "Diálogo",
+ "main stream, child work folded": "fluxo principal, trabalho dos filhos recolhido",
+ "child stream": "fluxo filho",
+ "tools": "ferramentas",
+ "External · Input": "Externo · Entrada",
+ "no opening line": "sem frase de abertura",
+ "delegated work": "trabalho delegado",
+ "show what the agent did": "mostrar o que o agente fez",
+ "hide what the agent did": "ocultar o que o agente fez",
+ "quiet": "silêncio",
+ "Main agent": "Agente principal",
+ "Child agent": "Agente filho",
+ "Response": "Resposta",
+ "Stream output": "Saída do fluxo",
+ "Client-made message · not from the provider": "Mensagem criada pelo cliente · não pelo provedor",
+ "Context put in": "Contexto inserido",
+ "Agent call": "Chamada de agente",
+ "Runtime notification": "Notificação do runtime",
+ "Launch acknowledged": "Lançamento confirmado",
+ "Model call": "Chamada ao modelo",
+ "Reasoning": "Raciocínio",
+ "Tool": "Ferramenta",
+ "call": "chamada",
+ "turn": "turno",
+ "input": "entrada",
+ "result": "resultado",
+ "failed": "falhou",
+ "to return": "até retornar",
+ "Open": "Abrir",
+ "Independent stream context. Its messages are not merged into the parent.": "Contexto de fluxo independente. Suas mensagens não são mescladas no pai.",
+ "Opened from": "Aberto a partir de",
+ "No relation records what opened it.": "Nenhuma relação registra o que o abriu.",
+ "← Back to the step that opened this": "← Voltar ao passo que abriu isto",
+ "No talks in this stream.": "Nenhum diálogo neste fluxo.",
+ "Outside any talk": "Fora de qualquer diálogo",
+ "Steps the fold could not place under a talk — a child’s output whose stream opened no talk, for instance. The document holds them so nothing is lost.": "Passos que o recolhimento não conseguiu colocar sob nenhum diálogo — por exemplo, a saída de um filho cujo fluxo não abriu nenhum diálogo. O documento os preserva para que nada se perca.",
+ "child streams…": "fluxos filhos…",
+ "child streams": "fluxos filhos",
+ "Back to": "Voltar a",
+ "Show this talk in the flow timeline": "Mostrar este diálogo na linha do tempo",
+ "Show this talk’s answer in the flow timeline": "Mostrar a resposta deste diálogo na linha do tempo",
+ "Locate in the flow timeline": "Localizar na linha do tempo",
+ "Flow timeline": "Linha do tempo do fluxo",
+ "What is the flow timeline?": "O que é a linha do tempo do fluxo?",
+ "← Parent timeline": "← Linha do tempo pai",
+ "Fade unrelated": "Esmaecer o não relacionado",
+ "Fade the steps that the selected one does not touch": "Esmaecer os passos que o passo selecionado não toca",
+ "Zoom": "Zoom",
+ "Center selected": "Centralizar a seleção",
+ "Scroll the timeline to the selected step": "Rolar a linha do tempo até o passo selecionado",
+ "events": "eventos",
+ "nested": "aninhados",
+ "External input": "Entrada externa",
+ "Responses": "Respostas",
+ "Model calls": "Chamadas ao modelo",
+ "Tools": "Ferramentas",
+ "Agent activity": "Atividade de agentes",
+ "Runtime notices": "Avisos do runtime",
+ "Nested streams": "Fluxos aninhados",
+ "Agent response": "Resposta do agente",
+ "Agent / nested stream": "Agente / fluxo aninhado",
+ "Context / annotation": "Contexto / anotação",
+ "Emitted by (ownership)": "Emitido por (pertencimento)",
+ "Exact join": "Junção exata",
+ "Strong inference": "Inferência forte",
+ "agents created": "agentes criados",
+ "This call started {n} child agents at nearly the same moment. Click, then pick one in the inspector.": "Esta chamada iniciou {n} agentes filhos quase no mesmo instante. Clique e depois escolha um no inspetor.",
+ "It reported finishing in this talk.": "Ele relatou sua conclusão neste diálogo.",
+ "Click to see what opened it.": "Clique para ver o que o abriu.",
+ "what it shows": "o que ela mostra",
+ "One talk, on a time axis. Each row is a lane: the messages, the model calls, the tools it ran, its agent activity, and the nested streams it started. The line above the lanes is the whole conversation, one cell per segment.": "Um diálogo, em um eixo de tempo. Cada linha é uma faixa: as mensagens, as chamadas ao modelo, as ferramentas executadas, a atividade dos agentes e os fluxos aninhados que iniciou. A linha acima das faixas é a conversa inteira, uma célula por segmento.",
+ "the heading": "o cabeçalho",
+ "Which stream the lanes belong to, how many steps are in this talk, and how many nested streams it touches.": "A qual fluxo as faixas pertencem, quantos passos este diálogo tem e quantos fluxos aninhados ele toca.",
+ "the axis": "o eixo",
+ "Busy stretches take the width. A long pause is cut to a short gap marked with how long it was, so a talk with an hour of waiting still reads.": "Os trechos de atividade ocupam a largura. Uma pausa longa é reduzida a um intervalo curto marcado com sua duração, de modo que um diálogo com uma hora de espera continua legível.",
+ "links": "ligações",
+ "The lines between lanes, drawn for the step you select. A solid line is an exact join, a dashed one was inferred, and a faint curve is ownership: the model call that produced a step.": "As linhas entre as faixas, desenhadas para o passo selecionado. Uma linha contínua é uma junção exata, uma tracejada foi inferida, e uma curva tênue é pertencimento: a chamada ao modelo que produziu um passo.",
+ "fade unrelated": "esmaecer o não relacionado",
+ "After you click a step, fade everything it does not touch.": "Depois de clicar em um passo, tudo o que ele não toca é esmaecido.",
+ "Inspector": "Inspetor",
+ "Evidence": "Evidência",
+ "Select a step.": "Selecione um passo.",
+ "nested stream": "fluxo aninhado",
+ "Stream": "Fluxo",
+ "Opened by": "Aberto por",
+ "Reported here by": "Relatado aqui por",
+ "this talk hears the ending. The call that started it is in an earlier talk, because a launch and its completion land in different turns.": "este diálogo ouve o fim. A chamada que o iniciou está em um diálogo anterior, porque um lançamento e sua conclusão caem em turnos diferentes.",
+ "Join quality": "Qualidade da junção",
+ "Opened at": "Aberto às",
+ "Its answer": "Sua resposta",
+ "not in this stream. A child’s output belongs to the child, so the parent holds a boundary that refers to it, never a copy. Dive in to read it.": "não está neste fluxo. A saída de um filho pertence ao filho, então o pai guarda um limite que a referencia, nunca uma cópia. Mergulhe para lê-la.",
+ "Opens in turn": "Abre, por sua vez",
+ "nothing — it opened no stream of its own": "nada — não abriu nenhum fluxo próprio",
+ "and {n} more": "e mais {n}",
+ "this call started {n} streams": "esta chamada iniciou {n} fluxos",
+ "A workflow call starts a run, and the run starts a pool of agents. They are all its children, not competing guesses at one child.": "Uma chamada de fluxo de trabalho inicia uma execução, e a execução inicia um grupo de agentes. Todos são seus filhos, não suposições concorrentes sobre um único filho.",
+ "Dive in →": "Mergulhar →",
+ "Enter to dive in · Escape to clear": "Enter para mergulhar · Esc para limpar",
+ "Node kind": "Tipo de nó",
+ "Lane": "Faixa",
+ "Segment": "Segmento",
+ "Direct parent": "Pai direto",
+ "Observed at": "Observado às",
+ "Request to result": "Da solicitação ao resultado",
+ "unavailable — the runtime does not report how long this ran": "indisponível — o runtime não informa quanto tempo isto levou",
+ "What \"request to result\" is": "O que é «da solicitação ao resultado»",
+ "The time between the record carrying the request and the record carrying the result. An exact identifier ties those two records together, so the interval belongs to this call. It is not how long the tool ran, and the runtime does not report that.": "O tempo entre o registro que carrega a solicitação e o registro que carrega o resultado. Um identificador exato liga esses dois registros, portanto o intervalo pertence a esta chamada. Não é quanto tempo a ferramenta levou; o runtime não i [...]
+ "yes": "sim",
+ "Content state": "Estado do conteúdo",
+ "Content bytes": "Bytes do conteúdo",
+ "Tokens": "Tokens",
+ "in {in} · out {out} · cache read {cacheRead} · cache write {cacheWrite}": "entrada {in} · saída {out} · cache lido {cacheRead} · cache escrito {cacheWrite}",
+ "Content {state}": "Conteúdo {state}",
+ "The source did not supply readable content for this step. It is reported as it was found, not filled in.": "A fonte não forneceu conteúdo legível para este passo. Ele é relatado como foi encontrado, sem preenchimento.",
+ "1 child agent": "1 agente filho",
+ "{n} child agents": "{n} agentes filhos",
+ "open the Relations tab →": "abrir a aba Relações →",
+ "← Back to parent stream": "← Voltar ao fluxo pai",
+ "No relation touches this step.": "Nenhuma relação toca este passo.",
+ "{n} agents created by this call": "{n} agentes criados por esta chamada",
+ "A workflow call starts a run, and the run starts a pool of agents. Every one of them is a child of this call, not a competing guess at one child. Pick the one to read.": "Uma chamada de fluxo de trabalho inicia uma execução, e a execução inicia um grupo de agentes. Cada um deles é filho desta chamada, não uma suposição concorrente sobre um único filho. Escolha o que quer ler.",
+ "outgoing": "saída",
+ "incoming": "entrada",
+ "joined on": "unido por",
+ "named from its run journal": "nomeado a partir do seu diário de execução",
+ "the other end is outside this stream": "a outra ponta está fora deste fluxo",
+ "This step was derived by assembly. It cites no landed record.": "Este passo foi derivado pela montagem. Ele não cita nenhum registro recebido.",
+ "landed positions": "posições recebidas",
+ "request": "solicitação",
+ "part": "parte",
+ "the text as the document carries it": "o texto como o documento o carrega",
+ "clipped: {shown} of {total} bytes": "cortado: {shown} de {total} bytes",
+ "Load the landed record": "Carregar o registro recebido",
+ "reading…": "lendo…",
+ "The record could not be read.": "Não foi possível ler o registro.",
+ "the landed record": "o registro recebido",
+ "dropped": "descartado",
+ "vocabulary": "vocabulário",
+ "the model’s own word": "a palavra do próprio modelo",
+ "asz term": "termo asz",
+ "runtime word": "palavra do runtime",
+ "none — this is derived here, the runtime has no word for it": "nenhuma — isto é derivado aqui; o runtime não tem palavra para isso",
+ "where to look": "onde procurar",
+ "nowhere in the source; it is produced by assembly": "em nenhum lugar da fonte; é produzido pela montagem",
+ "read it carefully": "leia com atenção",
+ "dialect": "dialeto",
+ "a field of the landed record": "um campo do registro recebido",
+ "asz field": "campo asz",
+ "what it is": "o que é",
+ "not applicable — this describes the landed record, not the source": "não aplicável — isto descreve o registro recebido, não a fonte",
+ "What does {key} mean?": "O que significa {key}?",
+ "Selected {what}": "Selecionado {what}",
+ "Selected nested stream {name}, opened by {kind}. Press Enter to dive in, Escape to clear.": "Fluxo aninhado {name} selecionado, aberto por {kind}. Pressione Enter para mergulhar, Esc para limpar.",
+ "Selection cleared.": "Seleção limpa.",
+ "Drawing the conversation…": "Desenhando a conversa…",
+ "Laying out {talks} talks and {steps} steps.": "Dispondo {talks} diálogos e {steps} passos.",
+ "Nothing arrives until OAP has folded the whole chain.": "Nada chega até que o OAP tenha dobrado toda a cadeia.",
+ "OAP is assembling the conversation": "O OAP está montando a conversa",
+ "Pick a time range, then click Run query.": "Escolha um intervalo de tempo e clique em Executar consulta.",
+ "about {s} s left": "cerca de {s} s restantes",
+ "drawing": "desenhando",
+ "parsing": "analisando",
+ "{n} of {m} MB received": "{n} de {m} MB recebidos",
+ "{rate} MB/s": "{rate} MB/s",
+ "{s} s": "{s} s",
+ "{talks} talks · {steps} steps · {streams} streams, as OAP counted them.": "{talks} diálogos · {steps} passos · {streams} fluxos, conforme o OAP os contou.",
+ "Title contains": "O título contém",
+ "Conversation id": "Id da conversa",
+ "OAP answered with an error; the rows below may be incomplete.": "O OAP respondeu com um erro; as linhas abaixo podem estar incompletas.",
+ "segments (activity windows)": "segmentos",
+ "span (elapsed)": "duração",
+ "Segment (activity window)": "Segmento",
+ "Segments (activity windows)": "Segmentos",
+ "Span (elapsed)": "Duração",
+ "Nothing of this conversation could be folded, so there is nothing to read. The problems listed above say why.": "Nada desta conversa pôde ser dobrado, então não há nada para ler. Os problemas listados acima explicam o motivo.",
+ "This OAP does not serve conversation documents; the AI agent conversation module needs OAP 11.1.0 or later.": "Este OAP não serve documentos de conversa; o módulo de conversas de agente de IA requer o OAP 11.1.0 ou mais recente."
}
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index 1352a1f0..7e2f610e 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -2921,5 +2921,224 @@
"JSON": "JSON",
"(empty — type an expression to run it)": "(为空 —— 输入一个表达式即可运行)",
"Could not read the instances of this service.": "无法读取该服务的实例列表。",
- "Could not read the endpoints of this service.": "无法读取该服务的端点列表。"
+ "Could not read the endpoints of this service.": "无法读取该服务的端点列表。",
+ "See AI agent conversations": "查看 AI 智能体会话",
+ "AI agent conversations": "AI 智能体会话",
+ "Conversations": "会话",
+ "Sender": "发送方",
+ "The conversation list could not be read.": "无法读取会话列表。",
+ "{n} conversations": "{n} 个会话",
+ "Listed from the newest {n} rounds in the window; a conversation whose newest round is older than that is not shown.": "仅列出时间范围内最新 {n} 个轮次折叠出的会话;最新轮次早于此范围的会话不会显示。",
+ "No conversations in this window.": "此时间范围内没有会话。",
+ "Talks": "对话",
+ "Steps": "步骤",
+ "Streams": "Streams",
+ "Unresolved": "未解析",
+ "Last activity": "最后活动",
+ "(untitled)": "(无标题)",
+ "The link is incomplete: it names no agent runtime.": "链接不完整:没有指明 Agent 运行时。",
+ "OAP holds no round of this conversation for this runtime.": "OAP 中没有此运行时下该会话的任何轮次。",
+ "Your role lacks the ai-conversation:read permission.": "你的角色缺少 ai-conversation:read 权限。",
+ "OAP did not answer within its time budget. The conversation may be very large; try again.": "OAP 未在时间预算内应答。该会话可能非常大,请重试。",
+ "This Horizon cannot read the document OAP sent.": "此 Horizon 无法读取 OAP 发送的文档。",
+ "The conversation could not be read.": "无法读取该会话。",
+ "AI agent conversation": "AI Agent 会话",
+ "All conversations": "全部会话",
+ "Reading the conversation…": "正在读取会话…",
+ "{mb} MB received": "已接收 {mb} MB",
+ "Try again": "重试",
+ "unavailable": "不可用",
+ "Open the conversation in a new tab": "在新标签页中打开该会话",
+ "round": "轮次",
+ "streams": "streams",
+ "talks": "次对话",
+ "unresolved": "未解析",
+ "Session": "会话",
+ "runs": "次运行",
+ "Child streams": "Child streams",
+ "subagents, each its own context": "子 Agent,各自独立的上下文",
+ "Relations": "关联",
+ "activity windows": "个活动窗口",
+ "verified": "已校验",
+ "incomplete": "不完整",
+ "mismatch": "不匹配",
+ "problems": "个问题",
+ "rounds verified": "轮次已校验",
+ "No talk matches.": "没有匹配的对话。",
+ "Talk": "对话",
+ "main stream, child work folded": "main stream,子 Agent 的工作已折叠",
+ "child stream": "child stream",
+ "tools": "个工具",
+ "External · Input": "外部 · 输入",
+ "no opening line": "无开场句",
+ "delegated work": "委派的工作",
+ "show what the agent did": "展开 Agent 所做的工作",
+ "hide what the agent did": "收起 Agent 所做的工作",
+ "quiet": "静默",
+ "Main agent": "主 Agent",
+ "Child agent": "子 Agent",
+ "Response": "回复",
+ "Stream output": "Stream 输出",
+ "Client-made message · not from the provider": "客户端生成的消息 · 并非来自模型提供方",
+ "Context put in": "注入的上下文",
+ "Agent call": "Agent 调用",
+ "Runtime notification": "运行时通知",
+ "Launch acknowledged": "已确认启动",
+ "Model call": "模型调用",
+ "Reasoning": "推理",
+ "Tool": "工具",
+ "call": "次调用",
+ "turn": "轮",
+ "input": "输入",
+ "result": "结果",
+ "failed": "失败",
+ "to return": "返回耗时",
+ "Open": "打开",
+ "Independent stream context. Its messages are not merged into the parent.": "独立的 stream 上下文。它的消息不会合并进 parent stream。",
+ "Opened from": "打开自",
+ "No relation records what opened it.": "没有关联记录说明是什么打开了它。",
+ "← Back to the step that opened this": "← 回到打开它的那一步",
+ "No talks in this stream.": "此 stream 中没有对话。",
+ "Outside any talk": "不属于任何对话",
+ "Steps the fold could not place under a talk — a child’s output whose stream opened no talk, for instance. The document holds them so nothing is lost.": "折叠时无法归入任何对话的步骤——例如某个子 Agent 的输出,而它所在的 stream 并未开启对话。文档保留了它们,不会丢失任何内容。",
+ "child streams…": "child streams…",
+ "child streams": "child streams",
+ "Back to": "回到",
+ "Show this talk in the flow timeline": "在流程时间线中显示此对话",
+ "Show this talk’s answer in the flow timeline": "在流程时间线中显示此对话的回答",
+ "Locate in the flow timeline": "在流程时间线中定位",
+ "Flow timeline": "流程时间线",
+ "What is the flow timeline?": "什么是流程时间线?",
+ "← Parent timeline": "← 父时间线",
+ "Fade unrelated": "淡化无关项",
+ "Fade the steps that the selected one does not touch": "淡化与所选步骤无关的步骤",
+ "Zoom": "缩放",
+ "Center selected": "居中所选",
+ "Scroll the timeline to the selected step": "将时间线滚动到所选步骤",
+ "events": "个事件",
+ "nested": "个嵌套",
+ "External input": "外部输入",
+ "Responses": "回复",
+ "Model calls": "模型调用",
+ "Tools": "工具",
+ "Agent activity": "Agent 活动",
+ "Runtime notices": "运行时通知",
+ "Nested streams": "Nested streams",
+ "Agent response": "Agent 回复",
+ "Agent / nested stream": "Agent / nested stream",
+ "Context / annotation": "上下文 / 注解",
+ "Emitted by (ownership)": "产生自(归属)",
+ "Exact join": "精确关联",
+ "Strong inference": "强推断",
+ "agents created": "个 Agent 已创建",
+ "This call started {n} child agents at nearly the same moment. Click, then pick one in the inspector.": "此调用几乎在同一时刻启动了 {n} 个子 Agent。点击后在检视器中选择其一。",
+ "It reported finishing in this talk.": "它在此对话中报告了完成。",
+ "Click to see what opened it.": "点击查看是什么打开了它。",
+ "what it shows": "它显示什么",
+ "One talk, on a time axis. Each row is a lane: the messages, the model calls, the tools it ran, its agent activity, and the nested streams it started. The line above the lanes is the whole conversation, one cell per segment.": "一次对话,放在时间轴上。每一行是一条通道:消息、模型调用、运行的工具、Agent 活动,以及它启动的 nested streams。通道上方的那一行是整个会话,每个分段一格。",
+ "the heading": "标题栏",
+ "Which stream the lanes belong to, how many steps are in this talk, and how many nested streams it touches.": "这些通道属于哪个 stream、此对话有多少步骤、涉及多少 nested streams。",
+ "the axis": "时间轴",
+ "Busy stretches take the width. A long pause is cut to a short gap marked with how long it was, so a talk with an hour of waiting still reads.": "繁忙的时段占据宽度。长时间的停顿被压缩为一段标注了时长的短间隙,因此等待了一小时的对话仍然易读。",
+ "links": "连线",
+ "The lines between lanes, drawn for the step you select. A solid line is an exact join, a dashed one was inferred, and a faint curve is ownership: the model call that produced a step.": "通道之间的连线,只为你选中的步骤绘制。实线是精确关联,虚线是推断得出,淡淡的曲线表示归属:产生该步骤的模型调用。",
+ "fade unrelated": "淡化无关项",
+ "After you click a step, fade everything it does not touch.": "点击某个步骤后,淡化它未触及的一切。",
+ "Inspector": "检视器",
+ "Evidence": "证据",
+ "Select a step.": "选择一个步骤。",
+ "nested stream": "nested stream",
+ "Stream": "Stream",
+ "Opened by": "打开者",
+ "Reported here by": "在此报告者",
+ "this talk hears the ending. The call that started it is in an earlier talk, because a launch and its completion land in different turns.": "此对话听到了结束。启动它的调用位于更早的对话中,因为启动与完成落在不同的轮次。",
+ "Join quality": "关联质量",
+ "Opened at": "打开时间",
+ "Its answer": "它的回答",
+ "not in this stream. A child’s output belongs to the child, so the parent holds a boundary that refers to it, never a copy. Dive in to read it.": "不在此 stream 中。子 Agent 的输出属于 child stream,parent stream 只持有一个指向它的边界,而非副本。深入进去阅读。",
+ "Opens in turn": "依次打开",
+ "nothing — it opened no stream of its own": "无——它没有开启自己的 stream",
+ "and {n} more": "以及另外 {n} 个",
+ "this call started {n} streams": "此调用启动了 {n} 个 stream",
+ "A workflow call starts a run, and the run starts a pool of agents. They are all its children, not competing guesses at one child.": "一次工作流调用启动一次运行,运行再启动一组 Agent。它们都是它的子项,而不是对同一个子项的多种猜测。",
+ "Dive in →": "深入 →",
+ "Enter to dive in · Escape to clear": "Enter 深入 · Escape 清除",
+ "Node kind": "节点类型",
+ "Lane": "通道",
+ "Segment": "分段",
+ "Direct parent": "直接父项",
+ "Observed at": "观测时间",
+ "Request to result": "请求到结果",
+ "unavailable — the runtime does not report how long this ran": "不可用——运行时未报告此步骤运行了多久",
+ "What \"request to result\" is": "什么是“请求到结果”",
+ "The time between the record carrying the request and the record carrying the result. An exact identifier ties those two records together, so the interval belongs to this call. It is not how long the tool ran, and the runtime does not report that.": "携带请求的记录与携带结果的记录之间的时间。一个精确的标识符把这两条记录绑在一起,因此这段间隔属于此次调用。它不是工具的运行时长,运行时并不报告那个数据。",
+ "yes": "是",
+ "Content state": "内容状态",
+ "Content bytes": "内容字节数",
+ "Tokens": "Token",
+ "in {in} · out {out} · cache read {cacheRead} · cache write {cacheWrite}": "输入 {in} · 输出 {out} · 缓存读取 {cacheRead} · 缓存写入 {cacheWrite}",
+ "Content {state}": "内容{state}",
+ "The source did not supply readable content for this step. It is reported as it was found, not filled in.": "来源未为此步骤提供可读内容。这里如实呈现,不做填补。",
+ "1 child agent": "1 个子 Agent",
+ "{n} child agents": "{n} 个子 Agent",
+ "open the Relations tab →": "打开“关联”选项卡 →",
+ "← Back to parent stream": "← 回到 parent stream",
+ "No relation touches this step.": "没有关联触及此步骤。",
+ "{n} agents created by this call": "此调用创建了 {n} 个 Agent",
+ "A workflow call starts a run, and the run starts a pool of agents. Every one of them is a child of this call, not a competing guess at one child. Pick the one to read.": "一次工作流调用启动一次运行,运行再启动一组 Agent。其中每一个都是此调用的子项,而不是对同一个子项的多种猜测。选择要阅读的那个。",
+ "outgoing": "出",
+ "incoming": "入",
+ "joined on": "关联依据",
+ "named from its run journal": "名称来自其运行日志",
+ "the other end is outside this stream": "另一端在此 stream 之外",
+ "This step was derived by assembly. It cites no landed record.": "此步骤由组装推导而来,未引用任何落地记录。",
+ "landed positions": "落地位置",
+ "request": "请求",
+ "part": "部分",
+ "the text as the document carries it": "文档所携带的文本",
+ "clipped: {shown} of {total} bytes": "已截断:{total} 字节中的 {shown} 字节",
+ "Load the landed record": "加载落地记录",
+ "reading…": "读取中…",
+ "The record could not be read.": "无法读取该记录。",
+ "the landed record": "落地记录",
+ "dropped": "已丢弃",
+ "vocabulary": "词汇",
+ "the model’s own word": "模型自己的说法",
+ "asz term": "asz 术语",
+ "runtime word": "运行时用词",
+ "none — this is derived here, the runtime has no word for it": "无——这是在此处推导出的,运行时没有对应的词",
+ "where to look": "去哪里查看",
+ "nowhere in the source; it is produced by assembly": "源中不存在;由组装产生",
+ "read it carefully": "请仔细阅读",
+ "dialect": "方言",
+ "a field of the landed record": "落地记录的一个字段",
+ "asz field": "asz 字段",
+ "what it is": "它是什么",
+ "not applicable — this describes the landed record, not the source": "不适用——这描述的是落地记录,而非源",
+ "What does {key} mean?": "{key} 是什么意思?",
+ "Selected {what}": "已选择 {what}",
+ "Selected nested stream {name}, opened by {kind}. Press Enter to dive in, Escape to clear.": "已选择 nested stream {name},由 {kind} 打开。按 Enter 深入,按 Escape 清除。",
+ "Selection cleared.": "已清除选择。",
+ "Drawing the conversation…": "正在绘制会话…",
+ "Laying out {talks} talks and {steps} steps.": "正在排布 {talks} 次对话和 {steps} 个步骤。",
+ "Nothing arrives until OAP has folded the whole chain.": "在 OAP 折叠完整个链之前不会有任何数据到达。",
+ "OAP is assembling the conversation": "OAP 正在组装会话",
+ "Pick a time range, then click Run query.": "选择时间范围,然后点击“执行查询”。",
+ "about {s} s left": "约剩 {s} 秒",
+ "drawing": "绘制中",
+ "parsing": "解析中",
+ "{n} of {m} MB received": "已接收 {n} / {m} MB",
+ "{rate} MB/s": "{rate} MB/s",
+ "{s} s": "{s} 秒",
+ "{talks} talks · {steps} steps · {streams} streams, as OAP counted them.": "{talks} 次对话 · {steps} 个步骤 · {streams} 个 stream(OAP 统计)",
+ "Title contains": "标题包含",
+ "Conversation id": "会话 ID",
+ "OAP answered with an error; the rows below may be incomplete.": "OAP 返回了错误;下方的行可能不完整。",
+ "segments (activity windows)": "个分段",
+ "span (elapsed)": "跨度",
+ "Segment (activity window)": "分段",
+ "Segments (activity windows)": "分段",
+ "Span (elapsed)": "跨度",
+ "Nothing of this conversation could be folded, so there is nothing to read. The problems listed above say why.": "此会话没有任何内容可以折叠,因此没有可读的内容。上方列出的问题说明了原因。",
+ "This OAP does not serve conversation documents; the AI agent conversation module needs OAP 11.1.0 or later.": "此 OAP 不提供会话文档;AI 智能体会话模块需要 OAP 11.1.0 或更高版本。"
}
diff --git a/apps/ui/src/layer/ai-conversation/ConversationPageView.vue b/apps/ui/src/layer/ai-conversation/ConversationPageView.vue
new file mode 100644
index 00000000..a0b8c44d
--- /dev/null
+++ b/apps/ui/src/layer/ai-conversation/ConversationPageView.vue
@@ -0,0 +1,337 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!--
+ One AI agent conversation, full page, outside the AppShell. The document is
+ tens of megabytes and a reader keeps it open beside the list, so the list
+ opens it in its own tab; the URL carries the conversation, its service and
+ sender, and the reader's position (talk / step / stream), so it can be
+ shared and lands on the same step. The page reads the document through the
+ BFF and shows the wait in phases — OAP folding the chain (nothing arrives),
+ the bytes coming in against the size the BFF states, the parse, the draw —
+ then hands it to the shared renderer.
+-->
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router';
+import { useI18n } from 'vue-i18n';
+import { isSupportedDocument, type AszViewDocument, type PublicState } from '@skywalking-horizon-ui/conversation-view';
+import type { AiConversationDocumentSummary } from '@skywalking-horizon-ui/api-client';
+import ProgressBar from '@/components/primitives/ProgressBar.vue';
+import { currentLocale } from '@/i18n';
+import logoSw from '@/assets/icons/logo-sw.svg?raw';
+import { bff } from '@/api/client';
+import { AiConversationViewError } from '@/api/scopes/ai-conversation';
+import LocaleChip from '@/shell/LocaleChip.vue';
+import ThemeChip from '@/shell/ThemeChip.vue';
+import { AVAILABLE_THEMES, useThemeStore } from '@/state/theme';
+import ConversationViewHost from './ConversationViewHost.vue';
+
+const route = useRoute();
+const router = useRouter();
+const { t } = useI18n({ useScope: 'global' });
+const themeStore = useThemeStore();
+// The shipped logo is white-fill; a light theme gets the blue one, as the topbar does.
+const logoSwBlue = logoSw.replace(/fill="#fff"/g, 'fill="#1368B3"');
+const isLightAppearance = computed(() => AVAILABLE_THEMES.find((t) => t.id === themeStore.active)?.appearance === 'light');
+
+const str = (v: unknown): string => (typeof v === 'string' ? v : Array.isArray(v) && typeof v[0] === 'string' ? v[0] : '');
+const conversation = computed(() => str(route.params.conversation));
+const service = computed(() => str(route.query.service));
+const instance = computed(() => str(route.query.instance));
+const position = computed<PublicState>(() => ({
+ ...(str(route.query.talk) ? { talk: str(route.query.talk) } : {}),
+ ...(str(route.query.step) ? { step: str(route.query.step) } : {}),
+ ...(str(route.query.stream) ? { stream: str(route.query.stream) } : {}),
+}));
+
+/** The wait, in the order it happens: OAP folding the chain (nothing arrives),
+ * the bytes coming in, the parse, the draw. */
+type Phase = 'assembling' | 'receiving' | 'parsing' | 'drawing' | 'ready' | 'error';
+const phase = ref<Phase>('assembling');
+const bytes = ref(0);
+const total = ref<number | null>(null);
+const summary = ref<AiConversationDocumentSummary | null>(null);
+const elapsedS = ref(0);
+const rate = ref<number | null>(null); // decoded bytes per second, over the transfer so far
+const doc = ref<AszViewDocument | null>(null);
+const failure = ref<{ title: string; detail: string | null } | null>(null);
+let inFlight: AbortController | null = null;
+let ticker: ReturnType<typeof setInterval> | null = null;
+let startedAt = 0;
+let firstByteAt = 0;
+
+const mb = (n: number): string => new Intl.NumberFormat(currentLocale(), { maximumFractionDigits: 1, minimumFractionDigits: 1 }).format(n / 1_000_000);
+const count = (n: number): string => new Intl.NumberFormat(currentLocale()).format(n);
+const fraction = computed<number | null>(() => {
+ if (phase.value === 'parsing' || phase.value === 'drawing') return 1;
+ if (phase.value === 'receiving' && total.value) return Math.min(1, bytes.value / total.value);
+ return null;
+});
+const percent = computed(() => (fraction.value === null ? null : Math.round(fraction.value * 100)));
+const secondsLeft = computed<number | null>(() => {
+ if (phase.value !== 'receiving' || !total.value || !rate.value || Date.now() - firstByteAt < 1500) return null;
+ return Math.max(1, Math.round((total.value - bytes.value) / rate.value));
+});
+const countsLine = computed(() =>
+ summary.value
+ ? t('{talks} talks · {steps} steps · {streams} streams, as OAP counted them.', {
+ talks: count(summary.value.talks),
+ steps: count(summary.value.steps),
+ streams: count(summary.value.streams),
+ })
+ : '',
+);
+
+function stopTicker(): void {
+ if (ticker) clearInterval(ticker);
+ ticker = null;
+}
+/** Two frames, so what was just set is painted before a long synchronous
+ * step (the parse, the draw) blocks the page. */
+async function painted(): Promise<void> {
+ await nextTick();
+ await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
+}
+const title = computed(() => doc.value?.summary.title || t('(untitled)'));
+const listRoute = computed(() => ({ path: '/layer/AI_AGENT/conversations' }));
+
+function describe(err: unknown): { title: string; detail: string | null } {
+ if (err instanceof AiConversationViewError) {
+ const detail = err.detail;
+ switch (err.kind) {
+ case 'bad_request':
+ return { title: t('The link is incomplete: it names no agent runtime.'), detail };
+ case 'not_found':
+ return { title: t('OAP holds no round of this conversation for this runtime.'), detail };
+ case 'not_served':
+ return { title: t('This OAP does not serve conversation documents; the AI agent conversation module needs OAP 11.1.0 or later.'), detail: null };
+ case 'forbidden':
+ return { title: t('Your role lacks the ai-conversation:read permission.'), detail: null };
+ case 'timeout':
+ return { title: t('OAP did not answer within its time budget. The conversation may be very large; try again.'), detail };
+ case 'unreachable':
+ return { title: t('OAP is unreachable.'), detail };
+ case 'unsupported':
+ return { title: t('This Horizon cannot read the document OAP sent.'), detail: err.message };
+ case 'network':
+ return { title: t('Cannot reach the server.'), detail };
+ default:
+ return { title: t('The conversation could not be read.'), detail: detail ?? err.message };
+ }
+ }
+ return { title: t('The conversation could not be read.'), detail: err instanceof Error ? err.message : String(err) };
+}
+
+async function load(): Promise<void> {
+ inFlight?.abort();
+ stopTicker();
+ const ctl = new AbortController();
+ inFlight = ctl;
+ phase.value = 'assembling';
+ bytes.value = 0;
+ total.value = null;
+ summary.value = null;
+ rate.value = null;
+ elapsedS.value = 0;
+ doc.value = null;
+ failure.value = null;
+ startedAt = Date.now();
+ firstByteAt = 0;
+ ticker = setInterval(() => {
+ elapsedS.value = Math.round((Date.now() - startedAt) / 1000);
+ }, 1000);
+ if (!conversation.value || !service.value) {
+ failure.value = describe(new AiConversationViewError('bad_request', 400, 'service_required'));
+ phase.value = 'error';
+ return;
+ }
+ try {
+ const r = await bff.aiConversation.view(
+ conversation.value,
+ { service: service.value, ...(instance.value ? { instance: instance.value } : {}) },
+ {
+ signal: ctl.signal,
+ onProgress: (p) => {
+ if (ctl.signal.aborted) return;
+ if (!firstByteAt) firstByteAt = Date.now();
+ bytes.value = p.bytes;
+ total.value = p.total;
+ summary.value = p.summary;
+ const seconds = (Date.now() - firstByteAt) / 1000;
+ rate.value = seconds > 0.2 ? p.bytes / seconds : null;
+ phase.value = p.phase;
+ },
+ },
+ );
+ if (ctl.signal.aborted) return;
+ if (!isSupportedDocument(r.document)) {
+ throw new AiConversationViewError('unsupported', 200, t('This Horizon cannot read the document OAP sent.'));
+ }
+ stopTicker();
+ phase.value = 'drawing';
+ await painted();
+ if (ctl.signal.aborted) return;
+ doc.value = r.document;
+ phase.value = 'ready';
+ } catch (err) {
+ if (ctl.signal.aborted) return;
+ stopTicker();
+ failure.value = describe(err);
+ phase.value = 'error';
+ }
+}
+
+/** The renderer's position goes into the URL in place, so the address bar is
+ * always the link to what is on screen. Compared against the query last
+ * written, not the route: a navigation is asynchronous, and two positions in
+ * quick succession would otherwise drop the second. */
+let written: PublicState | null = null;
+function onPosition(s: PublicState): void {
+ const last = written ?? position.value;
+ if ((last.talk ?? '') === (s.talk ?? '') && (last.step ?? '') === (s.step ?? '') && (last.stream ?? '') === (s.stream ?? '')) return;
+ written = { ...s };
+ const q: LocationQueryRaw = { ...route.query };
+ for (const k of ['talk', 'step', 'stream'] as const) {
+ if (s[k]) q[k] = s[k];
+ else delete q[k];
+ }
+ void router.replace({ query: q });
+}
+
+watch(title, (v) => {
+ document.title = phase.value === 'ready' ? `${v} · Horizon` : 'Horizon';
+});
+onMounted(() => {
+ // Outside the AppShell nothing else loads the org's default theme.
+ void themeStore.loadOrgDefault();
+ void load();
+});
+watch([conversation, service, instance], () => void load());
+onBeforeUnmount(() => {
+ inFlight?.abort();
+ stopTicker();
+ document.title = 'Horizon';
+});
+</script>
+
+<template>
+ <div class="cvp">
+ <header class="cvp-head">
+ <router-link class="cvp-brand" to="/" :title="t('Back to Horizon')">
+ <!-- eslint-disable-next-line vue/no-v-html -- build-time `?raw` import of a bundled SVG constant; no runtime input reaches it, and scripts/check-security.mjs scans the ?raw set for active content -->
+ <span class="cvp-logo" v-html="isLightAppearance ? logoSwBlue : logoSw" />
+ </router-link>
+ <div class="cvp-title">
+ <span class="cvp-kicker">{{ t('AI agent conversation') }}</span>
+ <h1 :class="{ untitled: phase === 'ready' && !doc?.summary.title }">{{ phase === 'ready' ? title : conversation }}</h1>
+ <span class="cvp-meta">
+ <span class="cvp-mono">{{ service }}</span>
+ <template v-if="instance"> · <span class="cvp-mono">{{ instance }}</span></template>
+ <template v-if="phase === 'ready'"> · <span class="cvp-mono">{{ conversation }}</span></template>
+ </span>
+ </div>
+ <div class="cvp-actions">
+ <router-link class="sw-btn cvp-link" :to="listRoute">{{ t('All conversations') }}</router-link>
+ <ThemeChip />
+ <LocaleChip />
+ </div>
+ </header>
+
+ <section v-if="phase !== 'ready' && phase !== 'error'" class="cvp-state" aria-live="polite">
+ <strong>{{ phase === 'drawing' ? t('Drawing the conversation…') : t('Reading the conversation…') }}</strong>
+ <ProgressBar :value="fraction" :label="t('Reading the conversation…')" />
+ <div class="cvp-line">
+ <template v-if="phase === 'assembling'">
+ <span>{{ t('OAP is assembling the conversation') }}</span>
+ <span class="cvp-num">{{ t('{s} s', { s: elapsedS }) }}</span>
+ </template>
+ <template v-else-if="phase === 'receiving'">
+ <span v-if="total">
+ <span class="cvp-num">{{ t('{n} of {m} MB received', { n: mb(bytes), m: mb(total) }) }}</span>
+ <template v-if="percent !== null"> · <b>{{ percent }}%</b></template>
+ </span>
+ <span v-else class="cvp-num">{{ t('{mb} MB received', { mb: mb(bytes) }) }}</span>
+ <span class="cvp-num cvp-faint">
+ <template v-if="rate">{{ t('{rate} MB/s', { rate: mb(rate) }) }}</template>
+ <template v-if="secondsLeft !== null"> · {{ t('about {s} s left', { s: secondsLeft }) }}</template>
+ </span>
+ </template>
+ <template v-else>
+ <span><span class="cvp-num">{{ t('{mb} MB received', { mb: mb(bytes) }) }}</span> · <b>100%</b></span>
+ <span class="cvp-faint">{{ phase === 'parsing' ? t('parsing') : t('drawing') }}</span>
+ </template>
+ </div>
+ <span class="cvp-sub">
+ <template v-if="phase === 'assembling'">{{ t('Nothing arrives until OAP has folded the whole chain.') }}</template>
+ <template v-else-if="phase === 'drawing' && summary">{{ t('Laying out {talks} talks and {steps} steps.', { talks: count(summary.talks), steps: count(summary.steps) }) }}</template>
+ <template v-else>{{ countsLine }}</template>
+ </span>
+ </section>
+
+ <section v-else-if="phase === 'error'" class="cvp-state err" role="alert">
+ <strong>{{ failure?.title }}</strong>
+ <code v-if="failure?.detail" class="cvp-detail">{{ failure.detail }}</code>
+ <div class="cvp-state-actions">
+ <button type="button" class="sw-btn" @click="load">{{ t('Try again') }}</button>
+ <router-link class="sw-btn cvp-link" :to="listRoute">{{ t('All conversations') }}</router-link>
+ </div>
+ </section>
+
+ <ConversationViewHost v-else-if="doc" :document="doc" :state="position" @update:state="onPosition" />
+ </div>
+</template>
+
+<style scoped>
+/* Exactly the viewport: the renderer fills what the header leaves, and only its
+ panels scroll. `auto` rather than `hidden` so a viewport shorter than the
+ workbench's floor can still reach the bottom. */
+.cvp { display: flex; flex-direction: column; height: 100dvh; overflow: auto; background: var(--sw-bg-0); color: var(--sw-fg-0); }
+.cvp-head { flex: none; }
+@media (max-width: 860px) {
+ .cvp { height: auto; min-height: 100dvh; overflow: visible; }
+}
+.cvp-head {
+ display: flex; align-items: center; gap: 14px; min-height: 48px; padding: 6px 12px;
+ border-bottom: 1px solid var(--sw-line); background: var(--sw-bg-1);
+}
+.cvp-brand { display: inline-flex; align-items: center; padding: 4px; border-radius: 6px; }
+.cvp-brand:hover { background: var(--sw-bg-3); }
+.cvp-logo { display: inline-flex; align-items: center; }
+.cvp-logo :deep(svg) { width: auto; height: 20px; display: block; }
+.cvp-title { display: flex; flex-direction: column; min-width: 0; flex: 1; line-height: 1.25; }
+.cvp-kicker { color: var(--sw-fg-2); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; }
+.cvp-title h1 { margin: 0; font-size: 14px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.cvp-title h1.untitled { color: var(--sw-fg-2); font-weight: 500; }
+.cvp-meta { color: var(--sw-fg-2); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.cvp-mono { font-family: var(--sw-mono); }
+.cvp-actions { display: flex; align-items: center; gap: 8px; }
+.cvp-link { text-decoration: none; }
+.cvp-state {
+ display: flex; flex-direction: column; align-items: stretch; gap: 10px; width: min(620px, calc(100% - 32px)); margin: 18vh auto 0; padding: 20px 22px;
+ border: 1px solid var(--sw-line); border-radius: 8px; background: var(--sw-bg-1); color: var(--sw-fg-1);
+}
+.cvp-state strong { color: var(--sw-fg-0); font-size: 14px; }
+.cvp-state.err { align-items: center; text-align: center; border-color: color-mix(in srgb, var(--sw-err) 45%, transparent); }
+.cvp-line { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; font-size: 12px; }
+.cvp-line b { color: var(--sw-fg-0); font-weight: 650; }
+.cvp-num { font-variant-numeric: tabular-nums; color: var(--sw-fg-0); }
+.cvp-faint { color: var(--sw-fg-2); }
+.cvp-sub { min-height: 1.4em; color: var(--sw-fg-2); font-size: 11px; line-height: 1.45; }
+.cvp-detail { max-width: 100%; padding: 6px 8px; border-radius: 4px; background: var(--sw-bg-2); color: var(--sw-fg-1); font-size: 11px; white-space: pre-wrap; overflow-wrap: anywhere; text-align: left; }
+.cvp-state-actions { display: flex; gap: 8px; margin-top: 6px; }
+</style>
diff --git a/apps/ui/src/layer/ai-conversation/ConversationViewHost.vue b/apps/ui/src/layer/ai-conversation/ConversationViewHost.vue
new file mode 100644
index 00000000..21891f21
--- /dev/null
+++ b/apps/ui/src/layer/ai-conversation/ConversationViewHost.vue
@@ -0,0 +1,88 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!--
+ Hosts the shared conversation renderer (`@skywalking-horizon-ui/conversation-view`)
+ inside Vue: mounts it once per document, hands it the UI's texts and locale,
+ and round-trips the reader's position (talk / step / stream) so the page can
+ keep it in the URL. The renderer owns everything inside the host element.
+-->
+<script setup lang="ts">
+import '@skywalking-horizon-ui/conversation-view/style.css';
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import {
+ makeFormatter,
+ mountConversationView,
+ type AszViewDocument,
+ type ConversationView,
+ type PublicState,
+} from '@skywalking-horizon-ui/conversation-view';
+import { currentLocale } from '@/i18n';
+import { conversationStrings } from './conversationStrings';
+
+const props = defineProps<{
+ document: AszViewDocument;
+ state?: PublicState;
+}>();
+const emit = defineEmits<{ (e: 'update:state', state: PublicState): void }>();
+
+const { t, locale } = useI18n({ useScope: 'global' });
+const el = ref<HTMLElement | null>(null);
+let view: ConversationView | null = null;
+
+function same(a: PublicState | undefined, b: PublicState): boolean {
+ return (a?.talk ?? '') === (b.talk ?? '') && (a?.step ?? '') === (b.step ?? '') && (a?.stream ?? '') === (b.stream ?? '');
+}
+
+function mountView(): void {
+ if (!el.value) return;
+ view?.destroy();
+ view = mountConversationView(el.value, {
+ document: props.document,
+ strings: conversationStrings(t),
+ formatter: makeFormatter(currentLocale(), t('unavailable')),
+ ...(props.state ? { state: props.state } : {}),
+ onStateChange: (s) => emit('update:state', s),
+ });
+}
+
+onMounted(mountView);
+watch(() => props.document, mountView);
+// The renderer takes its texts and number formats once; a locale change
+// mounts it again on the same document at the same position.
+watch(locale, mountView);
+watch(
+ () => props.state,
+ (s) => {
+ if (view && s && !same(s, view.getState())) view.setState(s);
+ },
+ { deep: true },
+);
+onBeforeUnmount(() => {
+ view?.destroy();
+ view = null;
+});
+</script>
+
+<template>
+ <div ref="el" class="conversation-host" />
+</template>
+
+<style scoped>
+.conversation-host { flex: 1; min-height: 0; }
+</style>
diff --git a/apps/ui/src/layer/ai-conversation/LayerConversationsView.vue b/apps/ui/src/layer/ai-conversation/LayerConversationsView.vue
new file mode 100644
index 00000000..e9792704
--- /dev/null
+++ b/apps/ui/src/layer/ai-conversation/LayerConversationsView.vue
@@ -0,0 +1,351 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!--
+ The Conversations tab of an AI_AGENT layer: the conversations of one agent
+ runtime (the shell's picked service) in a window, one row each, newest
+ activity first. Like the Traces and Logs tabs it owns its time range and
+ fires on Run query; the sender, the title text and the conversation id are
+ query conditions OAP applies. A row opens the conversation in a new tab.
+-->
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { useI18n } from 'vue-i18n';
+import type { AiConversationRow, LayerDef } from '@/api/client';
+import { useLayers } from '@/shell/useLayers';
+import { useSetupStore } from '@/state/setup';
+import { useLayerLanding } from '@/layer/useLayerLanding';
+import { useLayerInstances } from '@/layer/useLayerInstances';
+import { useLayerTabService } from '@/layer/useLayerServiceName';
+import { useSelectedService } from '@/layer/useSelectedService';
+import { useLayerConversations } from '@/layer/ai-conversation/useLayerConversations';
+import { formatDuration, timestampLabel } from '@/utils/formatters';
+
+const route = useRoute();
+const router = useRouter();
+const { t } = useI18n({ useScope: 'global' });
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+
+const { layers } = useLayers();
+const layer = computed<LayerDef | null>(() => layers.value.find((l) => l.key === layerKey.value) ?? null);
+const senderLabel = computed(() => layer.value?.slots.instances ?? t('Sender'));
+
+// The runtime is the shell's picked service, resolved to the NAME the OAP
+// list keys on — sample first, then the full roster, as the other tabs do.
+const { selectedId, setSelected: setSelectedService } = useSelectedService();
+const store = useSetupStore();
+const safeLayer = computed<LayerDef>(() => layer.value ?? {
+ key: layerKey.value, name: layerKey.value, color: 'var(--sw-fg-2)',
+ serviceCount: -1, active: false, level: null, slots: {}, caps: {},
+});
+const safeCfg = computed(() => {
+ if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns: [] };
+ return store.ensure(layer.value.key, {
+ slots: layer.value.slots, caps: layer.value.caps, metrics: layer.value.metrics,
+ }).landing;
+});
+const landing = useLayerLanding(safeLayer, safeCfg);
+const {
+ name: serviceName,
+ ref: serviceRef,
+ status: serviceStatus,
+ ready: serviceReady,
+} = useLayerTabService(layerKey, landing, {
+ embedded: computed(() => false),
+ focusService: computed(() => null),
+ focusServiceId: computed(() => null),
+});
+const serviceKey = computed<string | null>(() => serviceRef.value?.id ?? null);
+const landingRows = computed(() => landing.data.value?.sampledRows ?? landing.rows.value ?? []);
+watch(
+ landingRows,
+ (rows) => {
+ if (selectedId.value) return;
+ const first = rows[0];
+ if (first) setSelectedService(first.serviceId);
+ },
+ { immediate: true },
+);
+
+// Own time range. Conversations run for days, so the presets start at a day
+// and the default is a week — the BFF caps a window at 90 days.
+const WINDOW_PRESETS = computed(() => [
+ { label: t('Last 24 hours'), minutes: 24 * 60 },
+ { label: t('Last 7 days'), minutes: 7 * 24 * 60 },
+ { label: t('Last 30 days'), minutes: 30 * 24 * 60 },
+ { label: t('Last 90 days'), minutes: 90 * 24 * 60 },
+]);
+const windowMinutes = ref<number>(7 * 24 * 60);
+
+// The senders of this runtime are its instances active in the tab's window —
+// a Sessionizer pushes hours apart, so the shell's recent default misses them.
+const { instances: senders } = useLayerInstances(layerKey, serviceRef, windowMinutes);
+const senderFilter = ref('');
+
+// Manual fire: the picks stage into `applied`, and the query reads that
+// snapshot, so it runs on Run query and never on a half-changed toolbar.
+const titleFilter = ref('');
+const conversationFilter = ref('');
+interface Applied {
+ service: string | null;
+ instanceName: string | null;
+ conversation: string | null;
+ title: string | null;
+ windowMinutes: number;
+}
+const applied = ref<Applied>({ service: null, instanceName: null, conversation: null, title: null, windowMinutes: windowMinutes.value });
+const hasQueried = ref(false);
+const queryEnabled = computed(() => hasQueried.value && !!applied.value.service);
+const { rows, limit, reachable, queryError, error: transportError, isFetching, refetch } = useLayerConversations(layerKey, {
+ service: computed(() => applied.value.service),
+ instanceName: computed(() => applied.value.instanceName),
+ conversation: computed(() => applied.value.conversation),
+ title: computed(() => applied.value.title),
+ windowMinutes: computed(() => applied.value.windowMinutes),
+ enabled: queryEnabled,
+});
+function runQuery(): void {
+ if (!serviceReady.value || !serviceName.value) return;
+ applied.value = {
+ service: serviceName.value,
+ instanceName: senderFilter.value || null,
+ conversation: conversationFilter.value.trim() || null,
+ title: titleFilter.value.trim() || null,
+ windowMinutes: windowMinutes.value,
+ };
+ hasQueried.value = true;
+ void refetch();
+}
+
+// A runtime switch is a context change: back to the Run-query prompt, every
+// condition cleared, so the previous runtime's rows never sit under the new name.
+watch(serviceKey, () => {
+ hasQueried.value = false;
+ senderFilter.value = '';
+ titleFilter.value = '';
+ conversationFilter.value = '';
+});
+
+const transportErrorText = computed(() =>
+ transportError.value instanceof Error ? transportError.value.message : transportError.value ? String(transportError.value) : '',
+);
+
+function spanOf(r: AiConversationRow): string {
+ return formatDuration(Math.max(0, r.to - r.from) / 1000, true);
+}
+
+// A conversation opens in its own tab: the document is tens of megabytes and
+// the reader keeps the list. `noopener` because the new page needs nothing
+// from this one; the URL carries the conversation, its runtime and sender.
+function openConversation(r: AiConversationRow): void {
+ const service = applied.value.service;
+ if (!service) return;
+ const href = router.resolve({
+ name: 'ai-conversation',
+ params: { conversation: r.conversation },
+ query: { service, instance: r.serviceInstanceName },
+ }).href;
+ window.open(href, '_blank', 'noopener');
+}
+</script>
+
+<template>
+ <div class="cv-tab">
+ <section class="cv-toolbar sw-card">
+ <div class="cv-toolbar-head">
+ <span class="kicker">{{ t('AI agent conversations') }}</span>
+ <span v-if="isFetching" class="hint">{{ t('refreshing…') }}</span>
+ <button
+ class="sw-btn primary cv-run-btn"
+ type="button"
+ :disabled="!serviceReady"
+ @click="runQuery"
+ >{{ t('Run query') }}</button>
+ </div>
+ <div class="cv-controls">
+ <label class="cv-field">
+ <span>{{ t('Time range') }}</span>
+ <select v-model.number="windowMinutes">
+ <option v-for="p in WINDOW_PRESETS" :key="p.minutes" :value="p.minutes">{{ p.label }}</option>
+ </select>
+ </label>
+ <label class="cv-field">
+ <span>{{ senderLabel }}</span>
+ <select v-model="senderFilter" :disabled="!serviceReady">
+ <option value="">{{ t('All') }}</option>
+ <option v-for="s in senders" :key="s.id" :value="s.name">{{ s.name }}</option>
+ </select>
+ </label>
+ <label class="cv-field cv-grow">
+ <span>{{ t('Title contains') }}</span>
+ <input v-model="titleFilter" type="search" :disabled="!serviceReady" @keydown.enter.prevent="runQuery" />
+ </label>
+ <label class="cv-field cv-grow">
+ <span>{{ t('Conversation id') }}</span>
+ <input v-model="conversationFilter" type="search" class="cv-mono" :disabled="!serviceReady" @keydown.enter.prevent="runQuery" />
+ </label>
+ </div>
+ </section>
+
+ <section class="cv-body sw-card">
+ <div v-if="!serviceReady" class="cv-empty">
+ <template v-if="serviceStatus === 'resolving'">{{ t('Resolving service…') }}</template>
+ <template v-else-if="serviceStatus === 'unknown'">
+ {{ t('The selected service is not in this layer — pick another one to query.') }}
+ </template>
+ <template v-else>{{ t('Pick a service to run this query.') }}</template>
+ </div>
+ <div v-else-if="!hasQueried" class="cv-empty">
+ {{ t('Pick a time range, then click Run query.') }}
+ </div>
+ <div v-else-if="isFetching" class="cv-empty">{{ t('Reading data…') }}</div>
+ <div v-else-if="transportError || !reachable" class="banner err">
+ {{ t('The conversation list could not be read.') }}
+ <code v-if="transportError">{{ transportErrorText }}</code>
+ <code v-else-if="queryError">{{ queryError }}</code>
+ </div>
+ <template v-else>
+ <div v-if="queryError" class="banner err">
+ {{ t('OAP answered with an error; the rows below may be incomplete.') }}
+ <code>{{ queryError }}</code>
+ </div>
+ <div class="cv-meta">
+ <span class="cv-count">{{ t('{n} conversations', { n: rows.length }) }}</span>
+ <span v-if="limit" class="cv-rule">
+ {{ t('Listed from the newest {n} rounds in the window; a conversation whose newest round is older than that is not shown.', { n: limit }) }}
+ </span>
+ </div>
+ <div v-if="rows.length === 0" class="cv-empty">{{ t('No conversations in this window.') }}</div>
+ <div v-else class="cv-table-wrap">
+ <table class="cv-table">
+ <thead>
+ <tr>
+ <th class="left">{{ t('Title') }}</th>
+ <th class="left">{{ senderLabel }}</th>
+ <th>{{ t('Talks') }}</th>
+ <th>{{ t('Steps') }}</th>
+ <th>{{ t('Streams') }}</th>
+ <th>{{ t('Segments (activity windows)') }}</th>
+ <th>{{ t('Unresolved') }}</th>
+ <th>{{ t('Span (elapsed)') }}</th>
+ <th class="left">{{ t('Last activity') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr
+ v-for="r in rows"
+ :key="`${r.serviceInstanceId}/${r.conversation}`"
+ class="cv-row"
+ tabindex="0"
+ :title="t('Open the conversation in a new tab')"
+ @click="openConversation(r)"
+ @keydown.enter.prevent="openConversation(r)"
+ >
+ <td class="left">
+ <span class="cv-title" :class="{ untitled: !r.title }">{{ r.title || t('(untitled)') }}</span>
+ <span class="cv-id">{{ r.conversation }}</span>
+ </td>
+ <td class="left cv-mono">{{ r.serviceInstanceName }}</td>
+ <td>{{ r.talks }}</td>
+ <td>{{ r.steps }}</td>
+ <td>{{ r.streams }}</td>
+ <td>{{ r.segments }}</td>
+ <td :class="{ 'cv-warn': r.unresolved > 0 }">{{ r.unresolved }}</td>
+ <td>{{ spanOf(r) }}</td>
+ <td class="left">{{ timestampLabel(r.to) }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </template>
+ </section>
+ </div>
+</template>
+
+<style scoped>
+.cv-tab { display: flex; flex-direction: column; gap: 8px; }
+.cv-toolbar { padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; }
+.cv-toolbar-head { display: flex; align-items: center; gap: 10px; width: 100%; }
+.cv-toolbar-head .hint { color: var(--sw-fg-2); font-size: var(--sw-fs-xs); }
+/* `.sw-btn.primary` is locally scoped per page (each view declares its own);
+ the rule is copied to keep the visual stable without a shared global. */
+.cv-run-btn { margin-left: auto; }
+.sw-btn.primary {
+ background: var(--sw-accent);
+ color: var(--sw-bg-0);
+ border: none;
+ height: 26px;
+ padding: 0 14px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+}
+.sw-btn.primary:hover { background: var(--sw-accent-2); }
+.sw-btn.primary:disabled { opacity: 0.5; cursor: default; }
+.cv-controls { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: flex-end; }
+.cv-field { display: flex; flex-direction: column; gap: 3px; min-width: 160px; }
+.cv-field > span { color: var(--sw-fg-2); font-size: var(--sw-fs-xs); text-transform: uppercase; letter-spacing: 0.06em; }
+.cv-field select,
+.cv-field input {
+ min-height: 26px;
+ padding: 2px 8px;
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ background: var(--sw-bg-2);
+ color: var(--sw-fg-0);
+ font-size: var(--sw-fs-sm);
+}
+.cv-grow { flex: 1; min-width: 240px; }
+.cv-body { padding: 0; overflow: hidden; }
+.cv-empty { padding: 28px 12px; color: var(--sw-fg-2); font-size: var(--sw-fs-sm); text-align: center; }
+.banner.err { margin: 10px; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--sw-err) 45%, transparent); border-radius: 4px; color: var(--sw-fg-1); font-size: var(--sw-fs-sm); }
+.banner.err code { display: block; margin-top: 4px; color: var(--sw-fg-2); font-size: var(--sw-fs-xs); }
+.cv-meta { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; padding: 8px 10px; border-bottom: 1px solid var(--sw-line); font-size: var(--sw-fs-xs); color: var(--sw-fg-2); }
+.cv-count { color: var(--sw-fg-0); font-weight: 600; }
+.cv-table-wrap { overflow-x: auto; }
+.cv-table { width: 100%; border-collapse: collapse; font-size: var(--sw-fs-sm); }
+.cv-table th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ padding: 6px 10px;
+ border-bottom: 1px solid var(--sw-line-2);
+ background: var(--sw-bg-2);
+ color: var(--sw-fg-2);
+ font-size: var(--sw-fs-xs);
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-align: right;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+.cv-table td { padding: 6px 10px; border-bottom: 1px solid var(--sw-line); color: var(--sw-fg-1); text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
+.cv-table th.left,
+.cv-table td.left { text-align: left; }
+.cv-table td.left:first-child { white-space: normal; min-width: 260px; }
+.cv-table tbody tr.cv-row { cursor: pointer; }
+.cv-table tbody tr:hover td,
+.cv-table tbody tr:focus-visible td { background: var(--sw-bg-3); }
+.cv-table tbody tr:focus-visible { outline: 2px solid var(--sw-accent); outline-offset: -2px; }
+.cv-title { display: block; color: var(--sw-fg-0); }
+.cv-title.untitled { color: var(--sw-fg-3); font-style: italic; }
+.cv-id { display: block; color: var(--sw-fg-3); font-family: var(--sw-mono); font-size: var(--sw-fs-xs); }
+.cv-mono { font-family: var(--sw-mono); font-size: var(--sw-fs-xs); }
+.cv-warn { color: var(--sw-warn); }
+</style>
diff --git a/apps/ui/src/layer/ai-conversation/conversationStrings.test.ts b/apps/ui/src/layer/ai-conversation/conversationStrings.test.ts
new file mode 100644
index 00000000..78085941
--- /dev/null
+++ b/apps/ui/src/layer/ai-conversation/conversationStrings.test.ts
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { ENGLISH } from '@skywalking-horizon-ui/conversation-view';
+import { i18n, SUPPORTED_LOCALES } from '@/i18n';
+import { conversationStrings } from './conversationStrings';
+
+describe('conversationStrings', () => {
+ it('covers every text the renderer has, and in English says what the renderer says', () => {
+ const t = i18n.global.t as unknown as (key: string, named?: Record<string, unknown>) => string;
+ const ours = conversationStrings(t);
+ expect(Object.keys(ours).sort()).toEqual(Object.keys(ENGLISH).sort());
+ for (const [k, v] of Object.entries(ENGLISH)) expect(ours[k as keyof typeof ours], k).toBe(v);
+ });
+
+ it('keeps the placeholders for the renderer to fill, in every locale', async () => {
+ const placeholders = /\{[a-zA-Z]+\}/g;
+ for (const locale of SUPPORTED_LOCALES) {
+ if (locale !== 'en') await i18n.global.setLocaleMessage(locale, (await import(`@/i18n/locales/${locale}.json`)).default);
+ i18n.global.locale.value = locale;
+ const t = i18n.global.t as unknown as (key: string, named?: Record<string, unknown>) => string;
+ const ours = conversationStrings(t);
+ for (const [k, en] of Object.entries(ENGLISH)) {
+ const want = [...en.matchAll(placeholders)].map((m) => m[0]).sort();
+ if (want.length === 0) continue;
+ const got = [...ours[k as keyof typeof ours].matchAll(placeholders)].map((m) => m[0]).sort();
+ expect(got, `${locale} ${k}`).toEqual(want);
+ }
+ }
+ i18n.global.locale.value = 'en';
+ });
+});
diff --git a/apps/ui/src/layer/ai-conversation/conversationStrings.ts b/apps/ui/src/layer/ai-conversation/conversationStrings.ts
new file mode 100644
index 00000000..d3ee83b3
--- /dev/null
+++ b/apps/ui/src/layer/ai-conversation/conversationStrings.ts
@@ -0,0 +1,230 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { ViewStrings } from '@skywalking-horizon-ui/conversation-view';
+
+type Translate = (key: string, named?: Record<string, unknown>) => string;
+
+/** The renderer's texts in the UI's locale. Every value is a literal `t()`
+ * call so the catalog check sees each key. A text with a placeholder is
+ * translated with the placeholder as its own value, so `{n}` survives for
+ * the renderer to fill in. */
+export function conversationStrings(t: Translate): ViewStrings {
+ return {
+ round: t('round'),
+ // Not the tracing terms the catalogs keep verbatim: here a segment is an
+ // activity window and the span is elapsed time.
+ segments: t('segments (activity windows)'),
+ segmentList: t('Segments (activity windows)'),
+ streams: t('streams'),
+ talks: t('talks'),
+ talkList: t('Talks'),
+ span: t('span (elapsed)'),
+ unresolved: t('unresolved'),
+ overview: t('Overview'),
+ session: t('Session'),
+ steps: t('Steps'),
+ runs: t('runs'),
+ childStreams: t('Child streams'),
+ childStreamsNote: t('subagents, each its own context'),
+ relations: t('Relations'),
+ activityWindows: t('activity windows'),
+ idle: t('idle'),
+ integrityVerified: t('verified'),
+ integrityIncomplete: t('incomplete'),
+ integrityMismatch: t('mismatch'),
+ problems: t('problems'),
+ roundsVerified: t('rounds verified'),
+ filesListed: t('files'),
+ filterTalks: t('filter'),
+ noTalkMatches: t('No talk matches.'),
+ untitled: t('(untitled)'),
+ talk: t('Talk'),
+ mainStreamCaption: t('main stream, child work folded'),
+ childStreamCaption: t('child stream'),
+ tools: t('tools'),
+ externalInput: t('External · Input'),
+ noOpeningLine: t('no opening line'),
+ delegatedWork: t('delegated work'),
+ showWork: t('show what the agent did'),
+ hideWork: t('hide what the agent did'),
+ quiet: t('quiet'),
+ mainAgent: t('Main agent'),
+ childAgent: t('Child agent'),
+ agent: t('Agent'),
+ response: t('Response'),
+ streamOutput: t('Stream output'),
+ clientMadeMessage: t('Client-made message · not from the provider'),
+ contextPutIn: t('Context put in'),
+ agentCallToChild: t('Agent call'),
+ notificationFromChild: t('Runtime notification'),
+ launchAcknowledged: t('Launch acknowledged'),
+ modelCall: t('Model call'),
+ reasoning: t('Reasoning'),
+ toolWord: t('Tool'),
+ callWord: t('call'),
+ turn: t('turn'),
+ input: t('input'),
+ result: t('result'),
+ failed: t('failed'),
+ toReturn: t('to return'),
+ openStream: t('Open'),
+ independentStream: t('Independent stream context. Its messages are not merged into the parent.'),
+ openedFrom: t('Opened from'),
+ noOpenerRecorded: t('No relation records what opened it.'),
+ backToOpener: t('← Back to the step that opened this'),
+ noTalksInStream: t('No talks in this stream.'),
+ nothingFolded: t('Nothing of this conversation could be folded, so there is nothing to read. The problems listed above say why.'),
+ outsideAnyTalk: t('Outside any talk'),
+ outsideAnyTalkNote: t('Steps the fold could not place under a talk — a child’s output whose stream opened no talk, for instance. The document holds them so nothing is lost.'),
+ childStreamsPick: t('child streams…'),
+ childStreamSingular: t('child stream'),
+ childStreamPlural: t('child streams'),
+ backToStream: t('Back to'),
+ showThisTalk: t('Show this talk in the flow timeline'),
+ showThisAnswer: t('Show this talk’s answer in the flow timeline'),
+ locateInTimeline: t('Locate in the flow timeline'),
+ flowTimeline: t('Flow timeline'),
+ timelineHelp: t('What is the flow timeline?'),
+ parentTimeline: t('← Parent timeline'),
+ fadeUnrelated: t('Fade unrelated'),
+ fadeUnrelatedTitle: t('Fade the steps that the selected one does not touch'),
+ zoom: t('Zoom'),
+ centerSelected: t('Center selected'),
+ centerSelectedTitle: t('Scroll the timeline to the selected step'),
+ events: t('events'),
+ nested: t('nested'),
+ laneInput: t('External input'),
+ laneResponses: t('Responses'),
+ laneContext: t('Context put in'),
+ laneModel: t('Model calls'),
+ laneTools: t('Tools'),
+ laneAgents: t('Agent activity'),
+ laneNotices: t('Runtime notices'),
+ laneNested: t('Nested streams'),
+ legendInput: t('External input'),
+ legendResponse: t('Agent response'),
+ legendModel: t('Model call'),
+ legendTool: t('Tool'),
+ legendAgent: t('Agent / nested stream'),
+ legendContext: t('Context / annotation'),
+ legendOwns: t('Emitted by (ownership)'),
+ legendExact: t('Exact join'),
+ legendInferred: t('Strong inference'),
+ agentsCreated: t('agents created'),
+ poolTitle: t('This call started {n} child agents at nearly the same moment. Click, then pick one in the inspector.', { n: '{n}' }),
+ reportedHere: t('It reported finishing in this talk.'),
+ clickToSeeOpener: t('Click to see what opened it.'),
+ helpWhat: t('what it shows'),
+ helpWhatText: t('One talk, on a time axis. Each row is a lane: the messages, the model calls, the tools it ran, its agent activity, and the nested streams it started. The line above the lanes is the whole conversation, one cell per segment.'),
+ helpHeading: t('the heading'),
+ helpHeadingText: t('Which stream the lanes belong to, how many steps are in this talk, and how many nested streams it touches.'),
+ helpAxis: t('the axis'),
+ helpAxisText: t('Busy stretches take the width. A long pause is cut to a short gap marked with how long it was, so a talk with an hour of waiting still reads.'),
+ helpLinks: t('links'),
+ helpLinksText: t('The lines between lanes, drawn for the step you select. A solid line is an exact join, a dashed one was inferred, and a faint curve is ownership: the model call that produced a step.'),
+ helpFade: t('fade unrelated'),
+ helpFadeText: t('After you click a step, fade everything it does not touch.'),
+ inspector: t('Inspector'),
+ details: t('Details'),
+ evidence: t('Evidence'),
+ selectAStep: t('Select a step.'),
+ nestedStream: t('nested stream'),
+ stream: t('Stream'),
+ role: t('Role'),
+ openedBy: t('Opened by'),
+ reportedHereBy: t('Reported here by'),
+ note: t('Note'),
+ reportedNote: t('this talk hears the ending. The call that started it is in an earlier talk, because a launch and its completion land in different turns.'),
+ joinQuality: t('Join quality'),
+ openedAt: t('Opened at'),
+ itsAnswer: t('Its answer'),
+ itsAnswerNote: t('not in this stream. A child’s output belongs to the child, so the parent holds a boundary that refers to it, never a copy. Dive in to read it.'),
+ opensInTurn: t('Opens in turn'),
+ opensNothing: t('nothing — it opened no stream of its own'),
+ andMore: t('and {n} more', { n: '{n}' }),
+ poolWarning: t('this call started {n} streams', { n: '{n}' }),
+ poolWarningText: t('A workflow call starts a run, and the run starts a pool of agents. They are all its children, not competing guesses at one child.'),
+ diveIn: t('Dive in →'),
+ diveHint: t('Enter to dive in · Escape to clear'),
+ nodeKind: t('Node kind'),
+ lane: t('Lane'),
+ segment: t('Segment (activity window)'),
+ run: t('Run'),
+ directParent: t('Direct parent'),
+ observedAt: t('Observed at'),
+ requestToResult: t('Request to result'),
+ duration: t('Duration'),
+ durationUnavailable: t('unavailable — the runtime does not report how long this ran'),
+ requestToResultWhat: t('What "request to result" is'),
+ requestToResultText: t('The time between the record carrying the request and the record carrying the result. An exact identifier ties those two records together, so the interval belongs to this call. It is not how long the tool ran, and the runtime does not report that.'),
+ name: t('Name'),
+ failedField: t('Failed'),
+ yes: t('yes'),
+ no: t('no'),
+ contentState: t('Content state'),
+ contentBytes: t('Content bytes'),
+ tokens: t('Tokens'),
+ tokensText: t('in {in} · out {out} · cache read {cacheRead} · cache write {cacheWrite}', { in: '{in}', out: '{out}', cacheRead: '{cacheRead}', cacheWrite: '{cacheWrite}' }),
+ contentUnavailable: t('Content {state}', { state: '{state}' }),
+ contentUnavailableText: t('The source did not supply readable content for this step. It is reported as it was found, not filled in.'),
+ oneChildAgent: t('1 child agent'),
+ childAgents: t('{n} child agents', { n: '{n}' }),
+ openRelations: t('open the Relations tab →'),
+ backToParentStream: t('← Back to parent stream'),
+ noRelation: t('No relation touches this step.'),
+ agentsCreatedByCall: t('{n} agents created by this call', { n: '{n}' }),
+ agentsCreatedText: t('A workflow call starts a run, and the run starts a pool of agents. Every one of them is a child of this call, not a competing guess at one child. Pick the one to read.'),
+ outgoing: t('outgoing'),
+ incoming: t('incoming'),
+ joinedOn: t('joined on'),
+ namedFromJournal: t('named from its run journal'),
+ otherEndOutside: t('the other end is outside this stream'),
+ derivedByAssembly: t('This step was derived by assembly. It cites no landed record.'),
+ landedPositions: t('landed positions'),
+ request: t('request'),
+ part: t('part'),
+ record: t('record'),
+ clippedText: t('the text as the document carries it'),
+ fullTextNote: t('clipped: {shown} of {total} bytes', { shown: '{shown}', total: '{total}' }),
+ loadFullRecord: t('Load the landed record'),
+ loadingRecord: t('reading…'),
+ recordFailed: t('The record could not be read.'),
+ theLandedRecord: t('the landed record'),
+ dropped: t('dropped'),
+ flags: t('flags'),
+ unavailable: t('unavailable'),
+ vocabulary: t('vocabulary'),
+ modelOwnWord: t('the model’s own word'),
+ aszTerm: t('asz term'),
+ runtimeWord: t('runtime word'),
+ runtimeNoWord: t('none — this is derived here, the runtime has no word for it'),
+ whereToLook: t('where to look'),
+ nowhereInSource: t('nowhere in the source; it is produced by assembly'),
+ readCarefully: t('read it carefully'),
+ dialect: t('dialect'),
+ landedRecordField: t('a field of the landed record'),
+ aszField: t('asz field'),
+ whatItIs: t('what it is'),
+ notApplicable: t('not applicable — this describes the landed record, not the source'),
+ close: t('Close'),
+ whatDoesMean: t('What does {key} mean?', { key: '{key}' }),
+ selected: t('Selected {what}', { what: '{what}' }),
+ selectedNestedStream: t('Selected nested stream {name}, opened by {kind}. Press Enter to dive in, Escape to clear.', { name: '{name}', kind: '{kind}' }),
+ selectionCleared: t('Selection cleared.'),
+ };
+}
diff --git a/apps/ui/src/layer/ai-conversation/useLayerConversations.ts b/apps/ui/src/layer/ai-conversation/useLayerConversations.ts
new file mode 100644
index 00000000..95e95c3c
--- /dev/null
+++ b/apps/ui/src/layer/ai-conversation/useLayerConversations.ts
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { computed, type Ref } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import { bffClient } from '@/api/client';
+import type { AiConversationRow, AiConversationsResponse } from '@/api/client';
+
+export interface LayerConversationsParams {
+ /** The agent runtime — the layer's service, by NAME, as the OAP list keys on it. */
+ service: Ref<string | null>;
+ /** One sender, by instance name, or null for every sender of the runtime. */
+ instanceName: Ref<string | null>;
+ /** One conversation by id, or null. */
+ conversation: Ref<string | null>;
+ /** Text the title must contain, or null. OAP matches it. */
+ title: Ref<string | null>;
+ /** Rolling window in minutes. The tab owns its range; the global ticker is
+ * paused on it. */
+ windowMinutes: Ref<number>;
+ /** Manual fire: the query waits here until the operator presses Run query. */
+ enabled: Ref<boolean>;
+}
+
+/** The conversations of one agent runtime, newest activity first, as the BFF
+ * folded them from the newest rounds in the window. `limit` is that round
+ * budget, which the page states beside the table because OAP gives no
+ * truncation signal to show instead. */
+export function useLayerConversations(layerKey: Ref<string>, params: LayerConversationsParams) {
+ const q = useQuery<AiConversationsResponse>({
+ queryKey: [
+ 'layer-ai-conversations', layerKey, params.service, params.instanceName, params.conversation, params.title, params.windowMinutes,
+ ],
+ queryFn: ({ signal }) =>
+ bffClient.aiConversation.list(
+ layerKey.value,
+ {
+ service: params.service.value ?? '',
+ ...(params.instanceName.value ? { instanceName: params.instanceName.value } : {}),
+ ...(params.conversation.value ? { conversation: params.conversation.value } : {}),
+ ...(params.title.value ? { title: params.title.value } : {}),
+ windowMinutes: params.windowMinutes.value,
+ },
+ signal,
+ ),
+ enabled: computed(
+ () => layerKey.value.length > 0 && !!params.service.value && params.enabled.value,
+ ),
+ staleTime: 15_000,
+ });
+ const data = computed<AiConversationsResponse | null>(() => q.data.value ?? null);
+ return {
+ data,
+ rows: computed<AiConversationRow[]>(() => data.value?.rows ?? []),
+ limit: computed<number | null>(() => data.value?.limit ?? null),
+ reachable: computed<boolean>(() => data.value?.reachable ?? true),
+ queryError: computed<string | null>(() => data.value?.error ?? null),
+ isFetching: q.isFetching,
+ error: q.error,
+ refetch: q.refetch,
+ };
+}
diff --git a/apps/ui/src/layer/useLayerInstances.ts b/apps/ui/src/layer/useLayerInstances.ts
index 76afe658..fbc3b536 100644
--- a/apps/ui/src/layer/useLayerInstances.ts
+++ b/apps/ui/src/layer/useLayerInstances.ts
@@ -29,10 +29,21 @@ import { useQuery } from '@tanstack/vue-query';
import { bffClient } from '@/api/client';
import type { ServiceRef } from '@/utils/serviceRef';
-export function useLayerInstances(layerKey: Ref<string>, service: Ref<ServiceRef | null>) {
+export function useLayerInstances(
+ layerKey: Ref<string>,
+ service: Ref<ServiceRef | null>,
+ /** A tab that owns a range of days passes it, so the list covers what the
+ * tab can show; omitted means the BFF's recent default. */
+ windowMinutes?: Ref<number>,
+) {
const q = useQuery({
- queryKey: ['layer-instances', layerKey, service],
- queryFn: () => bffClient.layer.instances(layerKey.value, service.value!),
+ queryKey: ['layer-instances', layerKey, service, windowMinutes ?? null],
+ queryFn: () =>
+ bffClient.layer.instances(
+ layerKey.value,
+ service.value!,
+ windowMinutes?.value ? { windowMinutes: windowMinutes.value } : {},
+ ),
enabled: computed(() => layerKey.value.length > 0 && !!service.value),
staleTime: 30_000,
});
diff --git a/apps/ui/src/shell/SidebarLayerChildren.vue b/apps/ui/src/shell/SidebarLayerChildren.vue
index 8b98676a..e5070e89 100644
--- a/apps/ui/src/shell/SidebarLayerChildren.vue
+++ b/apps/ui/src/shell/SidebarLayerChildren.vue
@@ -56,6 +56,7 @@ function labelFor(path: string): string {
case 'logs': return t('Logs');
case 'browser-errors': return t('Browser Logs');
case 'pod-logs': return t('Pod Logs');
+ case 'conversations': return t('Conversations');
case 'trace-profiling': return t('Trace Profiling');
case 'ebpf-profiling': return t('eBPF Profiling');
case 'network-profiling': return t('Network Profiling');
diff --git a/apps/ui/src/shell/layerFromTemplate.test.ts b/apps/ui/src/shell/layerFromTemplate.test.ts
index 0d985d2f..6fdc0099 100644
--- a/apps/ui/src/shell/layerFromTemplate.test.ts
+++ b/apps/ui/src/shell/layerFromTemplate.test.ts
@@ -166,7 +166,7 @@ describe('componentsToCaps — parity with the menu route', () => {
service: true, instances: true, endpoints: true, topology: true, deployment: true,
endpointDependency: true, traces: true, logs: true, browserErrors: true, podLogs: true,
traceProfiling: true, ebpfProfiling: true, asyncProfiling: true, networkProfiling: true,
- pprofProfiling: true, continuousProfiling: true,
+ pprofProfiling: true, continuousProfiling: true, aiConversations: true,
},
deployment: { roles: [] },
traces: { source: 'both' },
diff --git a/apps/ui/src/shell/layerFromTemplate.ts b/apps/ui/src/shell/layerFromTemplate.ts
index bd210243..2816dd71 100644
--- a/apps/ui/src/shell/layerFromTemplate.ts
+++ b/apps/ui/src/shell/layerFromTemplate.ts
@@ -112,6 +112,7 @@ export function componentsToCaps(
networkProfiling: !!c.networkProfiling,
pprofProfiling: !!c.pprofProfiling,
continuousProfiling: !!c.continuousProfiling,
+ aiConversations: !!c.aiConversations,
};
}
diff --git a/apps/ui/src/shell/router/index.ts b/apps/ui/src/shell/router/index.ts
index 59f28fde..cc75e4ff 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -92,6 +92,10 @@ function layerRoute(): RouteRecordRaw {
// On-demand pod logs (live tail). Instance-pinned; only K8s-
// deployed layers (caps.podLogs) surface the tab in the sidebar.
{ path: 'pod-logs', component: () => import('@/layer/pod-logs/LayerPodLogsView.vue') },
+ // AI agent conversations (AI_AGENT layer). The runtime is the shell's
+ // service picker, like every other record tab; the tab owns its time
+ // range and its sender filter.
+ { path: 'conversations', component: () => import('@/layer/ai-conversation/LayerConversationsView.vue') },
{ path: 'trace-profiling', component: () => import('@/layer/profiling/LayerTraceProfilingView.vue') },
{ path: 'ebpf-profiling', component: () => import('@/layer/profiling/LayerEBPFProfilingView.vue') },
{ path: 'async-profiling', component: () => import('@/layer/profiling/LayerAsyncProfilingView.vue') },
@@ -380,6 +384,16 @@ const router = createRouter({
name: 'ai',
component: () => import('@/ai/AiFullPageView.vue'),
},
+ // One AI agent conversation, full page, OUTSIDE the AppShell. The document is
+ // tens of megabytes and a reader keeps it open beside the list, so the list
+ // opens it in its own tab; the URL carries the position (talk / step /
+ // stream) and is the thing to share. Requires auth via the global guard; the
+ // BFF enforces `ai-conversation:read` on the document route itself.
+ {
+ path: '/ai-conversation/:conversation',
+ name: 'ai-conversation',
+ component: () => import('@/layer/ai-conversation/ConversationPageView.vue'),
+ },
{
path: '/',
component: () => import('@/shell/AppShell.vue'),
diff --git a/apps/ui/src/shell/useOapInfo.ts b/apps/ui/src/shell/useOapInfo.ts
index 17966211..a467f814 100644
--- a/apps/ui/src/shell/useOapInfo.ts
+++ b/apps/ui/src/shell/useOapInfo.ts
@@ -93,6 +93,7 @@ export function useOapInfo() {
const capabilities = computed<OapCapabilities>(() => ({
queryAlarms: info.value?.capabilities?.queryAlarms ?? false,
logKeywords: info.value?.capabilities?.logKeywords ?? false,
+ aiConversations: info.value?.capabilities?.aiConversations ?? false,
}));
/** Storage backend probed via TTL response shape. `unknown` until the
diff --git a/apps/ui/src/shell/useTopbarTimeContext.ts b/apps/ui/src/shell/useTopbarTimeContext.ts
index 5070395c..6872b0f9 100644
--- a/apps/ui/src/shell/useTopbarTimeContext.ts
+++ b/apps/ui/src/shell/useTopbarTimeContext.ts
@@ -36,6 +36,8 @@ const TIME_RANGE_OPT_OUT = [
// Browser Logs has its own Time range picker — auto-refresh would shift
// the visible window mid-investigation.
/^\/layer\/[^/]+\/browser-errors$/,
+ // AI agent conversations: own range in days, fired on Run query.
+ /^\/layer\/[^/]+\/conversations$/,
// Pod Logs is a live tail driven by its own interval poll — the global
// ticker would double-fire and there's no rolling window to refresh.
/^\/layer\/[^/]+\/pod-logs$/,
diff --git a/apps/ui/src/state/theme.test.ts b/apps/ui/src/state/theme.test.ts
index e88a0a75..27991faf 100644
--- a/apps/ui/src/state/theme.test.ts
+++ b/apps/ui/src/state/theme.test.ts
@@ -25,7 +25,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { bff } from '@/api/client';
-import { useThemeStore } from './theme';
+import { THEMES } from '@skywalking-horizon-ui/design-tokens';
+import { AVAILABLE_THEMES, useThemeStore } from './theme';
+
+describe('theme table — one list, two readers', () => {
+ it('the picker describes exactly the shared themes, with the same appearance', () => {
+ expect(AVAILABLE_THEMES.map((t) => ({ id: t.id, label: t.label, appearance: t.appearance }))).toEqual([...THEMES]);
+ });
+});
describe('theme store — org default', () => {
beforeEach(() => {
diff --git a/apps/ui/src/state/theme.ts b/apps/ui/src/state/theme.ts
index 900591da..86a0cf42 100644
--- a/apps/ui/src/state/theme.ts
+++ b/apps/ui/src/state/theme.ts
@@ -30,7 +30,8 @@
* from the org default" affordances. The store keeps the resolved id
* and reflects it via `<html data-theme="<id>">`; CSS in
* `themes.css` swaps tokens off that attribute. Adding a theme is
- * one CSS block + one entry in `AVAILABLE_THEMES`.
+ * one CSS block, one entry in the design-tokens `THEMES` list and
+ * one in `AVAILABLE_THEMES`; the unit test keeps the two lists equal.
*/
import { defineStore } from 'pinia';
@@ -38,8 +39,11 @@ import { computed, ref, watch } from 'vue';
import { useConfigBundle } from '@/controls/configBundle';
import { debug } from '@/utils/debug';
import type { TemplateBadge } from '@/api/scopes/configs';
+import type { ThemeId as SharedThemeId } from '@skywalking-horizon-ui/design-tokens';
-export type ThemeId = 'horizon' | 'meridian' | 'obsidian' | 'daybreak' | 'aurora';
+/** The id list is shared with hosts outside Horizon (the renderer
+ * package's `themes.json` is generated from it). */
+export type ThemeId = SharedThemeId;
/** Full per-theme metadata — lifted from the design bundle's
* `screens/style-setup.jsx`. The token values here are duplicated
diff --git a/docs/access-control/rbac.md b/docs/access-control/rbac.md
index 0f39020f..1c847fb9 100644
--- a/docs/access-control/rbac.md
+++ b/docs/access-control/rbac.md
@@ -25,6 +25,7 @@ Known verbs are grouped into areas:
| `traces:read` | Traces tab on any layer, trace detail page. |
| `logs:read` | Logs tab on any layer, log detail page. |
| `browser-errors:read` | Browser Logs tab (BROWSER layer): list JS error logs, list source maps, resolve a stack. |
+| `ai-conversation:read` | AI agent conversations (AI_AGENT layer): list the conversations the AI Sessionizer pushed and open one. Distinct from `ai:read`, which is the AI assistant. |
| `inspect:read` | The read-only inspect tools: Metrics Inspect (`/operate/inspect`), Trace Inspect (`/operate/trace-inspect`), Log Inspect (`/operate/log-inspect`). |
| `topology:read` | Topology tab, topology widgets on overviews. |
| `profile:read` | Profiling tab (results read-only) and the continuous-profiling policy list. |
@@ -116,7 +117,7 @@ Default definitions (used when `rbac.roles` is not overridden):
Read-only data catalog, the read-only inspect tools, and the AI assistant. Deliberately limited — does not include `*:read` so a viewer cannot peek at rule definitions, live-debug sessions, setup screens, or cluster / TTL / config internals.
```
-metrics:read, alarms:read, events:read, traces:read, logs:read, browser-errors:read, inspect:read, topology:read, profile:read, overview:read, infra-3d:read, ai:read, mcp:read
+metrics:read, alarms:read, events:read, traces:read, logs:read, browser-errors:read, ai-conversation:read, inspect:read, topology:read, profile:read, overview:read, infra-3d:read, ai:read, mcp:read
```
### `maintainer`
diff --git a/docs/changelog/1.1.0.md b/docs/changelog/1.1.0.md
index 66079102..fd07047d 100644
--- a/docs/changelog/1.1.0.md
+++ b/docs/changelog/1.1.0.md
@@ -6,6 +6,16 @@
**Breaking:** Turning the **Cold** pill on no longer re-reads the page. It changes what the NEXT read asks for — the following auto-refresh round, or whatever you do first, typically choosing the time range the cold data lives in. Flipping it used to start a round of its own, which on a cold tier routinely ran past the sixty-second cap; the sweep that followed then re-queued exactly the queries the cap had just cancelled, so the cap was at its most useless during the outage it exists for [...]
+## AI agent conversations
+
+### Features
+
+**Horizon can now read the AI agent conversations an OAP stores.** OAP 11.1.0 keeps the conversations that the [SkyWalking AI Sessionizer](https://github.com/apache/skywalking-ai-sessionizer) pushes under the `AI_AGENT` layer, one row per conversation and one document per conversation. This release wires Horizon to both reads, behind a new permission, `ai-conversation:read`, that the built-in `viewer`, `maintainer` and `operator` roles carry. It is its own permission rather than part of [...]
+
+**A new bundled layer, AI Agents, with a Conversations tab.** When OAP reports the `AI_AGENT` layer, the sidebar gains an **AI Agents** entry whose one tab lists the conversations of one agent runtime: pick the runtime in the layer header and a time range (a day to 90 days), optionally one sender, click **Run query**, and each row shows the conversation's title, which Sessionizer sent it, its talks, steps, streams, segments and unresolved references, its span and its last activity, newes [...]
+
+**A conversation opens in its own tab, as one shareable page.** Clicking a row on the Conversations tab opens `/ai-conversation/<id>` in a new browser tab: the conversation's transcript, with the agent's work folded under each input; a flow timeline of one execution stream with lanes for input, responses, context, model calls, tools, agent activity and nested child streams, relation lines for the selected step, and a way to dive into a child agent and back; and an inspector with the sele [...]
+
## Sign-in and access control
### Features
diff --git a/docs/customization/layer-templates.md b/docs/customization/layer-templates.md
index 8fd0df36..1e49b884 100644
--- a/docs/customization/layer-templates.md
+++ b/docs/customization/layer-templates.md
@@ -98,7 +98,7 @@ Per-tab feature toggles. A `false` value hides the tab.
}
```
-The keys are the per-layer sub-tabs. `networkProfiling` and `podLogs` are also available. Only the **service** dashboard is on when its key is omitted; every other tab is **off unless explicitly set `true`** — the bundled templates enable each tab they want (`general.json` sets every flag `true` for exactly this reason). The row a layer opens on is the **first enabled** in the order `service → instances → endpoints → topology → deployment → endpointDependency → traces → logs → profiling` [...]
+The keys are the per-layer sub-tabs. `networkProfiling`, `podLogs` and `aiConversations` (the AI agent conversations list of the `AI_AGENT` layer) are also available. Only the **service** dashboard is on when its key is omitted; every other tab is **off unless explicitly set `true`** — the bundled templates enable each tab they want (`general.json` sets every flag `true` for exactly this reason). The row a layer opens on is the **first enabled** in the order `service → instances → endpoi [...]
`deployment` is the exception: it is **off by default** and only appears when the layer also carries a [`deployment`](#deployment) config block — see [Deployment](#deployment) below.
diff --git a/docs/customization/menu-structure.md b/docs/customization/menu-structure.md
index a95df239..d26cf8bd 100644
--- a/docs/customization/menu-structure.md
+++ b/docs/customization/menu-structure.md
@@ -63,7 +63,7 @@ Expanding a layer shows one row per enabled component, plus any extension pages
```text
service -> instance -> endpoint -> topology -> deployment -> dependency
--> trace -> zipkin-trace -> logs -> browser-errors -> pod-logs
+-> trace -> zipkin-trace -> logs -> browser-errors -> pod-logs -> conversations
-> trace-profiling -> ebpf-profiling -> network-profiling
-> continuous-profiling -> pprof -> async-profiling
```
diff --git a/docs/dashboards/ai_agent.md b/docs/dashboards/ai_agent.md
new file mode 100644
index 00000000..448a5232
--- /dev/null
+++ b/docs/dashboards/ai_agent.md
@@ -0,0 +1,32 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+# AI Agents
+
+The **AI_AGENT** layer is where the [SkyWalking AI Sessionizer](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/readme/) lands the conversations of long-lived AI agents. The Sessionizer collects an agent runtime's transcripts on the machine where the agent runs, assembles them into conversations, and pushes them to OAP; OAP stores them under this layer and answers Horizon's reads. Nothing in Horizon talks to the agent runtime or to the Sessionizer directly.
+
+In Horizon's sidebar this layer is named **AI Agents**. Its top-level entities are **Agent runtimes** (the service slot): one per kind of agent, `Claude Code` for the Claude Code adapter, or whatever service name the Sessionizer was configured with. Each runtime reports through one or more **Senders** (the instance slot): one Sessionizer process on one machine, named `user@host` by default, or the mailbox or machine name its operator set.
+
+The layer has no metric dashboards yet, so it has no Service, Instance or Endpoint page; its one tab is **Conversations**. See [AI Agent Conversations](../operate/ai-agent-conversations.md) for what that tab shows and how to read it.
+
+> The layer appears only when OAP reports it, which needs OAP 11.1.0 or later with at least one conversation pushed. The OAP side — receiving, verifying and storing the files, and its retention — is documented with the other [OAP backend setup pages](https://github.com/apache/skywalking/tree/master/docs/en/setup/backend) in the SkyWalking repository.
+
+## Bundled template
+
+The bundled AI_AGENT template enables the `aiConversations` component and nothing else, and names the two entity slots as above. Like every layer template it can be edited under **Dashboard setup → Layer dashboards** — for example to rename the slots for your organisation — and published to OAP; see [Layer Dashboard Templates](../customization/layer-templates.md).
+
+When the Sessionizer starts exporting conversation metrics, this template is where their dashboards will be added.
diff --git a/docs/menu.yml b/docs/menu.yml
index ee57405f..9d46c32f 100644
--- a/docs/menu.yml
+++ b/docs/menu.yml
@@ -77,6 +77,8 @@ catalog:
path: "/operate/alarms"
- name: "Events"
path: "/operate/events"
+ - name: "AI Agent Conversations"
+ path: "/operate/ai-agent-conversations"
- name: "Profiling"
path: "/operate/profiling"
- name: "Metrics Inspect"
@@ -110,6 +112,10 @@ catalog:
path: "/dashboards/general"
- name: "Browser"
path: "/dashboards/browser"
+ - name: "AI Agents"
+ catalog:
+ - name: "AI Agents"
+ path: "/dashboards/ai_agent"
- name: "AWS"
catalog:
- name: "AWS DynamoDB"
diff --git a/docs/operate/ai-agent-conversations.md b/docs/operate/ai-agent-conversations.md
new file mode 100644
index 00000000..378ab48b
--- /dev/null
+++ b/docs/operate/ai-agent-conversations.md
@@ -0,0 +1,86 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+# AI Agent Conversations
+
+The **Conversations** tab of the [AI Agents](../dashboards/ai_agent.md) layer lists the conversations of long-lived AI agents that the [SkyWalking AI Sessionizer](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/readme/) pushed to OAP. A conversation is the Sessionizer's unit of storage: one durable exchange between a person and an agent, however many sessions, context resets and child agents it spanned. This page is about the list; each row stands for one conversation a [...]
+
+It is not the [AI assistant](ai-assistant.md). The assistant is Horizon's own chat, where Horizon sends text to a model provider on your behalf. This tab reads stored transcripts of other agents and sends nothing anywhere.
+
+## Requirements
+
+- OAP 11.1.0 or later. Older OAPs have no `AI_AGENT` layer, so the layer never appears in the sidebar.
+- A running AI Sessionizer configured to push to this OAP. Until it has pushed at least one conversation, the layer has no service and stays hidden.
+- The `ai-conversation:read` permission, which the built-in `viewer`, `maintainer` and `operator` roles carry. See [Roles and Permissions](../access-control/rbac.md).
+
+## Reading the list
+
+The **agent runtime** is the layer's service — `Claude Code`, or the name the Sessionizer was configured with — and it is picked where every layer's service is picked, in the header above the tabs. Pick a **time range**, optionally one **sender** (the Sessionizer processes that pushed to this runtime), then click **Run query**. The sender is part of the query: OAP lists only that sender's conversations. The tab owns its own time range, like the Traces and Logs tabs: the global time picke [...]
+
+Two more conditions travel with the query: **Title contains** keeps the conversations whose title contains the text, matched by OAP on each conversation's newest title, and **Conversation id** asks OAP for that one conversation. Enter in either box runs the query.
+
+| Column | Meaning |
+|---|---|
+| Title | The session's title as the agent runtime recorded it. A conversation with no title shows *(untitled)*; the conversation id sits under the title in either case. |
+| Sender | Which Sessionizer pushed it — one process on one machine, `user@host` unless its operator named it. |
+| Talks | Readable exchanges: one input from outside, the agent's run, and its answer. |
+| Steps | Everything the agent did: model calls, reasoning, tool uses, messages, agent launches, resets. |
+| Streams | Execution streams: the main agent plus one per child agent it started. |
+| Segments | Activity windows, split by idle time. |
+| Unresolved | References the Sessionizer could not resolve — a tool result it never saw, a child whose stream never arrived. Worth a look when it is not zero. |
+| Span | From the conversation's first record to its last activity. |
+| Last activity | When the newest record was written, in your browser's time. Rows are ordered by it, newest first. |
+
+The counts come from the conversation's newest round as the Sessionizer wrote it; they are what the Sessionizer itself lists, not something Horizon computed.
+
+### What the list can and cannot show
+
+**The list is built from rounds, not conversations.** The Sessionizer publishes a conversation as a chain of immutable rounds, a new round every few minutes while the agent is active, and OAP builds the list from the newest rounds in the window — up to a fixed budget (10,000 by default), folded to one row per conversation. The line above the table states that budget. A conversation whose newest round is older than every one of those rounds is not listed even though it is stored; narrowin [...]
+
+**Retention is OAP's.** On BanyanDB the conversation files live in their own group with their own lifetime (30 days by default); on other storages they follow the record retention. A conversation older than that is gone from the list because it is gone from OAP.
+
+**One conversation, one row, per sender.** The same conversation pushed by two Sessionizers, or by one that was renamed between pushes, appears once per sender name.
+
+## Reading a conversation
+
+Click a row, or press Enter on it, and the conversation opens in a **new browser tab** of its own. The page reads the whole conversation from OAP in one document and shows the wait as it happens: first *OAP is assembling the conversation* with the seconds counting, because OAP folds the whole chain before it sends a byte; then the bytes coming in as *n of m MB received* with a percentage, the rate and the time left, and the talk, step and stream counts; then the parse and the draw. A con [...]
+
+The page's address is the thing to share. It carries the conversation, its agent runtime and sender, and the reader's position — the talk, the selected step and the stream being read — and it is updated in place as you move, so copying the address bar hands a colleague the same step. Opening it needs a Horizon sign-in and the `ai-conversation:read` permission; a signed-out reader is sent to the login page and back.
+
+The page has three parts, and a header with the conversation's title, runtime, sender and id, a link back to the list, the theme chip and the language picker (the page follows your Horizon theme, the light one included, and every text of it is translated):
+
+- **Transcript** — the talks of one execution stream in reading order. What came from outside sits on the right; the agent's replies sit on the left; the work between an input and its reply (model calls, reasoning, tool uses, agent launches, context resets) is folded under a *show what the agent did* row so a long run reads as a conversation until you open it. Long pauses are marked. Steps the Sessionizer could not place under any talk are listed in their own *Outside any talk* section r [...]
+- **Flow timeline** — the same stream on a time axis, one lane per kind of activity (external input, responses, context put in, model calls, tools, agent activity, runtime notices, nested streams). Busy stretches take the width; a long pause is cut to a marked gap. Selecting a step draws the relations that touch it: a solid line is an exact join, a dashed one was inferred, a faint curve is ownership (the model call that produced a step). A child agent appears as a nested stream you can s [...]
+- **Inspector** — the selected step's **Details** (kind, lane, stream, segment, run, parent, time, token counts, request-to-result interval where the runtime recorded it), its **Relations** (what it opened, what opened it, what it joined with and how well), and its **Evidence**: the landed positions of the record the step came from and the text as the document carries it, with a note when the document clipped it.
+
+The status strip at the top states the document's integrity — *verified* when every round's digest chained, *incomplete* when rounds are missing, *mismatch* when a digest did not match — and lists the problems the Sessionizer recorded when it is not verified. The **Overview** button opens the summary cells and a filterable list of every talk in the conversation.
+
+What the page shows is exactly what the Sessionizer's own viewer shows for the same conversation: the two draw the same document with the same renderer. The words the runtime uses for things — node kinds such as `message.external`, relation types, join qualities — are shown as the document carries them, in every locale.
+
+### Limits
+
+- **One document, all at once.** OAP sends the whole conversation, and its `viewTimeoutMs` budget (120 s by default, see [Configuration File](../setup/horizon-yaml.md)) covers the wait for the first byte. A conversation OAP cannot assemble within that time reports a timeout; try again, or raise the budget on both sides. Horizon holds the document before the browser starts receiving it, which is how the page knows the size to count against; that hold is a fifth of the document's size per [...]
+- **Records are not on OAP.** The Sessionizer's own viewer can open the raw landed record behind a step; OAP stores the assembled document only, so this page shows the text the document carries and says when it was clipped.
+
+## Troubleshooting
+
+- **The layer is missing from the sidebar.** OAP reports no `AI_AGENT` layer: either it is older than 11.1.0, or nothing has been pushed yet. Check the Sessionizer's push output for its receiver address and errors.
+- **The runtime is listed but the query returns nothing.** The window may be too narrow for a conversation's last activity, or every round in the window may belong to other conversations (see above). Try 90 days, and check the sender filter is not set.
+- **A conversation you know exists is not there.** Its newest round may lie outside the round budget, or the conversation may be older than OAP's retention. The Sessionizer's own list page shows what it holds locally; compare the two.
+- **The conversation page says OAP holds no round for this runtime.** The link names a runtime OAP has no round of this conversation for — the Sessionizer was renamed between pushes, or the sender in the link is not the one that pushed it. Open the conversation from the list again.
+- **The page waits a long time, then reports a timeout.** OAP assembles the whole chain before answering, and a very long conversation can exceed the time budget. Try again; if it keeps happening, raise `performance.aiConversation.viewTimeoutMs` in Horizon and the matching `viewRequestTimeout` on OAP.
+- **A row does nothing when clicked.** The browser blocked the new tab as a pop-up. Allow pop-ups for Horizon's address, or open the row with Enter after focusing it.
diff --git a/docs/setup/horizon-yaml.md b/docs/setup/horizon-yaml.md
index b6da6840..ca43f20f 100644
--- a/docs/setup/horizon-yaml.md
+++ b/docs/setup/horizon-yaml.md
@@ -96,6 +96,7 @@ performance:
topologyMaxEdges?: number
headerWarmupMaxServices?: number
maxPageSize: { traces?, logs?, browserLogs?, events? }
+ aiConversation: { listLimit?, viewTimeoutMs? }
layers: { excluded?: [{ key, reason? }] }
```
@@ -233,6 +234,9 @@ performance:
topologyMaxEdges: 15000
headerWarmupMaxServices: 200
maxPageSize: { traces: 100, logs: 100, browserLogs: 100, events: 200 }
+ aiConversation:
+ listLimit: 10000
+ viewTimeoutMs: 120000
```
The `performance` block tunes how hard Horizon drives your OAP and storage backend. **Every default equals the built-in value, so the whole block is optional** — omit it and Horizon behaves exactly as it does without it. Every value is also **clamped to a hard ceiling**: a number above the ceiling is pulled back down to it (config can only lower the load below a built-in limit, never raise it past one). Hot-reloadable — a change takes effect on the next request of that kind.
@@ -269,6 +273,15 @@ These govern how Horizon batches and parallelizes its metric queries to OAP. Eac
- **`topologyMaxNodes` / `topologyMaxEdges`** are a readability and safety valve, not a data limit — if your deployment legitimately has a graph this large, raising them lets it render (at the cost of a denser scene and a heavier draw). Lower them if you'd rather force operators to scope down sooner.
- **`maxPageSize.*`** bound how many rows one Traces / Logs / Browser-Logs / Events page shows, and with it how much each list read pulls from storage (one record beyond the page — that extra row is how the list knows another page exists). Some storage backends fail or slow on large list queries — lower these to keep list pages cheap on a constrained backend; raise them (up to the ceiling) if your backend serves big result sets comfortably and operators want more rows per page.
+### `performance.aiConversation` — AI agent conversations
+
+| Key | Meaning | Default |
+|---|---|---|
+| `listLimit` | How many of the newest **rounds** the conversation list reads in the chosen time range before folding them into one row per conversation. It is not a row count: a conversation that ran for days has hundreds of rounds, and every one of them spends this budget, so a small value lets one long conversation hide the short ones entirely. The default is the OAP ceiling. Lower it only if the list is slow on your storage. | `10000` |
+| `viewTimeoutMs` | How long Horizon waits for OAP to send a conversation document. OAP assembles the whole conversation before the first byte leaves — several seconds for a conversation with tens of thousands of steps — and runs the request under its own timeout (`viewRequestTimeout`, 120 s by default), which this should match. | `120000` |
+
+A conversation document is relayed exactly as OAP sends it, compressed, so these two are the only knobs; there is no page size.
+
## Excluded layers
```yaml
diff --git a/docs/setup/rbac.md b/docs/setup/rbac.md
index fe3fdd77..4ed3469d 100644
--- a/docs/setup/rbac.md
+++ b/docs/setup/rbac.md
@@ -9,8 +9,8 @@ rbac:
enabled: true
builtinRoles: replace # or `keep` to merge onto the built-ins
roles:
- viewer: [metrics:read, alarms:read, events:read, traces:read, logs:read, browser-errors:read, inspect:read, topology:read, profile:read, overview:read, infra-3d:read, ai:read, mcp:read]
- maintainer: [metrics:read, alarms:read, events:read, traces:read, logs:read, browser-errors:read, topology:read, profile:read, overview:read, cluster:read, inspect:read, ttl:read, config:read, infra-3d:read, ai:read, mcp:read]
+ viewer: [metrics:read, alarms:read, events:read, traces:read, logs:read, browser-errors:read, ai-conversation:read, inspect:read, topology:read, profile:read, overview:read, infra-3d:read, ai:read, mcp:read]
+ maintainer: [metrics:read, alarms:read, events:read, traces:read, logs:read, browser-errors:read, ai-conversation:read, topology:read, profile:read, overview:read, cluster:read, inspect:read, ttl:read, config:read, infra-3d:read, ai:read, mcp:read]
operator: [metrics:read, ..., rule:write:structural, live-debug:write, profile:enable, ai:read, mcp:read]
admin: ["*"]
landingByRole:
@@ -33,7 +33,7 @@ rbac:
| Role | Purpose | Grants |
|---|---|---|
-| `viewer` | Read-only data catalog, inspect tools, public overviews, the AI assistant, and MCP. | `metrics:read`, `alarms:read`, `events:read`, `traces:read`, `logs:read`, `browser-errors:read`, `inspect:read`, `topology:read`, `profile:read`, `overview:read`, `infra-3d:read`, `ai:read`, `mcp:read`. Deliberately not `*:read` so the viewer cannot see rule definitions, live-debug sessions, setup screens, or cluster / TTL / config internals. |
+| `viewer` | Read-only data catalog, inspect tools, public overviews, the AI assistant, and MCP. | `metrics:read`, `alarms:read`, `events:read`, `traces:read`, `logs:read`, `browser-errors:read`, `ai-conversation:read`, `inspect:read`, `topology:read`, `profile:read`, `overview:read`, `infra-3d:read`, `ai:read`, `mcp:read`. Deliberately not `*:read` so the viewer cannot see rule definitions, live-debug sessions, setup screens, or cluster / TTL / config internals. |
| `maintainer` | Viewer + platform monitoring. | viewer baseline + `cluster:read`, `ttl:read`, `config:read`. |
| `operator` | Configures observability. | maintainer baseline + `source-map:write`, the six Dashboard-setup read/write pairs (`overview-template`, `layer-template`, `translation`, `alarm-setup`, `infra-3d-setup`, `setup`), `alarm-rule:read`, `rule:read`, `rule:write`, `rule:write:structural`, `rule:delete`, `live-debug:read`/`write`, `profile:enable`. Alarm rules are read-only for every role. |
| `admin` | Unrestricted. | `*`. |
diff --git a/packages/api-client/src/ai-conversation.ts b/packages/api-client/src/ai-conversation.ts
new file mode 100644
index 00000000..4bf943fc
--- /dev/null
+++ b/packages/api-client/src/ai-conversation.ts
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Wire types for AI agent conversations — the `AI_AGENT` layer OAP fills from
+ * the AI Sessionizer's Session Data and Session Flow files (OAP 11.1.0+).
+ *
+ * Two reads. The LIST is a GraphQL query, `listConversations`, one row per
+ * conversation from its newest round's header. The CONVERSATION itself is not
+ * GraphQL: OAP serves it as one `asz.view` document from its own route on the
+ * query host, streamed and compressed, because a long conversation is tens of
+ * megabytes. The BFF relays that route byte for byte; the document's shape is
+ * defined by the Sessionizer (`pkg/sessionview`), not here.
+ */
+
+/** One row of the list, straight from OAP's `ConversationRow`. Every value comes
+ * from the newest round's attributes; nothing is folded to build it. */
+export interface AiConversationRow {
+ conversation: string;
+ /** The sender: one Sessionizer process on one machine. */
+ serviceInstanceId: string;
+ serviceInstanceName: string;
+ /** Empty when the session carried no title — render a placeholder, never the id. */
+ title: string;
+ /** The head round this row came from. */
+ round: number;
+ talks: number;
+ steps: number;
+ streams: number;
+ segments: number;
+ unresolved: number;
+ /** When the conversation began, epoch ms. */
+ from: number;
+ /** Its last activity so far, epoch ms. */
+ to: number;
+}
+
+export interface AiConversationsQueryRequest {
+ /** Service NAME. OAP's condition keys on the name (`ServiceCondition.serviceName`),
+ * so the id form other feeds carry has nowhere to go here. */
+ service: string;
+ /** Optional sender filter — a row's `serviceInstanceName`. */
+ instanceName?: string;
+ /** Optional: one conversation by id. OAP reads that conversation's rounds alone. */
+ conversation?: string;
+ /** Optional: only rows whose title contains this text, case-insensitively.
+ * OAP matches it on the folded row, so it never widens the rounds read. */
+ title?: string;
+ /** The newest N ROUNDS OAP reads in the window before folding them to one row
+ * per conversation. Not a row count: a chatty conversation spends the budget
+ * of quiet ones, so the BFF defaults this to the OAP ceiling. */
+ limit?: number;
+ /** Rolling window in minutes, ending at "now". Ignored when an explicit
+ * `startMs` / `endMs` pair is supplied. */
+ windowMinutes?: number;
+ /** Absolute range as epoch milliseconds. The BFF renders these in OAP-server
+ * time — send ms, never pre-formatted strings. */
+ startMs?: number;
+ endMs?: number;
+}
+
+export interface AiConversationsResponse {
+ generatedAt: number;
+ query: AiConversationsQueryRequest;
+ /** The `limit` actually sent. OAP gives no signal whether it truncated
+ * anything, so the page states the rule with this number instead of
+ * pretending to know. */
+ limit: number;
+ /** Newest last activity first. */
+ rows: AiConversationRow[];
+ reachable: boolean;
+ error?: string;
+}
+
+/** The first two keys of every `asz.view` document, and the media types the OAP
+ * route names it by. A reader that meets another major version stops there —
+ * a 1.x adds keys and never removes one; a 2.0 may do either. */
+export const ASZ_VIEW_FORMAT = 'asz.view';
+export const ASZ_VIEW_MAJOR_VERSION = 1;
+export const ASZ_VIEW_JSON_MEDIA_TYPE = 'application/vnd.skywalking.asz.view+json';
+export const ASZ_VIEW_YAML_MEDIA_TYPE = 'application/vnd.skywalking.asz.view+yaml';
+
+/** What the view route answers with instead of a document: an RFC 9457 problem,
+ * `application/problem+json`. 400 without a service, 404 when the service
+ * stores no round of the conversation, 500 on a storage failure. */
+export interface AiConversationProblem {
+ type: string;
+ title: string;
+ status: number;
+ detail: string;
+}
+
+/** Response headers the BFF adds to a relayed document. OAP streams the
+ * document without knowing its length; the BFF holds it and is the first to
+ * know, so the page can show how much of the whole has arrived. */
+export const AI_CONVERSATION_DOCUMENT_BYTES_HEADER = 'x-horizon-document-bytes';
+export const AI_CONVERSATION_DOCUMENT_SUMMARY_HEADER = 'x-horizon-document-summary';
+
+/** The counts of the document's `summary`, as the BFF read them off its head. */
+export interface AiConversationDocumentSummary {
+ talks: number;
+ steps: number;
+ streams: number;
+ segments: number;
+ rounds: number;
+ unresolved: number;
+}
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 8d12b95c..3c8f6e9c 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -173,6 +173,21 @@ export type {
EventsResponse,
} from './events.js';
export type { OapInfo, OapCapabilities, OapBackend } from './oap-info.js';
+export type {
+ AiConversationRow,
+ AiConversationsQueryRequest,
+ AiConversationsResponse,
+ AiConversationProblem,
+ AiConversationDocumentSummary,
+} from './ai-conversation.js';
+export {
+ ASZ_VIEW_FORMAT,
+ ASZ_VIEW_MAJOR_VERSION,
+ ASZ_VIEW_JSON_MEDIA_TYPE,
+ ASZ_VIEW_YAML_MEDIA_TYPE,
+ AI_CONVERSATION_DOCUMENT_BYTES_HEADER,
+ AI_CONVERSATION_DOCUMENT_SUMMARY_HEADER,
+} from './ai-conversation.js';
export type {
RecordsTTL,
MetricsTTL,
diff --git a/packages/api-client/src/layer-menu.test.ts b/packages/api-client/src/layer-menu.test.ts
index 695f14ea..b1c7005e 100644
--- a/packages/api-client/src/layer-menu.test.ts
+++ b/packages/api-client/src/layer-menu.test.ts
@@ -77,6 +77,7 @@ const EVERY_ROW: LayerMenuInput = {
logs: true,
browserErrors: true,
podLogs: true,
+ aiConversations: true,
traceProfiling: true,
ebpfProfiling: true,
networkProfiling: true,
@@ -90,7 +91,11 @@ const EVERY_ROW: LayerMenuInput = {
describe('DEFAULT_LAYER_ROW_ORDER', () => {
it('is exactly the order the sidebar rendered before centralisation', () => {
- expect([...DEFAULT_LAYER_ROW_ORDER]).toEqual(SIDEBAR_ORDER_BEFORE);
+ // Rows added since the transcript (`conversations`) are allowed; what must
+ // not move is the relative order of every row the transcript knew.
+ expect(DEFAULT_LAYER_ROW_ORDER.filter((p) => SIDEBAR_ORDER_BEFORE.includes(p))).toEqual(
+ SIDEBAR_ORDER_BEFORE,
+ );
});
it('lists every row once — no duplicate, no omission', () => {
diff --git a/packages/api-client/src/layer-menu.ts b/packages/api-client/src/layer-menu.ts
index 7932ae02..89a89cbc 100644
--- a/packages/api-client/src/layer-menu.ts
+++ b/packages/api-client/src/layer-menu.ts
@@ -41,7 +41,7 @@ import type { LayerCaps, LayerSlots } from './menu.js';
/** The `Icon` names a layer row can carry. Spelled out rather than typed
* as `string` so the sidebar's `IconName` prop still checks — the UI's
* icon union is the real constraint, and this must stay a subset of it. */
-export type LayerMenuRowIcon = 'svc' | 'prof' | 'ep' | 'topo' | 'trace' | 'log' | 'web' | 'flame' | 'set';
+export type LayerMenuRowIcon = 'svc' | 'prof' | 'ep' | 'topo' | 'trace' | 'log' | 'web' | 'flame' | 'set' | 'ai';
/** One navigable row under a layer. */
export interface LayerMenuRow {
@@ -145,6 +145,7 @@ export const DEFAULT_LAYER_ROW_ORDER = [
'logs',
'browser-errors',
'pod-logs',
+ 'conversations',
'trace-profiling',
'ebpf-profiling',
'network-profiling',
@@ -177,6 +178,7 @@ const ROW_DEFS: Record<BuiltInLayerRow, { icon: LayerMenuRowIcon; when: (L: Laye
logs: { icon: 'log', when: (L) => Boolean(L.caps?.logs) },
'browser-errors': { icon: 'web', when: (L) => Boolean(L.caps?.browserErrors) },
'pod-logs': { icon: 'log', when: (L) => Boolean(L.caps?.podLogs) },
+ conversations: { icon: 'ai', when: (L) => Boolean(L.caps?.aiConversations) },
'trace-profiling': { icon: 'flame', when: (L) => Boolean(L.caps?.traceProfiling) },
'ebpf-profiling': { icon: 'flame', when: (L) => Boolean(L.caps?.ebpfProfiling) },
'network-profiling': { icon: 'prof', when: (L) => Boolean(L.caps?.networkProfiling) },
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index b242013b..f2ca59b3 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -87,6 +87,9 @@ export interface LayerCaps {
* fetched on demand from the K8s API (never persisted). Gates the
* per-layer "Pod Logs" tab; only K8s-deployed layers set it. */
podLogs?: boolean;
+ /** Per-layer "Conversations" tab — the AI agent conversations the AI
+ * Sessionizer pushed under the layer (`AI_AGENT`). */
+ aiConversations?: boolean;
events?: boolean;
}
diff --git a/packages/api-client/src/oap-info.ts b/packages/api-client/src/oap-info.ts
index bbda8143..14cc67aa 100644
--- a/packages/api-client/src/oap-info.ts
+++ b/packages/api-client/src/oap-info.ts
@@ -76,6 +76,10 @@ export interface OapCapabilities {
* OAP accepts the filter and ignores it, so a client that offers the
* search anyway returns an unfiltered page that reads as a match. */
logKeywords: boolean;
+ /** `Query.listConversations` — AI agent conversations (OAP 11.1.0+). An OAP
+ * without it lists no `AI_AGENT` layer either, so the sidebar row disappears
+ * on its own; this is what lets a bookmarked conversation page say why. */
+ aiConversations: boolean;
}
/** Storage backend the connected OAP runs on, as far as the BFF can
diff --git a/packages/conversation-view/README.md b/packages/conversation-view/README.md
new file mode 100644
index 00000000..9ab89d1e
--- /dev/null
+++ b/packages/conversation-view/README.md
@@ -0,0 +1,62 @@
+# @skywalking-horizon-ui/conversation-view
+
+Renders one AI agent conversation — an `asz.view` 1.0 document, as the [SkyWalking AI Sessionizer](https://github.com/apache/skywalking-ai-sessionizer) assembles it and the SkyWalking OAP reproduces it — into a host element: the transcript of talks, the flow timeline of one execution stream, and an inspector that answers where every step came from.
+
+No framework, no network. The host parses and version-checks the document and hands it in; the renderer fetches nothing. Every colour and font is a Horizon design token, so the host chooses the theme by setting `data-theme` on `<html>`.
+
+Horizon's conversation page wraps this in one small Vue component. The AI Sessionizer's own viewer embeds the same build, made from a pinned Horizon commit, so the two hosts draw a conversation identically.
+
+## Using it
+
+```ts
+import { mountConversationView, isSupportedDocument, makeFormatter } from '@skywalking-horizon-ui/conversation-view';
+import '@skywalking-horizon-ui/conversation-view/style.css';
+
+const doc = await (await fetch(url)).json();
+if (!isSupportedDocument(doc)) throw new Error(`unknown document ${doc.format} ${doc.version}`);
+
+const view = mountConversationView(document.getElementById('conversation')!, {
+ document: doc,
+ formatter: makeFormatter(navigator.language), // or EN_US_FORMATTER for one rendering everywhere
+ state: { talk: params.get('talk') ?? undefined, step: params.get('step') ?? undefined },
+ onStateChange: (s) => history.replaceState(null, '', `?${new URLSearchParams(s)}`),
+});
+view.destroy();
+```
+
+| Option | Meaning |
+|---|---|
+| `document` | The parsed `asz.view` document. |
+| `strings` | Any of the renderer's texts, translated. English is the default for every key. Node kinds, relation types, qualities and states from the document are never translated. |
+| `formatter` | How times, counts and spans are written. `EN_US_FORMATTER` is the Sessionizer's fixed rendering; `makeFormatter(locale)` follows a locale. |
+| `glossary` | The Sessionizer's glossary, when the host has one. Puts a `?` beside every name it can explain. |
+| `loadRecord` | Reads one landed record by `{seq, row}`. Offered in the Evidence tab only when present. |
+| `state` / `onStateChange` | The reader's position — talk, step, stream — for a host to keep in its URL and restore. |
+
+## Embedding outside Horizon
+
+The package also builds a **host shell** for a page that does not have Horizon's tokens and fonts of its own:
+
+```
+dist/host-shell/horizon-theme.css the design tokens, the five themes, the base body rules, the font faces
+dist/host-shell/fonts/*.woff2 Inter and JetBrains Mono (latin, variable weight; SIL OFL 1.1)
+dist/host-shell/themes.json [{ id, label, appearance }] for every theme
+```
+
+Four steps:
+
+1. Load `host-shell/horizon-theme.css`, then `style.css`.
+2. Pick a theme id from `themes.json`.
+3. Set `<html data-theme="<id>" data-appearance="<appearance>">`.
+4. Mount the renderer.
+
+Horizon never loads the host shell; it has the same tokens and fonts already.
+
+## Building
+
+```sh
+pnpm --filter @skywalking-horizon-ui/conversation-view build # dist/conversation-view.{js,css}, index.d.ts, host-shell/
+pnpm --filter @skywalking-horizon-ui/conversation-view test:unit
+```
+
+The output is deliberately readable — not minified, ASF header kept — because another project commits it after building it from a pinned commit of this repository.
diff --git a/packages/conversation-view/package.json b/packages/conversation-view/package.json
new file mode 100644
index 00000000..a352c99c
--- /dev/null
+++ b/packages/conversation-view/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "@skywalking-horizon-ui/conversation-view",
+ "version": "1.1.0-dev",
+ "license": "Apache-2.0",
+ "private": true,
+ "type": "module",
+ "description": "Renders one asz.view document — an AI agent conversation as the SkyWalking AI Sessionizer assembles it — without a framework, on Horizon's design tokens.",
+ "main": "./dist/conversation-view.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/conversation-view.js"
+ },
+ "./style.css": "./dist/conversation-view.css",
+ "./host-shell/*": "./dist/host-shell/*"
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "vite build && tsc -p tsconfig.build.json && node scripts/build-host-shell.mjs",
+ "clean": "rm -rf dist",
+ "prepare": "vite build && tsc -p tsconfig.build.json && node scripts/build-host-shell.mjs",
+ "type-check": "tsc --noEmit",
+ "test:unit": "vitest run --environment jsdom --pool=threads",
+ "test:unit:watch": "vitest --environment jsdom --pool=threads"
+ },
+ "devDependencies": {
+ "@fontsource-variable/inter": "^5.3.0",
+ "@fontsource-variable/jetbrains-mono": "^5.3.0",
+ "@skywalking-horizon-ui/design-tokens": "workspace:*",
+ "@types/node": "^22.20.1",
+ "jsdom": "^25.0.1",
+ "typescript": "~5.6.3",
+ "vite": "^6.4.3",
+ "vitest": "^4.1.10"
+ }
+}
diff --git a/packages/conversation-view/scripts/build-host-shell.mjs b/packages/conversation-view/scripts/build-host-shell.mjs
new file mode 100644
index 00000000..9a2252ec
--- /dev/null
+++ b/packages/conversation-view/scripts/build-host-shell.mjs
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The host shell: what a page OUTSIDE Horizon needs to run the renderer on a
+ * Horizon theme, offline. Horizon itself never imports it — it has the tokens
+ * and the fonts already. The AI Sessionizer's viewer embeds it after building
+ * this package from a pinned Horizon commit.
+ *
+ * dist/host-shell/horizon-theme.css tokens.css + themes.css + the base body rules + @font-face
+ * dist/host-shell/fonts/*.woff2 Inter and JetBrains Mono, latin, variable weight (OFL-1.1)
+ * dist/host-shell/themes.json every theme's id, label and appearance
+ *
+ * A host picks a theme by id from themes.json and sets `data-theme` and
+ * `data-appearance` on <html>; the stylesheet does the rest.
+ */
+
+import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { THEMES } from '@skywalking-horizon-ui/design-tokens';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const pkg = resolve(here, '..');
+/** Resolves through each package's `exports` map, the same way a bundler would. */
+const file = (specifier) => fileURLToPath(import.meta.resolve(specifier));
+
+const tokens = readFileSync(file('@skywalking-horizon-ui/design-tokens/tokens.css'), 'utf8');
+const themes = readFileSync(file('@skywalking-horizon-ui/design-tokens/themes.css'), 'utf8');
+
+const out = resolve(pkg, 'dist/host-shell');
+mkdirSync(join(out, 'fonts'), { recursive: true });
+
+// The two variable faces Horizon ships (@fontsource-variable, latin subset).
+// The face names match Horizon's own stylesheet so `--sw-font: "Inter"` and
+// `--sw-mono` resolve to the same files here.
+const fonts = [
+ { pkg: '@fontsource-variable/inter', file: 'inter-latin-wght-normal.woff2', family: 'Inter', mono: false },
+ { pkg: '@fontsource-variable/jetbrains-mono', file: 'jetbrains-mono-latin-wght-normal.woff2', family: 'JetBrains Mono', mono: true },
+];
+let faces = '';
+for (const f of fonts) {
+ // The package's entry is its index.css; the woff2 files sit beside it.
+ copyFileSync(join(dirname(file(f.pkg)), 'files', f.file), join(out, 'fonts', f.file));
+ faces += `@font-face {\n font-family: "${f.family}";\n font-style: normal;\n font-display: swap;\n font-weight: 100 900;\n src: url(./fonts/${f.file}) format("woff2-variations");\n}\n`;
+}
+
+const header = `/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/* Generated by @skywalking-horizon-ui/conversation-view scripts/build-host-shell.mjs — do not edit.
+ * Horizon's design tokens (tokens.css), its five themes (themes.css), the base
+ * body rules a Horizon page starts from, and the two bundled font faces
+ * (Inter, JetBrains Mono — SIL Open Font License 1.1). */
+`;
+
+const base = `
+html, body { margin: 0; height: 100%; }
+body { background: var(--sw-bg-0); color: var(--sw-fg-0); font-family: var(--sw-sans); font-size: 12px; line-height: 1.45; font-feature-settings: "cv11", "ss01", "tnum"; }
+*, *::before, *::after { box-sizing: border-box; }
+`;
+
+writeFileSync(join(out, 'horizon-theme.css'), `${header}\n${faces}\n${stripHeader(tokens)}\n${stripHeader(themes)}\n${base}`);
+writeFileSync(join(out, 'themes.json'), `${JSON.stringify(THEMES, null, 2)}\n`);
+console.log(`host shell → ${out} (${THEMES.length} themes, ${fonts.length} fonts)`);
+
+/** The tokens files carry their own ASF header; one header per output file. */
+function stripHeader(css) {
+ return css.replace(/^\s*\/\*[\s\S]*?\*\/\s*/, '');
+}
diff --git a/packages/conversation-view/src/dom.ts b/packages/conversation-view/src/dom.ts
new file mode 100644
index 00000000..78f5090e
--- /dev/null
+++ b/packages/conversation-view/src/dom.ts
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Escape text for an HTML template. Every string from the document goes
+ * through this: titles, tool names and results are user data. */
+export function esc(v: unknown): string {
+ return String(v ?? '').replace(/[&<>"']/g, (c) => (
+ { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } as Record<string, string>
+ )[c]!);
+}
+
+/** A CSS attribute-selector value. `CSS.escape` where the platform has it. */
+export function cssEscape(v: string): string {
+ const g = globalThis as { CSS?: { escape?: (s: string) => string } };
+ if (g.CSS?.escape) return g.CSS.escape(v);
+ return v.replace(/["\\]/g, '\\$&');
+}
+
+/** Scroll where the element supports it. jsdom and old engines have no
+ * `scrollTo` on elements, and a render must not die on a scroll. */
+export function scrollTo(el: Element | null, opts: ScrollToOptions): void {
+ if (!el) return;
+ const target = el as Element & { scrollTo?: (o: ScrollToOptions) => void };
+ if (typeof target.scrollTo === 'function') target.scrollTo(opts);
+ else {
+ if (opts.left !== undefined) target.scrollLeft = opts.left;
+ if (opts.top !== undefined) target.scrollTop = opts.top;
+ }
+}
+
+export function reducedMotion(): boolean {
+ const g = globalThis as { matchMedia?: (q: string) => { matches: boolean } };
+ return typeof g.matchMedia === 'function' && g.matchMedia('(prefers-reduced-motion: reduce)').matches;
+}
+
+/** Next paint, or soon, where there is no paint. */
+export function nextFrame(fn: () => void): void {
+ const g = globalThis as { requestAnimationFrame?: (cb: () => void) => number };
+ if (typeof g.requestAnimationFrame === 'function') g.requestAnimationFrame(fn);
+ else setTimeout(fn, 0);
+}
+
+/** The first element under `root` matching the selector, typed. */
+export function q<T extends Element = HTMLElement>(root: ParentNode, selector: string): T {
+ const el = root.querySelector<T>(selector);
+ if (!el) throw new Error(`conversation-view: missing element ${selector}`);
+ return el;
+}
diff --git a/packages/conversation-view/src/format.ts b/packages/conversation-view/src/format.ts
new file mode 100644
index 00000000..3bccf9bc
--- /dev/null
+++ b/packages/conversation-view/src/format.ts
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * How moments and quantities are written. Each host keeps its own rule: the
+ * Sessionizer's page fixes `en-US` with a 24-hour clock so two people looking
+ * at one conversation read the same text; Horizon renders in the browser's
+ * locale like the rest of its screens. Both hand the renderer one of these.
+ */
+export interface TimeFormatter {
+ /** `12:34:56`, or a placeholder for an unobserved time (0). */
+ time(ms: number): string;
+ /** `12:34` for a tick on a whole minute. */
+ timeShort(ms: number): string;
+ /** `Sep 05`, empty for an unobserved time. */
+ day(ms: number): string;
+ /** `Sep 05 12:34:56` for a full stamp. */
+ dateTime(ms: number): string;
+ /** A count with grouping separators. */
+ number(n: number): string;
+ /** An elapsed span, or the word for an unavailable one. */
+ duration(ms: number): string;
+}
+
+const UNOBSERVED = '--:--:--';
+
+export function makeFormatter(locale: string, unavailable = 'unavailable'): TimeFormatter {
+ const time = new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' });
+ const short = new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
+ const day = new Intl.DateTimeFormat(locale, { month: 'short', day: '2-digit' });
+ const num = new Intl.NumberFormat(locale);
+ return {
+ time: (ms) => (ms ? time.format(new Date(ms)) : UNOBSERVED),
+ timeShort: (ms) => (ms ? short.format(new Date(ms)) : '--:--'),
+ day: (ms) => (ms ? day.format(new Date(ms)) : ''),
+ dateTime: (ms) => (ms ? `${day.format(new Date(ms))} ${time.format(new Date(ms))}` : UNOBSERVED),
+ number: (n) => num.format(n ?? 0),
+ duration: (ms) => {
+ if (!Number.isFinite(ms)) return unavailable;
+ if (ms < 1000) return `${Math.round(ms)} ms`;
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)} min`;
+ if (ms < 86_400_000) return `${(ms / 3_600_000).toFixed(1)} h`;
+ return `${(ms / 86_400_000).toFixed(ms < 864_000_000 ? 1 : 0)} d`;
+ },
+ };
+}
+
+/** The Sessionizer's own rule: one rendering for every reader. */
+export const EN_US_FORMATTER: TimeFormatter = makeFormatter('en-US');
diff --git a/packages/conversation-view/src/index.ts b/packages/conversation-view/src/index.ts
new file mode 100644
index 00000000..c06d021c
--- /dev/null
+++ b/packages/conversation-view/src/index.ts
@@ -0,0 +1,580 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Renders one AI agent conversation — an `asz.view` 1.0 document as the
+ * SkyWalking AI Sessionizer assembles it and the OAP reproduces it — into a
+ * host element: the transcript of talks, the flow timeline of one stream, and
+ * an inspector that answers where each step came from.
+ *
+ * No framework and no network. The host parses and version-checks the
+ * document, hands it in with the words it wants shown and the way it writes
+ * times, and reads the reader's position back to keep in its URL. Horizon
+ * wraps this in one small Vue component; the Sessionizer's own viewer embeds
+ * the same build. Every colour and font is a Horizon design token, so the host
+ * decides the theme by setting `data-theme` on `<html>`.
+ */
+
+import './styles.css';
+import { esc, cssEscape, reducedMotion, scrollTo } from './dom.js';
+import { EN_US_FORMATTER, type TimeFormatter } from './format.js';
+import { ConversationModel, type Step } from './model.js';
+import { ENGLISH, fill, type ViewStrings } from './strings.js';
+import type { AszRef, AszViewDocument, Glossary, LandedRecord } from './types.js';
+import { drawOverview, drawStatus, drawStreamTabs, drawTalkList, setOverviewOpen, setProblemsOpen } from './view/chrome.js';
+import type { InspectorTab, ViewContext, ViewState } from './view/context.js';
+import { drawInspector } from './view/inspector.js';
+import { setupPanels } from './view/panels.js';
+import { bindTimeline, centerX, drawTimeline, folderBottom, paintViewport } from './view/timeline.js';
+import { bindTranscript, drawTranscript } from './view/transcript.js';
+
+export type { TimeFormatter } from './format.js';
+export { makeFormatter, EN_US_FORMATTER } from './format.js';
+export type { ViewStrings } from './strings.js';
+export { ENGLISH } from './strings.js';
+export * from './types.js';
+
+/** The reader's position, as a host keeps it in its URL. */
+export interface PublicState {
+ talk?: string;
+ step?: string;
+ stream?: string;
+}
+
+export interface MountOptions {
+ document: AszViewDocument;
+ strings?: Partial<ViewStrings>;
+ formatter?: TimeFormatter;
+ glossary?: Glossary | null;
+ /** Reads one landed record by address. Offered in the Evidence tab only when
+ * present: the Sessionizer's viewer has such an endpoint, the OAP has none. */
+ loadRecord?: (ref: AszRef) => Promise<LandedRecord>;
+ state?: PublicState;
+ onStateChange?: (state: PublicState) => void;
+}
+
+export interface ConversationView {
+ destroy(): void;
+ /** Move the reader: a host applies a URL change it did not originate. */
+ setState(state: PublicState): void;
+ getState(): PublicState;
+}
+
+function skeleton(s: ViewStrings): string {
+ return `
+ <div class="acv-strip"><div class="acv-status"></div>
+ <div class="acv-problems acv-explain" hidden>
+ <div class="acv-explain-head"><span class="acv-explain-title acv-problems-title"></span><button type="button" class="acv-explain-close acv-problems-close" aria-label="${esc(s.close)}">×</button></div>
+ <div class="acv-problems-body"></div>
+ </div>
+ <div class="acv-overview" id="acv-overview" hidden>
+ <section class="acv-summary" aria-label="${esc(s.overview)}"></section>
+ <div class="acv-picker">
+ <div class="acv-picker-head"><span class="acv-kicker">${esc(s.talkList)}</span><input class="acv-talk-filter" type="search" placeholder="${esc(s.filterTalks)}" aria-label="${esc(s.filterTalks)}"></div>
+ <div class="acv-talk-list"></div>
+ </div>
+ </div>
+ </div>
+ <section class="acv-workbench">
+ <div class="acv-main">
+ <section class="acv-transcript">
+ <div class="acv-transcript-head">
+ <div class="acv-heading"><h2>${esc(s.talk)}</h2><span class="acv-talk-caption"></span></div>
+ <div class="acv-stream-tabs" role="tablist"></div>
+ </div>
+ <div class="acv-transcript-list"></div>
+ </section>
+ <div class="acv-splitter acv-split-inspector" role="separator" aria-orientation="vertical" tabindex="0"></div>
+ <aside class="acv-inspector">
+ <div class="acv-inspector-head">
+ <div class="acv-heading"><span class="acv-kicker">${esc(s.inspector)}</span><h2 class="acv-inspector-title">—</h2></div>
+ <div class="acv-inspector-meta"></div>
+ </div>
+ <div class="acv-tablist" role="tablist">
+ <button class="acv-tab" type="button" role="tab" data-tab="details" aria-selected="true">${esc(s.details)}</button>
+ <button class="acv-tab" type="button" role="tab" data-tab="relations" aria-selected="false">${esc(s.relations)}</button>
+ <button class="acv-tab" type="button" role="tab" data-tab="evidence" aria-selected="false">${esc(s.evidence)}</button>
+ </div>
+ <div class="acv-inspector-body" role="tabpanel"></div>
+ </aside>
+ </div>
+ <div class="acv-splitter acv-split-dock" role="separator" aria-orientation="horizontal" tabindex="0"></div>
+ <section class="acv-dock">
+ <div class="acv-toolbar">
+ <div class="acv-control-group">
+ <div class="acv-tl-heading"><strong>${esc(s.flowTimeline)} <button type="button" class="acv-q acv-dock-help-btn" aria-label="${esc(s.timelineHelp)}" title="${esc(s.timelineHelp)}">?</button></strong><small class="acv-tl-scope">—</small></div>
+ <button type="button" class="acv-tl-back acv-btn" hidden>${esc(s.parentTimeline)}</button>
+ <label class="acv-checkbox" title="${esc(s.fadeUnrelatedTitle)}"><input class="acv-focus" type="checkbox" checked> ${esc(s.fadeUnrelated)}</label>
+ </div>
+ <div class="acv-control-group">
+ <label class="acv-control-label">${esc(s.zoom)}</label>
+ <input class="acv-zoom" type="range" min="10" max="200" step="10" value="100" aria-label="${esc(s.zoom)}">
+ <output class="acv-zoom-out">100%</output>
+ <button type="button" class="acv-fit acv-btn" title="${esc(s.centerSelectedTitle)}">${esc(s.centerSelected)}</button>
+ </div>
+ <div class="acv-dock-help acv-explain" hidden>
+ <div class="acv-explain-head"><span class="acv-explain-title">${esc(s.flowTimeline)}</span><button type="button" class="acv-explain-close acv-dock-help-close" aria-label="${esc(s.close)}">×</button></div>
+ <dl class="acv-explain-rows">
+ <dt>${esc(s.helpWhat)}</dt><dd>${esc(s.helpWhatText)}</dd>
+ <dt>${esc(s.helpHeading)}</dt><dd>${esc(s.helpHeadingText)}</dd>
+ <dt>${esc(s.helpAxis)}</dt><dd>${esc(s.helpAxisText)}</dd>
+ <dt>${esc(s.helpLinks)}</dt><dd>${esc(s.helpLinksText)}</dd>
+ <dt>${esc(s.helpFade)}</dt><dd>${esc(s.helpFadeText)}</dd>
+ </dl>
+ </div>
+ </div>
+ <div class="acv-tl-panel"><div class="acv-tl-layout">
+ <div class="acv-lane-labels"></div>
+ <div class="acv-tl-scroll"><div class="acv-tl-canvas"><div class="acv-tl-static"></div><div class="acv-tl-items"></div></div></div>
+ </div></div>
+ <div class="acv-legend">
+ <span class="acv-legend-item acv-kind-user"><span class="acv-legend-swatch"></span>${esc(s.legendInput)}</span>
+ <span class="acv-legend-item acv-kind-assistant"><span class="acv-legend-swatch"></span>${esc(s.legendResponse)}</span>
+ <span class="acv-legend-item acv-kind-model"><span class="acv-legend-swatch"></span>${esc(s.legendModel)}</span>
+ <span class="acv-legend-item acv-kind-tool"><span class="acv-legend-swatch"></span>${esc(s.legendTool)}</span>
+ <span class="acv-legend-item acv-kind-agent"><span class="acv-legend-swatch"></span>${esc(s.legendAgent)}</span>
+ <span class="acv-legend-item acv-kind-instruction"><span class="acv-legend-swatch"></span>${esc(s.legendContext)}</span>
+ <span class="acv-legend-item" style="color:var(--acv-owns)"><span class="acv-legend-line"></span>${esc(s.legendOwns)}</span>
+ <span class="acv-legend-item" style="color:var(--acv-edge)"><span class="acv-legend-line"></span>${esc(s.legendExact)}</span>
+ <span class="acv-legend-item"><span class="acv-legend-line dashed"></span>${esc(s.legendInferred)}</span>
+ </div>
+ </section>
+ </section>
+ <div class="acv-sr" aria-live="polite"></div>`;
+}
+
+export function mountConversationView(host: HTMLElement, opts: MountOptions): ConversationView {
+ const s: ViewStrings = { ...ENGLISH, ...(opts.strings ?? {}) };
+ const model = new ConversationModel(opts.document);
+ const root = host;
+ root.classList.add('acv');
+ root.innerHTML = skeleton(s);
+
+ const state: ViewState = {
+ talk: null,
+ stream: null,
+ sel: null,
+ folder: null,
+ rawRef: null,
+ zoom: 1,
+ focus: true,
+ picked: false,
+ tab: 'details',
+ navStack: [],
+ openTalks: new Set(),
+ autoOpen: new Set(),
+ explain: null,
+ overviewOpen: false,
+ };
+
+ // A host applying a position (from its URL) must hear one change, the final
+ // one: opening the talk on the way to the step emits the talk's default step
+ // first, and a host that wrote that to its URL would then read it back and
+ // move the reader off the step it was asked for.
+ let muted = 0;
+ const emit = (): void => {
+ if (muted === 0) opts.onStateChange?.(getState());
+ };
+ const getState = (): PublicState => ({
+ ...(state.talk ? { talk: state.talk.id } : {}),
+ ...(state.sel ? { step: state.sel } : {}),
+ ...(state.stream ? { stream: state.stream } : {}),
+ });
+
+ const ctx: ViewContext = {
+ root,
+ model,
+ s,
+ f: opts.formatter ?? EN_US_FORMATTER,
+ glossary: opts.glossary ?? null,
+ loadRecord: opts.loadRecord,
+ state,
+ q<T extends Element = HTMLElement>(selector: string): T {
+ const el = root.querySelector<T>(selector);
+ if (!el) throw new Error(`conversation-view: missing element ${selector}`);
+ return el;
+ },
+ select,
+ selectFolder,
+ clearSelection,
+ clearFolder,
+ diveIn,
+ goBack,
+ goToOpener,
+ openTalk,
+ focusTalk,
+ switchStream,
+ showTab,
+ renderAll,
+ drawTimeline: () => drawTimeline(ctx),
+ drawTranscript: () => drawTranscript(ctx),
+ drawInspector: () => drawInspector(ctx),
+ drawStreamTabs: () => drawStreamTabs(ctx),
+ drawTalkList: () => drawTalkList(ctx),
+ centerOn,
+ announce: (text) => {
+ ctx.q('.acv-sr').textContent = text;
+ },
+ };
+
+ function renderAll(recenter = false): void {
+ drawStreamTabs(ctx);
+ drawTimeline(ctx);
+ drawTranscript(ctx);
+ drawInspector(ctx);
+ if (recenter) centerOn(state.sel, 'auto');
+ emit();
+ }
+
+ function openTalk(id: string, keepStack = false): void {
+ const t = model.talkById.get(id);
+ if (!t) return;
+ state.talk = t;
+ if (!keepStack) state.navStack = [];
+ state.stream = t.stream;
+ state.picked = false;
+ state.folder = null;
+ state.openTalks.clear();
+ state.autoOpen.clear();
+ const ev = model.stepsOfTalk(t.id);
+ state.sel = ev[0]?.id ?? model.steps(t.stream)[0]?.id ?? null;
+ drawTalkList(ctx);
+ renderAll(true);
+ }
+
+ /** Point the flow timeline at a talk without moving the reader off it: the
+ * input card points at the step that opened it, the reply card at the
+ * answer that closed it. */
+ function focusTalk(id: string, end?: string): void {
+ const t = model.talkById.get(id);
+ if (!t) return;
+ state.talk = t;
+ state.stream = t.stream;
+ state.picked = false;
+ const steps = model.stepsOfTalk(id);
+ let op: Step | undefined;
+ if (end === 'reply') {
+ const replies = steps.filter((e) => e.kind === 'message.assistant' || e.kind === 'agent.output');
+ op = replies[replies.length - 1] ?? steps[steps.length - 1];
+ } else {
+ op = steps.find((e) => e.kind === 'message.external') ?? steps[0];
+ }
+ if (op) state.sel = op.id;
+ drawStreamTabs(ctx);
+ drawTimeline(ctx);
+ drawTranscript(ctx);
+ drawInspector(ctx);
+ if (op) centerOn(op.id, reducedMotion() ? 'auto' : 'smooth', false);
+ emit();
+ }
+
+ function switchStream(name: string): void {
+ if (state.stream === name) return;
+ if (!model.streamByName.has(name)) return;
+ state.stream = name;
+ const ev = model.steps(name);
+ if (ev.length && !ev.some((e) => e.id === state.sel)) state.sel = ev[0]!.id;
+ // The talk belongs to the stream being read: the selected step's, else the
+ // stream's first. Keeping the parent's would caption a child with the
+ // parent's counts.
+ const talkId = model.step(state.sel)?.talk ?? model.talksOf(name)[0]?.id ?? null;
+ state.talk = talkId ? (model.talkById.get(talkId) ?? null) : null;
+ renderAll(true);
+ }
+
+ function select(id: string, center: boolean, alsoTranscript = true): void {
+ const e = model.step(id);
+ if (!e) return;
+ if (e.stream !== state.stream) {
+ switchStream(e.stream);
+ }
+ if (e.talk && state.talk?.id !== e.talk) {
+ const t = model.talkById.get(e.talk);
+ if (t) state.talk = t;
+ }
+ state.sel = id;
+ state.rawRef = null;
+ state.folder = null;
+ state.picked = true;
+ drawStreamTabs(ctx);
+ drawTimeline(ctx);
+ drawTranscript(ctx);
+ drawInspector(ctx);
+ if (center) centerOn(id, reducedMotion() ? 'auto' : 'smooth', alsoTranscript);
+ ctx.announce(fill(s.selected, { what: `${e.kind}${e.name ? ` ${e.name}` : ''}` }));
+ emit();
+ }
+
+ /** Selecting a nested stream is not entering it: it shows which step opened
+ * it and offers the way in. Only Dive in leaves the stream being read. */
+ function selectFolder(name: string): void {
+ if (!state.stream) return;
+ const f = model.foldersFor(state.stream).find((x) => x.stream.name === name);
+ if (!f) return;
+ state.folder = name;
+ state.picked = true;
+ state.sel = f.from.id;
+ state.rawRef = null;
+ drawTimeline(ctx);
+ drawTranscript(ctx);
+ drawInspector(ctx);
+ centerOn(f.from.id, reducedMotion() ? 'auto' : 'smooth');
+ const bottom = folderBottom(ctx, name);
+ if (bottom !== null) {
+ const sc = ctx.q('.acv-tl-scroll');
+ const want = Math.max(0, bottom - sc.clientHeight + 12);
+ if (want > sc.scrollTop) {
+ sc.scrollTop = want;
+ ctx.q('.acv-lane-labels').scrollTop = want;
+ }
+ }
+ ctx.announce(fill(s.selectedNestedStream, { name: f.stream.label || name, kind: f.from.kind }));
+ emit();
+ }
+
+ function clearSelection(): void {
+ if (!state.folder && !state.sel && !state.picked) return;
+ state.folder = null;
+ state.sel = null;
+ state.picked = false;
+ state.rawRef = null;
+ drawTimeline(ctx);
+ drawTranscript(ctx);
+ drawInspector(ctx);
+ ctx.announce(s.selectionCleared);
+ emit();
+ }
+
+ function clearFolder(): void {
+ if (!state.folder) return;
+ state.folder = null;
+ state.picked = false;
+ drawTimeline(ctx);
+ drawTranscript(ctx);
+ drawInspector(ctx);
+ }
+
+ function diveIn(): void {
+ const name = state.folder;
+ if (!name || !state.stream) return;
+ state.navStack.push({ stream: state.stream, sel: state.sel, talk: state.talk?.id ?? null });
+ state.folder = null;
+ switchStream(name);
+ }
+
+ function goBack(): void {
+ const t = state.navStack.pop();
+ if (!t) return;
+ state.stream = t.stream;
+ state.sel = t.sel;
+ state.talk = t.talk ? (model.talkById.get(t.talk) ?? null) : null;
+ renderAll(true);
+ }
+
+ /** Back to the exact step in the parent stream that opened the one being
+ * read. The talk is what the transcript shows, so the opener's talk is
+ * opened first; switching stream alone would land on a stream whose talk
+ * is not the one in focus. */
+ function goToOpener(stream: string, step: string, talk: string | null): void {
+ muted++;
+ try {
+ if (talk && state.talk?.id !== talk) openTalk(talk, true);
+ else if (stream !== state.stream) switchStream(stream);
+ state.folder = null;
+ select(step, true);
+ } finally {
+ muted--;
+ }
+ emit();
+ }
+
+ function showTab(tab: InspectorTab): void {
+ state.tab = tab;
+ root.querySelectorAll<HTMLElement>('[data-tab]').forEach((t) => t.setAttribute('aria-selected', String(t.dataset.tab === tab)));
+ drawInspector(ctx);
+ }
+
+ function centerOn(id: string | null, behavior: ScrollBehavior, alsoTranscript = true): void {
+ if (!id) return;
+ const sc = ctx.q('.acv-tl-scroll');
+ const cx = centerX(ctx, id);
+ if (cx !== null) {
+ scrollTo(sc, { left: Math.max(0, cx - sc.clientWidth / 2), behavior });
+ // Programmatic scrolling repaints the window; a scroll event may not
+ // follow in every engine, and the clip has to be there to be seen.
+ paintViewport(ctx);
+ }
+ if (!alsoTranscript) return;
+ const list = ctx.q('.acv-transcript-list');
+ const card = list.querySelector<HTMLElement>(`[data-card="${cssEscape(id)}"], [data-step="${cssEscape(id)}"]`);
+ if (card) {
+ // Measured through rectangles, not offsetTop: the card sits inside the
+ // fold's own box, so offsetTop answers against that box.
+ const top = card.getBoundingClientRect().top - list.getBoundingClientRect().top + list.scrollTop;
+ scrollTo(list, { top: Math.max(0, top - list.clientHeight / 2 + card.offsetHeight / 2), behavior });
+ }
+ }
+
+ // ---- wiring ----
+ drawStatus(ctx);
+ drawOverview(ctx);
+ bindTranscript(ctx);
+ bindTimeline(ctx);
+ const teardownPanels = setupPanels(ctx);
+
+ root.querySelectorAll<HTMLElement>('[data-tab]').forEach((b) => (b.onclick = () => showTab(b.dataset.tab as InspectorTab)));
+ ctx.q('.acv-dock-help-btn').onclick = () => {
+ const box = ctx.q('.acv-dock-help');
+ box.hidden = !box.hidden;
+ };
+ ctx.q('.acv-dock-help-close').onclick = () => {
+ ctx.q('.acv-dock-help').hidden = true;
+ };
+ ctx.q<HTMLInputElement>('.acv-focus').onchange = (e) => {
+ state.focus = (e.target as HTMLInputElement).checked;
+ drawTimeline(ctx);
+ drawTranscript(ctx);
+ };
+ ctx.q<HTMLInputElement>('.acv-zoom').oninput = (e) => {
+ const v = Number((e.target as HTMLInputElement).value);
+ state.zoom = v / 100;
+ ctx.q('.acv-zoom-out').textContent = `${v}%`;
+ drawTimeline(ctx);
+ };
+ ctx.q('.acv-fit').onclick = () => centerOn(state.sel, 'smooth');
+ ctx.q<HTMLButtonElement>('.acv-tl-back').onclick = () => {
+ const b = ctx.q<HTMLButtonElement>('.acv-tl-back');
+ if (b.dataset.upStream && b.dataset.upStep) goToOpener(b.dataset.upStream, b.dataset.upStep, b.dataset.upTalk || null);
+ else goBack();
+ };
+ ctx.q<HTMLInputElement>('.acv-talk-filter').oninput = () => drawTalkList(ctx);
+ ctx.q('.acv-talk-list').addEventListener('click', (ev) => {
+ const b = (ev.target as HTMLElement).closest<HTMLElement>('[data-talk-pick]');
+ if (!b) return;
+ openTalk(b.dataset.talkPick!);
+ setOverviewOpen(ctx, false);
+ });
+ // The floating overview closes on a click anywhere else, as a menu does.
+ const onPointerDown = (e: PointerEvent): void => {
+ const t = e.target as HTMLElement | null;
+ if (state.overviewOpen && !(t && (t.closest('.acv-overview') || t.closest('.acv-overview-toggle')))) {
+ setOverviewOpen(ctx, false);
+ }
+ const help = ctx.q('.acv-dock-help');
+ if (!help.hidden && !(t && (t.closest('.acv-dock-help') || t.closest('.acv-dock-help-btn')))) help.hidden = true;
+ const problems = ctx.q('.acv-problems');
+ if (!problems.hidden && !(t && (t.closest('.acv-problems') || t.closest('.acv-integrity')))) setProblemsOpen(ctx, false);
+ };
+ document.addEventListener('pointerdown', onPointerDown);
+
+ // Keys, only while the reader is in this component or nowhere in particular:
+ // a host's own inputs and shortcuts keep theirs.
+ const onKey = (e: KeyboardEvent): void => {
+ const active = document.activeElement as HTMLElement | null;
+ if (e.key === 'Escape' && state.overviewOpen) {
+ setOverviewOpen(ctx, false);
+ return;
+ }
+ if (e.key === 'Escape' && !ctx.q('.acv-dock-help').hidden) {
+ ctx.q('.acv-dock-help').hidden = true;
+ return;
+ }
+ if (e.key === 'Escape' && !ctx.q('.acv-problems').hidden) {
+ setProblemsOpen(ctx, false);
+ return;
+ }
+ if (active && ['INPUT', 'TEXTAREA', 'SELECT'].includes(active.tagName)) return;
+ if (active && active !== document.body && !root.contains(active)) return;
+ if (e.key === 'Escape') {
+ clearSelection();
+ return;
+ }
+ if (e.key === 'Enter' && state.folder) {
+ if (active && active.closest('button, a, select, [tabindex]') && active !== root) return;
+ e.preventDefault();
+ diveIn();
+ return;
+ }
+ const k = e.key.toLowerCase();
+ if (k !== 'j' && k !== 'k') return;
+ if (!state.stream) return;
+ const ev = model.steps(state.stream);
+ const i = ev.findIndex((x) => x.id === state.sel);
+ if (i < 0) return;
+ const next = ev[k === 'j' ? Math.min(ev.length - 1, i + 1) : Math.max(0, i - 1)];
+ if (next) select(next.id, true);
+ };
+ document.addEventListener('keydown', onKey);
+
+ // ---- first position ----
+ function setState(pub: PublicState): void {
+ muted++;
+ try {
+ applyState(pub);
+ } finally {
+ muted--;
+ }
+ emit();
+ }
+
+ function applyState(pub: PublicState): void {
+ const step = pub.step ? model.step(pub.step) : null;
+ if (step) {
+ const talk = step.talk ?? model.talksOf(step.stream)[0]?.id;
+ if (talk && state.talk?.id !== talk) openTalk(talk, true);
+ else if (step.stream !== state.stream) switchStream(step.stream);
+ select(step.id, true);
+ return;
+ }
+ if (pub.talk && model.talkById.has(pub.talk)) {
+ openTalk(pub.talk);
+ if (pub.stream && pub.stream !== state.stream) switchStream(pub.stream);
+ return;
+ }
+ if (pub.stream && model.streamByName.has(pub.stream)) {
+ const first = model.talksOf(pub.stream)[0];
+ if (first) openTalk(first.id);
+ else {
+ state.stream = pub.stream;
+ state.sel = model.steps(pub.stream)[0]?.id ?? null;
+ renderAll(true);
+ }
+ return;
+ }
+ const first = model.firstTalk();
+ if (first) openTalk(first.id);
+ else {
+ // A document with no talk at all still has its streams and its loose steps.
+ state.stream = model.doc.streams.find((x) => x.role === 'main')?.name ?? model.doc.streams[0]?.name ?? null;
+ state.sel = state.stream ? (model.steps(state.stream)[0]?.id ?? null) : null;
+ renderAll(true);
+ }
+ }
+ setState(opts.state ?? {});
+
+ return {
+ destroy(): void {
+ document.removeEventListener('keydown', onKey);
+ document.removeEventListener('pointerdown', onPointerDown);
+ teardownPanels();
+ root.innerHTML = '';
+ root.classList.remove('acv');
+ },
+ setState,
+ getState,
+ };
+}
diff --git a/packages/conversation-view/src/model.ts b/packages/conversation-view/src/model.ts
new file mode 100644
index 00000000..e923e4bc
--- /dev/null
+++ b/packages/conversation-view/src/model.ts
@@ -0,0 +1,327 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The document, indexed once. The Sessionizer's page fetched an overview, a
+ * flow and one talk tree at a time; here the whole conversation is in hand, so
+ * every lookup a view needs is a map built at load: nodes by id, steps per
+ * stream in reading order, talks per stream, the streams a talk starts.
+ *
+ * Measured on the largest real conversation (922 talks, 49,827 steps, a 61 MB
+ * document): parsing takes 139 ms and walking every node 5 ms, so nothing here
+ * is lazy.
+ */
+
+import type { AszEdge, AszNode, AszRef, AszSegment, AszStream, AszViewDocument, AszUsage, AszDrop } from './types.js';
+import { CONTAINER_KINDS, kindOf, type KindType, type Track } from './vocabulary.js';
+
+/** One step as the transcript and the timeline draw it: the node flattened,
+ * with the run and the talk it belongs to and where it sits in reading order. */
+export interface Step {
+ id: string;
+ kind: string;
+ at: number;
+ parent?: string;
+ stream: string;
+ run: string | null;
+ /** The talk containing it; null for a step outside any talk (`loose`). */
+ talk: string | null;
+ track: Track;
+ type: KindType;
+ name?: string;
+ text?: string;
+ state?: string;
+ bytes?: number;
+ result?: string;
+ resultState?: string;
+ resultBytes?: number;
+ durationMs?: number;
+ durationHow?: string;
+ reqToRes?: number;
+ reqToResJoin?: string;
+ failed?: boolean;
+ ref?: AszRef;
+ refs?: AszRef[];
+ attrs?: Record<string, unknown>;
+ usage?: AszUsage;
+ flags?: string[];
+ dropped?: AszDrop[];
+ edges: AszEdge[];
+ /** Position in the flattened document, for ties and for nodes without a ref. */
+ order: number;
+ depth: number;
+}
+
+export interface TalkRow {
+ id: string;
+ stream: string;
+ label: string;
+ reply: string;
+ runs: number;
+ steps: number;
+ tools: number;
+ from: number;
+ to: number;
+ child: boolean;
+ segment: string;
+}
+
+/** A child stream a talk reaches: a call started it, or a notification said it
+ * finished. `back` marks the latter. */
+export interface Folder {
+ stream: AszStream;
+ from: Step;
+ quality: string;
+ /** How many streams the same call started — a pool. */
+ candidates: number;
+ back: boolean;
+}
+
+/** Reading order is the order the records were written — the landed position,
+ * not the timestamp. A runtime stamps a whole batch at once and not always in
+ * write order; in one corpus a talk's opening human message carried a later
+ * millisecond than the six injections behind it, so sorting by time buried
+ * the line that starts the talk six cards down. */
+export function readOrder(a: Step, b: Step): number {
+ if (a.ref && b.ref) return a.ref.seq - b.ref.seq || a.ref.row - b.ref.row;
+ return (a.at || 0) - (b.at || 0) || a.order - b.order;
+}
+
+export function timeOrder(a: Step, b: Step): number {
+ return (a.at || 0) - (b.at || 0) || a.order - b.order;
+}
+
+export class ConversationModel {
+ readonly talks: TalkRow[] = [];
+ readonly talkById = new Map<string, TalkRow>();
+ readonly streamByName = new Map<string, AszStream>();
+ readonly streamById = new Map<string, AszStream>();
+ readonly segmentById = new Map<string, AszSegment>();
+ readonly stepById = new Map<string, Step>();
+ /** Every step of a stream, in reading order. */
+ private readonly stepsByStream = new Map<string, Step[]>();
+ /** The same steps, in time order, for the axis. */
+ private readonly flowByStream = new Map<string, Step[]>();
+ private readonly talksByStream = new Map<string, TalkRow[]>();
+ private readonly stepsByTalk = new Map<string, Step[]>();
+ /** Steps outside any talk, per stream. */
+ private readonly looseByStream = new Map<string, Step[]>();
+ private readonly folderCache = new Map<string, Folder[]>();
+ /** Steps that start or report a stream, keyed by the stream id. */
+ private readonly openersByStreamId = new Map<string, Step[]>();
+
+ constructor(readonly doc: AszViewDocument) {
+ for (const s of doc.streams) {
+ this.streamByName.set(s.name, s);
+ this.streamById.set(s.id, s);
+ }
+ for (const seg of doc.segments) this.segmentById.set(seg.id, seg);
+ let order = 0;
+ const flatten = (root: AszNode, talkId: string | null, streamFallback: string): void => {
+ const walk = (n: AszNode, run: string | null, depth: number): void => {
+ if (n.kind === 'run') run = n.id;
+ if (!CONTAINER_KINDS.has(n.kind)) {
+ const meta = kindOf(n.kind);
+ const step: Step = {
+ id: n.id,
+ kind: n.kind,
+ at: n.at,
+ parent: n.parent,
+ stream: n.stream || streamFallback,
+ run,
+ talk: talkId,
+ track: meta.track,
+ type: meta.type,
+ name: n.name,
+ text: n.text,
+ state: n.state,
+ bytes: n.bytes,
+ result: n.result,
+ resultState: n.result_state,
+ resultBytes: n.result_bytes,
+ durationMs: n.duration_ms,
+ durationHow: n.duration_measured_by,
+ reqToRes: n.request_to_result_ms,
+ reqToResJoin: n.request_to_result_join,
+ failed: n.failed,
+ ref: n.ref,
+ refs: n.refs,
+ attrs: n.attrs,
+ usage: n.usage,
+ flags: n.flags,
+ dropped: n.dropped,
+ edges: n.edges ?? [],
+ order: order++,
+ depth,
+ };
+ this.stepById.set(step.id, step);
+ push(this.stepsByStream, step.stream, step);
+ if (talkId) push(this.stepsByTalk, talkId, step);
+ else push(this.looseByStream, step.stream, step);
+ }
+ for (const k of n.children ?? []) walk(k, run, depth + 1);
+ };
+ walk(root, null, 0);
+ };
+ for (const t of doc.talks) {
+ const row: TalkRow = {
+ id: t.id,
+ stream: t.stream ?? 'main',
+ label: t.label ?? '',
+ reply: t.reply ?? '',
+ runs: t.runs ?? 0,
+ steps: t.steps ?? 0,
+ tools: t.tools ?? 0,
+ from: t.from ?? 0,
+ to: t.to ?? 0,
+ child: !!t.child,
+ segment: t.segment ?? '',
+ };
+ this.talks.push(row);
+ this.talkById.set(row.id, row);
+ push(this.talksByStream, row.stream, row);
+ flatten(t, t.id, row.stream);
+ }
+ for (const n of doc.loose ?? []) flatten(n, null, n.stream ?? 'main');
+ for (const [name, steps] of this.stepsByStream) {
+ steps.sort(readOrder);
+ this.flowByStream.set(name, [...steps].sort(timeOrder));
+ }
+ for (const steps of this.stepsByTalk.values()) steps.sort(readOrder);
+ for (const steps of this.looseByStream.values()) steps.sort(readOrder);
+ for (const step of this.stepById.values()) {
+ for (const e of step.edges) {
+ if (e.dir === 'out' && (e.type === 'starts' || e.type === 'reports') && this.streamById.has(e.other)) {
+ push(this.openersByStreamId, e.other, step);
+ }
+ }
+ }
+ }
+
+ get title(): string {
+ return this.doc.summary.title;
+ }
+
+ /** Steps of a stream in reading order. */
+ steps(stream: string): Step[] {
+ return this.stepsByStream.get(stream) ?? [];
+ }
+
+ /** Steps of a stream in time order, for the axis. */
+ flow(stream: string): Step[] {
+ return this.flowByStream.get(stream) ?? [];
+ }
+
+ talksOf(stream: string): TalkRow[] {
+ return this.talksByStream.get(stream) ?? [];
+ }
+
+ stepsOfTalk(talk: string): Step[] {
+ return this.stepsByTalk.get(talk) ?? [];
+ }
+
+ loose(stream: string): Step[] {
+ return this.looseByStream.get(stream) ?? [];
+ }
+
+ step(id: string | null | undefined): Step | null {
+ return id ? (this.stepById.get(id) ?? null) : null;
+ }
+
+ /** The streams the assembler could tie to the start or the end of a stream
+ * from THIS stream's steps. Several candidates for one call are all kept:
+ * the assembler did not choose, and neither does a view. */
+ foldersFor(stream: string): Folder[] {
+ const cached = this.folderCache.get(stream);
+ if (cached) return cached;
+ const seen = new Map<string, Folder>();
+ const steps = this.steps(stream);
+ for (const e of steps) {
+ for (const g of e.edges) {
+ if (g.dir !== 'out' || (g.type !== 'starts' && g.type !== 'reports')) continue;
+ const st = this.streamById.get(g.other);
+ if (!st) continue;
+ const prev = seen.get(st.name);
+ // A call is the better anchor than the notification that it finished.
+ if (prev && !prev.back) continue;
+ seen.set(st.name, { stream: st, from: e, quality: g.quality, candidates: 0, back: g.type === 'reports' });
+ }
+ }
+ const calls = new Map<string, number>();
+ for (const e of steps) {
+ for (const g of e.edges) if (g.type === 'starts' && g.dir === 'out') calls.set(e.id, (calls.get(e.id) ?? 0) + 1);
+ }
+ const out = [...seen.values()];
+ for (const f of out) f.candidates = calls.get(f.from.id) ?? 1;
+ this.folderCache.set(stream, out);
+ return out;
+ }
+
+ /** The step that opened a stream, from the stream's own `opened_by`. */
+ openerOf(streamName: string): { step: Step; talk: string | null; quality: string } | null {
+ const st = this.streamByName.get(streamName);
+ const o = st?.opened_by?.[0];
+ if (!o?.step) return null;
+ const step = this.step(o.step);
+ if (!step) return null;
+ return { step, talk: o.talk || step.talk, quality: o.quality };
+ }
+
+ /** The streams a stream opened, for the "opens in turn" list. */
+ openedBy(streamName: string): AszStream[] {
+ return this.doc.streams.filter((x) => (x.opened_by ?? []).some((o) => o.stream === streamName));
+ }
+
+ /** The talk a step belongs to, walking up if the step is a container id. */
+ talkOf(id: string): string | null {
+ const s = this.step(id);
+ if (s) return s.talk;
+ return this.talkById.has(id) ? id : null;
+ }
+
+ /** The first talk of the main stream, else the first talk at all. */
+ firstTalk(): TalkRow | null {
+ return this.talks.find((t) => !t.child) ?? this.talks[0] ?? null;
+ }
+
+ /** Everything that has to do with a selection: the step, what a relation
+ * joins it to, its owner, and what it owns. Null means nothing is picked and
+ * nothing fades. */
+ relatedTo(sel: string | null, folder: string | null, streamSteps: Step[]): Set<string> | null {
+ if (!sel) return null;
+ if (folder) return new Set([sel]);
+ const here = this.step(sel);
+ if (!here) return null;
+ const set = new Set<string>([sel]);
+ for (const r of here.edges) set.add(r.other);
+ if (here.parent) set.add(here.parent);
+ for (const e of streamSteps) if (e.parent === sel) set.add(e.id);
+ return set;
+ }
+}
+
+function push<K, V>(m: Map<K, V[]>, k: K, v: V): void {
+ const list = m.get(k);
+ if (list) list.push(v);
+ else m.set(k, [v]);
+}
+
+/** A value from a node's `attrs`, as a string. */
+export function attrString(attrs: Record<string, unknown> | undefined, key: string): string {
+ const v = attrs?.[key];
+ return typeof v === 'string' ? v : '';
+}
diff --git a/packages/conversation-view/src/strings.ts b/packages/conversation-view/src/strings.ts
new file mode 100644
index 00000000..261a4557
--- /dev/null
+++ b/packages/conversation-view/src/strings.ts
@@ -0,0 +1,448 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Every piece of text the renderer shows, in English. A host that translates
+ * its chrome passes its own values; a host that does not gets these. Keys are
+ * stable identifiers, not the English itself, so a host's catalog survives a
+ * rewording here.
+ *
+ * Node kinds, relation types, qualities and states from the document are NOT
+ * here: they are the model's vocabulary and are shown as the document says
+ * them, the same in every language, so a reader can grep them in the
+ * Sessionizer's docs.
+ */
+export interface ViewStrings {
+ // Status strip and overview
+ round: string;
+ segments: string;
+ /** The overview cell's heading — a noun, where `segments` is the unit behind a count. */
+ segmentList: string;
+ streams: string;
+ talks: string;
+ /** The overview's talk list heading — a noun, where `talks` is the unit behind a count. */
+ talkList: string;
+ span: string;
+ unresolved: string;
+ overview: string;
+ session: string;
+ steps: string;
+ runs: string;
+ childStreams: string;
+ childStreamsNote: string;
+ relations: string;
+ activityWindows: string;
+ idle: string;
+ integrityVerified: string;
+ integrityIncomplete: string;
+ integrityMismatch: string;
+ problems: string;
+ roundsVerified: string;
+ filesListed: string;
+ filterTalks: string;
+ noTalkMatches: string;
+ untitled: string;
+ // Transcript
+ talk: string;
+ mainStreamCaption: string;
+ childStreamCaption: string;
+ tools: string;
+ externalInput: string;
+ noOpeningLine: string;
+ delegatedWork: string;
+ showWork: string;
+ hideWork: string;
+ quiet: string;
+ mainAgent: string;
+ childAgent: string;
+ agent: string;
+ response: string;
+ streamOutput: string;
+ clientMadeMessage: string;
+ contextPutIn: string;
+ agentCallToChild: string;
+ notificationFromChild: string;
+ launchAcknowledged: string;
+ modelCall: string;
+ reasoning: string;
+ toolWord: string;
+ callWord: string;
+ turn: string;
+ input: string;
+ result: string;
+ failed: string;
+ toReturn: string;
+ openStream: string;
+ independentStream: string;
+ openedFrom: string;
+ noOpenerRecorded: string;
+ backToOpener: string;
+ noTalksInStream: string;
+ /** The transcript when the document folded nothing at all. */
+ nothingFolded: string;
+ outsideAnyTalk: string;
+ outsideAnyTalkNote: string;
+ childStreamsPick: string;
+ childStreamSingular: string;
+ childStreamPlural: string;
+ backToStream: string;
+ showThisTalk: string;
+ showThisAnswer: string;
+ locateInTimeline: string;
+ // Timeline
+ flowTimeline: string;
+ timelineHelp: string;
+ parentTimeline: string;
+ fadeUnrelated: string;
+ fadeUnrelatedTitle: string;
+ zoom: string;
+ centerSelected: string;
+ centerSelectedTitle: string;
+ events: string;
+ nested: string;
+ laneInput: string;
+ laneResponses: string;
+ laneContext: string;
+ laneModel: string;
+ laneTools: string;
+ laneAgents: string;
+ laneNotices: string;
+ laneNested: string;
+ legendInput: string;
+ legendResponse: string;
+ legendModel: string;
+ legendTool: string;
+ legendAgent: string;
+ legendContext: string;
+ legendOwns: string;
+ legendExact: string;
+ legendInferred: string;
+ agentsCreated: string;
+ poolTitle: string;
+ reportedHere: string;
+ clickToSeeOpener: string;
+ helpWhat: string;
+ helpWhatText: string;
+ helpHeading: string;
+ helpHeadingText: string;
+ helpAxis: string;
+ helpAxisText: string;
+ helpLinks: string;
+ helpLinksText: string;
+ helpFade: string;
+ helpFadeText: string;
+ // Inspector
+ inspector: string;
+ details: string;
+ evidence: string;
+ selectAStep: string;
+ nestedStream: string;
+ stream: string;
+ role: string;
+ openedBy: string;
+ reportedHereBy: string;
+ note: string;
+ reportedNote: string;
+ joinQuality: string;
+ openedAt: string;
+ itsAnswer: string;
+ itsAnswerNote: string;
+ opensInTurn: string;
+ opensNothing: string;
+ andMore: string;
+ poolWarning: string;
+ poolWarningText: string;
+ diveIn: string;
+ diveHint: string;
+ nodeKind: string;
+ lane: string;
+ segment: string;
+ run: string;
+ directParent: string;
+ observedAt: string;
+ requestToResult: string;
+ duration: string;
+ durationUnavailable: string;
+ requestToResultWhat: string;
+ requestToResultText: string;
+ name: string;
+ failedField: string;
+ yes: string;
+ no: string;
+ contentState: string;
+ contentBytes: string;
+ tokens: string;
+ tokensText: string;
+ contentUnavailable: string;
+ contentUnavailableText: string;
+ oneChildAgent: string;
+ childAgents: string;
+ openRelations: string;
+ backToParentStream: string;
+ noRelation: string;
+ agentsCreatedByCall: string;
+ agentsCreatedText: string;
+ outgoing: string;
+ incoming: string;
+ joinedOn: string;
+ namedFromJournal: string;
+ otherEndOutside: string;
+ derivedByAssembly: string;
+ landedPositions: string;
+ request: string;
+ part: string;
+ record: string;
+ clippedText: string;
+ fullTextNote: string;
+ loadFullRecord: string;
+ loadingRecord: string;
+ recordFailed: string;
+ theLandedRecord: string;
+ dropped: string;
+ flags: string;
+ unavailable: string;
+ // Glossary explanations
+ vocabulary: string;
+ modelOwnWord: string;
+ aszTerm: string;
+ runtimeWord: string;
+ runtimeNoWord: string;
+ whereToLook: string;
+ nowhereInSource: string;
+ readCarefully: string;
+ dialect: string;
+ landedRecordField: string;
+ aszField: string;
+ whatItIs: string;
+ notApplicable: string;
+ close: string;
+ whatDoesMean: string;
+ // Announcements
+ selected: string;
+ selectedNestedStream: string;
+ selectionCleared: string;
+}
+
+export const ENGLISH: ViewStrings = {
+ round: 'round',
+ segments: 'segments',
+ segmentList: 'Segments',
+ streams: 'streams',
+ talks: 'talks',
+ talkList: 'Talks',
+ span: 'span',
+ unresolved: 'unresolved',
+ overview: 'Overview',
+ session: 'Session',
+ steps: 'Steps',
+ runs: 'runs',
+ childStreams: 'Child streams',
+ childStreamsNote: 'subagents, each its own context',
+ relations: 'Relations',
+ activityWindows: 'activity windows',
+ idle: 'idle',
+ integrityVerified: 'verified',
+ integrityIncomplete: 'incomplete',
+ integrityMismatch: 'mismatch',
+ problems: 'problems',
+ roundsVerified: 'rounds verified',
+ filesListed: 'files',
+ filterTalks: 'filter',
+ noTalkMatches: 'No talk matches.',
+ untitled: '(untitled)',
+ talk: 'Talk',
+ mainStreamCaption: 'main stream, child work folded',
+ childStreamCaption: 'child stream',
+ tools: 'tools',
+ externalInput: 'External · Input',
+ noOpeningLine: 'no opening line',
+ delegatedWork: 'delegated work',
+ showWork: 'show what the agent did',
+ hideWork: 'hide what the agent did',
+ quiet: 'quiet',
+ mainAgent: 'Main agent',
+ childAgent: 'Child agent',
+ agent: 'Agent',
+ response: 'Response',
+ streamOutput: 'Stream output',
+ clientMadeMessage: 'Client-made message · not from the provider',
+ contextPutIn: 'Context put in',
+ agentCallToChild: 'Agent call',
+ notificationFromChild: 'Runtime notification',
+ launchAcknowledged: 'Launch acknowledged',
+ modelCall: 'Model call',
+ reasoning: 'Reasoning',
+ toolWord: 'Tool',
+ callWord: 'call',
+ turn: 'turn',
+ input: 'input',
+ result: 'result',
+ failed: 'failed',
+ toReturn: 'to return',
+ openStream: 'Open',
+ independentStream: 'Independent stream context. Its messages are not merged into the parent.',
+ openedFrom: 'Opened from',
+ noOpenerRecorded: 'No relation records what opened it.',
+ backToOpener: '← Back to the step that opened this',
+ noTalksInStream: 'No talks in this stream.',
+ nothingFolded: 'Nothing of this conversation could be folded, so there is nothing to read. The problems listed above say why.',
+ outsideAnyTalk: 'Outside any talk',
+ outsideAnyTalkNote: 'Steps the fold could not place under a talk — a child’s output whose stream opened no talk, for instance. The document holds them so nothing is lost.',
+ childStreamsPick: 'child streams…',
+ childStreamSingular: 'child stream',
+ childStreamPlural: 'child streams',
+ backToStream: 'Back to',
+ showThisTalk: 'Show this talk in the flow timeline',
+ showThisAnswer: 'Show this talk’s answer in the flow timeline',
+ locateInTimeline: 'Locate in the flow timeline',
+ flowTimeline: 'Flow timeline',
+ timelineHelp: 'What is the flow timeline?',
+ parentTimeline: '← Parent timeline',
+ fadeUnrelated: 'Fade unrelated',
+ fadeUnrelatedTitle: 'Fade the steps that the selected one does not touch',
+ zoom: 'Zoom',
+ centerSelected: 'Center selected',
+ centerSelectedTitle: 'Scroll the timeline to the selected step',
+ events: 'events',
+ nested: 'nested',
+ laneInput: 'External input',
+ laneResponses: 'Responses',
+ laneContext: 'Context put in',
+ laneModel: 'Model calls',
+ laneTools: 'Tools',
+ laneAgents: 'Agent activity',
+ laneNotices: 'Runtime notices',
+ laneNested: 'Nested streams',
+ legendInput: 'External input',
+ legendResponse: 'Agent response',
+ legendModel: 'Model call',
+ legendTool: 'Tool',
+ legendAgent: 'Agent / nested stream',
+ legendContext: 'Context / annotation',
+ legendOwns: 'Emitted by (ownership)',
+ legendExact: 'Exact join',
+ legendInferred: 'Strong inference',
+ agentsCreated: 'agents created',
+ poolTitle: 'This call started {n} child agents at nearly the same moment. Click, then pick one in the inspector.',
+ reportedHere: 'It reported finishing in this talk.',
+ clickToSeeOpener: 'Click to see what opened it.',
+ helpWhat: 'what it shows',
+ helpWhatText:
+ 'One talk, on a time axis. Each row is a lane: the messages, the model calls, the tools it ran, its agent activity, and the nested streams it started. The line above the lanes is the whole conversation, one cell per segment.',
+ helpHeading: 'the heading',
+ helpHeadingText: 'Which stream the lanes belong to, how many steps are in this talk, and how many nested streams it touches.',
+ helpAxis: 'the axis',
+ helpAxisText: 'Busy stretches take the width. A long pause is cut to a short gap marked with how long it was, so a talk with an hour of waiting still reads.',
+ helpLinks: 'links',
+ helpLinksText:
+ 'The lines between lanes, drawn for the step you select. A solid line is an exact join, a dashed one was inferred, and a faint curve is ownership: the model call that produced a step.',
+ helpFade: 'fade unrelated',
+ helpFadeText: 'After you click a step, fade everything it does not touch.',
+ inspector: 'Inspector',
+ details: 'Details',
+ evidence: 'Evidence',
+ selectAStep: 'Select a step.',
+ nestedStream: 'nested stream',
+ stream: 'Stream',
+ role: 'Role',
+ openedBy: 'Opened by',
+ reportedHereBy: 'Reported here by',
+ note: 'Note',
+ reportedNote: 'this talk hears the ending. The call that started it is in an earlier talk, because a launch and its completion land in different turns.',
+ joinQuality: 'Join quality',
+ openedAt: 'Opened at',
+ itsAnswer: 'Its answer',
+ itsAnswerNote: 'not in this stream. A child’s output belongs to the child, so the parent holds a boundary that refers to it, never a copy. Dive in to read it.',
+ opensInTurn: 'Opens in turn',
+ opensNothing: 'nothing — it opened no stream of its own',
+ andMore: 'and {n} more',
+ poolWarning: 'this call started {n} streams',
+ poolWarningText: 'A workflow call starts a run, and the run starts a pool of agents. They are all its children, not competing guesses at one child.',
+ diveIn: 'Dive in →',
+ diveHint: 'Enter to dive in · Escape to clear',
+ nodeKind: 'Node kind',
+ lane: 'Lane',
+ segment: 'Segment',
+ run: 'Run',
+ directParent: 'Direct parent',
+ observedAt: 'Observed at',
+ requestToResult: 'Request to result',
+ duration: 'Duration',
+ durationUnavailable: 'unavailable — the runtime does not report how long this ran',
+ requestToResultWhat: 'What "request to result" is',
+ requestToResultText:
+ 'The time between the record carrying the request and the record carrying the result. An exact identifier ties those two records together, so the interval belongs to this call. It is not how long the tool ran, and the runtime does not report that.',
+ name: 'Name',
+ failedField: 'Failed',
+ yes: 'yes',
+ no: 'no',
+ contentState: 'Content state',
+ contentBytes: 'Content bytes',
+ tokens: 'Tokens',
+ tokensText: 'in {in} · out {out} · cache read {cacheRead} · cache write {cacheWrite}',
+ contentUnavailable: 'Content {state}',
+ contentUnavailableText: 'The source did not supply readable content for this step. It is reported as it was found, not filled in.',
+ oneChildAgent: '1 child agent',
+ childAgents: '{n} child agents',
+ openRelations: 'open the Relations tab →',
+ backToParentStream: '← Back to parent stream',
+ noRelation: 'No relation touches this step.',
+ agentsCreatedByCall: '{n} agents created by this call',
+ agentsCreatedText: 'A workflow call starts a run, and the run starts a pool of agents. Every one of them is a child of this call, not a competing guess at one child. Pick the one to read.',
+ outgoing: 'outgoing',
+ incoming: 'incoming',
+ joinedOn: 'joined on',
+ namedFromJournal: 'named from its run journal',
+ otherEndOutside: 'the other end is outside this stream',
+ derivedByAssembly: 'This step was derived by assembly. It cites no landed record.',
+ landedPositions: 'landed positions',
+ request: 'request',
+ part: 'part',
+ record: 'record',
+ clippedText: 'the text as the document carries it',
+ fullTextNote: 'clipped: {shown} of {total} bytes',
+ loadFullRecord: 'Load the landed record',
+ loadingRecord: 'reading…',
+ recordFailed: 'The record could not be read.',
+ theLandedRecord: 'the landed record',
+ dropped: 'dropped',
+ flags: 'flags',
+ unavailable: 'unavailable',
+ vocabulary: 'vocabulary',
+ modelOwnWord: 'the model’s own word',
+ aszTerm: 'asz term',
+ runtimeWord: 'runtime word',
+ runtimeNoWord: 'none — this is derived here, the runtime has no word for it',
+ whereToLook: 'where to look',
+ nowhereInSource: 'nowhere in the source; it is produced by assembly',
+ readCarefully: 'read it carefully',
+ dialect: 'dialect',
+ landedRecordField: 'a field of the landed record',
+ aszField: 'asz field',
+ whatItIs: 'what it is',
+ notApplicable: 'not applicable — this describes the landed record, not the source',
+ close: 'Close',
+ whatDoesMean: 'What does {key} mean?',
+ selected: 'Selected {what}',
+ selectedNestedStream: 'Selected nested stream {name}, opened by {kind}. Press Enter to dive in, Escape to clear.',
+ selectionCleared: 'Selection cleared.',
+};
+
+/** `{name}` placeholders, filled from `vars`. */
+export function fill(template: string, vars: Record<string, string | number>): string {
+ return template.replace(/\{(\w+)\}/g, (_, k: string) => (k in vars ? String(vars[k]) : `{${k}}`));
+}
diff --git a/packages/conversation-view/src/styles.css b/packages/conversation-view/src/styles.css
new file mode 100644
index 00000000..6c91b170
--- /dev/null
+++ b/packages/conversation-view/src/styles.css
@@ -0,0 +1,382 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * Every colour, radius and font here is a Horizon design token (`--sw-*`), so
+ * the renderer follows whichever theme the host has set on <html data-theme>,
+ * the light one included. The only variables this file owns are the node-kind
+ * colours, one table, so a collision on some theme is fixed in one place.
+ * Soft fills and shadows are mixed from tokens rather than fixed rgba blacks:
+ * a fixed dark overlay is the one thing a light theme cannot survive.
+ */
+.acv {
+ --acv-bg: var(--sw-bg-0);
+ --acv-surface: var(--sw-bg-1);
+ --acv-surface-2: var(--sw-bg-2);
+ --acv-surface-3: var(--sw-bg-3);
+ --acv-line: var(--sw-line-2);
+ --acv-line-soft: var(--sw-line);
+ --acv-text: var(--sw-fg-0);
+ --acv-muted: var(--sw-fg-1);
+ --acv-faint: var(--sw-fg-2);
+ --acv-fainter: var(--sw-fg-3);
+ --acv-focus: var(--sw-accent);
+ --acv-radius: var(--sw-radius, 6px);
+ --acv-sans: var(--sw-font, var(--sw-sans, system-ui, sans-serif));
+ --acv-mono: var(--sw-mono, ui-monospace, monospace);
+
+ /* Kinds are categories, not statuses, so they take the theme's categorical
+ series rather than its ok/warn/err; only an error keeps the error colour. */
+ --acv-kind-user: var(--sw-series-1);
+ --acv-kind-assistant: var(--sw-series-2);
+ --acv-kind-subagent-response: var(--sw-series-5);
+ --acv-kind-model: var(--sw-series-3);
+ --acv-kind-tool: var(--sw-series-4);
+ --acv-kind-agent: var(--sw-series-5);
+ --acv-kind-instruction: var(--sw-series-6);
+ --acv-kind-annotation: var(--sw-series-7);
+ --acv-kind-error: var(--sw-err);
+ --acv-edge: var(--sw-fg-2);
+ --acv-owns: var(--sw-fg-3);
+
+ --acv-inspector: 350px;
+ --acv-dock: 47%;
+
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ color: var(--acv-text);
+ background: var(--acv-bg);
+ font-family: var(--acv-sans);
+ font-size: 12px;
+ line-height: 1.45;
+}
+.acv *,
+.acv *::before,
+.acv *::after { box-sizing: border-box; }
+.acv button,
+.acv input,
+.acv select { font: inherit; color: inherit; }
+.acv button:focus-visible,
+.acv input:focus-visible,
+.acv select:focus-visible,
+.acv [tabindex]:focus-visible { outline: 2px solid var(--acv-focus); outline-offset: 2px; }
+.acv code,
+.acv pre,
+.acv .mono { font-family: var(--acv-mono); }
+.acv-kicker { color: var(--acv-faint); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; }
+.acv-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
+.acv-empty { padding: 30px; color: var(--acv-muted); text-align: center; }
+.acv-faint { color: var(--acv-faint); }
+.acv-warn { color: var(--sw-warn); }
+.acv-btn { min-height: 29px; padding: 4px 10px; border: 1px solid var(--acv-line); border-radius: var(--acv-radius); background: var(--acv-surface-2); color: var(--acv-muted); cursor: pointer; font-size: 12px; }
+.acv-btn:hover { color: var(--acv-text); background: var(--acv-surface-3); }
+.acv-btn.primary { border-color: var(--acv-kind-agent); background: color-mix(in srgb, var(--acv-kind-agent) 14%, transparent); color: var(--acv-text); }
+.acv-btn.primary:hover { background: color-mix(in srgb, var(--acv-kind-agent) 24%, transparent); }
+.acv-linkish { padding: 0; border: 0; background: transparent; color: var(--sw-info); cursor: pointer; text-align: left; font-size: 12px; }
+.acv-linkish:hover { text-decoration: underline; }
+.acv-hint { color: var(--acv-faint); font-size: 10px; }
+
+/* ---------- status strip ---------- */
+.acv-strip { position: relative; display: flex; align-items: center; justify-content: flex-end; gap: 12px; min-height: 40px; padding: 5px 12px; border-bottom: 1px solid var(--acv-line); background: var(--acv-surface); }
+.acv-status { display: flex; align-items: center; justify-content: flex-end; gap: 9px; min-width: 0; color: var(--acv-muted); font-size: 11px; white-space: nowrap; flex-wrap: wrap; }
+.acv-status strong { color: var(--acv-text); font-weight: 650; }
+.acv-badge { display: inline-flex; align-items: center; gap: 6px; min-height: 26px; padding: 3px 9px; border: 1px solid var(--acv-line); border-radius: 999px; background: var(--acv-surface-2); color: var(--acv-muted); font-size: 11px; }
+.acv-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
+.acv-integrity { cursor: default; }
+.acv-integrity.is-verified { color: var(--sw-ok); border-color: color-mix(in srgb, var(--sw-ok) 40%, transparent); background: color-mix(in srgb, var(--sw-ok) 10%, transparent); }
+.acv-integrity.is-incomplete { color: var(--sw-warn); border-color: color-mix(in srgb, var(--sw-warn) 40%, transparent); background: color-mix(in srgb, var(--sw-warn) 10%, transparent); cursor: pointer; }
+.acv-integrity.is-mismatch { color: var(--sw-err); border-color: color-mix(in srgb, var(--sw-err) 40%, transparent); background: color-mix(in srgb, var(--sw-err) 10%, transparent); cursor: pointer; }
+/* Floats under the strip, as the overview does, and scrolls inside: a broken
+ chain can list a problem per round. */
+.acv-explain.acv-problems { position: absolute; top: 100%; right: 12px; z-index: 30; width: min(760px, calc(100% - 24px)); margin: 4px 0 0; border-color: color-mix(in srgb, var(--sw-warn) 45%, transparent); background: color-mix(in srgb, var(--sw-warn) 7%, var(--acv-surface)); box-shadow: 0 14px 36px color-mix(in srgb, var(--acv-text) 22%, transparent); }
+.acv-problems .acv-explain-head { border-bottom-color: color-mix(in srgb, var(--sw-warn) 30%, transparent); background: color-mix(in srgb, var(--sw-warn) 8%, transparent); }
+.acv-problems .acv-explain-title { color: var(--sw-warn); }
+.acv-problems-body { max-height: 50vh; overflow: auto; padding: 8px 14px; color: var(--acv-muted); font-size: 11px; }
+.acv-problems-body ul { margin: 0; padding-left: 18px; }
+.acv-problems-body li { margin: 2px 0; font-family: var(--acv-mono); font-size: 10.5px; }
+.acv-overview-toggle { min-height: 28px; padding: 4px 9px; border: 1px solid var(--acv-line); border-radius: var(--acv-radius); background: var(--acv-surface-2); color: var(--acv-muted); cursor: pointer; }
+.acv-overview-toggle:hover { color: var(--acv-text); background: var(--acv-surface-3); }
+.acv-chevron { display: inline-block; margin-left: 5px; transition: transform 0.16s ease; }
+.acv-overview-toggle[aria-expanded="true"] .acv-chevron { transform: rotate(180deg); }
+
+/* ---------- overview ---------- */
+/* Floats under the strip, over the workbench; nothing below moves. */
+.acv-overview { position: absolute; top: 100%; left: 0; right: 0; z-index: 30; border-bottom: 1px solid var(--acv-line); background: var(--acv-surface); box-shadow: 0 18px 40px color-mix(in srgb, var(--acv-text) 22%, transparent); }
+.acv-overview[hidden] { display: none; }
+.acv-summary { display: grid; grid-template-columns: minmax(260px, 1.7fr) repeat(4, minmax(110px, 0.6fr)); gap: 1px; background: var(--acv-line); border-bottom: 1px solid var(--acv-line); }
+.acv-cell { min-height: 74px; padding: 14px 18px; background: var(--acv-surface); }
+.acv-cell strong { display: block; margin-top: 5px; font-size: 18px; font-weight: 620; }
+.acv-cell-sub { margin-top: 3px; color: var(--acv-faint); font-size: 10px; line-height: 1.35; }
+.acv-cell-primary strong { font-size: 13px; line-height: 1.45; font-weight: 500; overflow-wrap: anywhere; font-family: var(--acv-mono); }
+.acv-picker { display: grid; gap: 1px; background: var(--acv-line); }
+.acv-picker-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--acv-line); background: var(--acv-surface); }
+.acv-picker-head input { flex: 1; min-width: 0; padding: 4px 8px; border: 1px solid var(--acv-line); border-radius: var(--acv-radius); background: var(--acv-bg); color: var(--acv-text); font-size: 11px; }
+.acv-talk-list { height: 232px; overflow-y: auto; overscroll-behavior: contain; background: var(--acv-surface); }
+.acv-pick { display: block; width: 100%; padding: 7px 12px; border: 0; border-bottom: 1px solid var(--acv-line-soft); background: transparent; text-align: left; cursor: pointer; }
+.acv-pick:hover { background: var(--acv-surface-2); }
+.acv-pick.on { background: var(--acv-surface-3); }
+.acv-pick b { display: block; overflow: hidden; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
+.acv-pick-reply { display: block; margin-top: 2px; padding-left: 10px; border-left: 2px solid color-mix(in srgb, var(--acv-kind-assistant) 60%, transparent); overflow: hidden; color: var(--acv-muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+.acv-pick small { display: block; margin-top: 2px; color: var(--acv-faint); font-size: 10px; font-family: var(--acv-mono); }
+
+/* ---------- workbench ---------- */
+/* The workbench takes what the host leaves after the strip and the overview:
+ a host with a definite height (Horizon's page, the Sessionizer's) gets a
+ workbench that fills it and never scrolls the page; only the transcript,
+ inspector and timeline scroll inside. Where nothing gives it a height it
+ stands 600px tall, as the Sessionizer's own page has always done. */
+.acv-workbench { display: grid; grid-template-rows: minmax(160px, 1fr) 7px var(--acv-dock); flex: 1 1 0; min-height: 600px; margin: 8px; border: 1px solid var(--acv-line); border-radius: 8px; overflow: hidden; background: var(--acv-surface); box-shadow: 0 18px 60px color-mix(in srgb, var(--acv-text) 10%, transparent); }
+.acv-main { display: grid; grid-template-columns: minmax(0, 1fr) 7px var(--acv-inspector); min-height: 0; overflow: hidden; }
+.acv-transcript { display: flex; flex-direction: column; min-width: 0; min-height: 0; border-right: 1px solid var(--acv-line); background: var(--acv-surface); }
+.acv-inspector { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--acv-surface); }
+.acv-splitter { background: var(--acv-line-soft); cursor: col-resize; }
+.acv-splitter:hover,
+.acv-splitter.dragging { background: var(--acv-focus); }
+.acv-split-dock { cursor: row-resize; }
+.acv-resizing { user-select: none; }
+
+.acv-toolbar { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; min-height: 44px; padding: 5px 8px; border-bottom: 1px solid var(--acv-line); background: var(--acv-surface); }
+.acv-control-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
+.acv-control-label { margin-right: 3px; color: var(--acv-faint); font-size: 11px; }
+.acv-tab,
+.acv-stream-btn { min-height: 29px; padding: 4px 10px; border: 0; border-radius: var(--acv-radius); background: transparent; color: var(--acv-muted); cursor: pointer; font-size: 12px; }
+.acv-tab[aria-selected="true"],
+.acv-stream-btn.active { background: var(--acv-surface-3); color: var(--acv-text); }
+.acv-tab:hover,
+.acv-stream-btn:hover { color: var(--acv-text); background: var(--acv-surface-3); }
+.acv-tab[hidden] { display: none; }
+.acv-checkbox { display: inline-flex; min-height: 29px; align-items: center; gap: 6px; color: var(--acv-muted); font-size: 11px; cursor: pointer; }
+.acv-checkbox input { accent-color: var(--sw-info); }
+.acv-zoom-out { width: 38px; color: var(--acv-muted); font-size: 11px; text-align: right; font-variant-numeric: tabular-nums; }
+.acv input[type="range"] { width: 100px; accent-color: var(--sw-info); }
+
+/* ---------- timeline dock ---------- */
+.acv-dock { position: relative; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-height: 0; overflow: hidden; border-top: 1px solid var(--acv-line); background: var(--acv-bg); }
+.acv-tl-heading { display: flex; flex-direction: column; min-width: 145px; margin-right: 8px; }
+.acv-tl-heading strong { font-size: 11px; letter-spacing: 0.09em; text-transform: uppercase; }
+.acv-tl-heading small { margin-top: 2px; color: var(--acv-faint); font-size: 10px; }
+.acv-tl-panel { min-width: 0; min-height: 0; overflow: hidden; }
+.acv-tl-layout { display: grid; grid-template-columns: 156px minmax(0, 1fr); height: 100%; min-height: 0; overflow: hidden; }
+.acv-lane-labels { position: relative; height: 100%; overflow: hidden; scrollbar-width: none; background: var(--acv-surface); border-right: 1px solid var(--acv-line); }
+.acv-lane-labels::-webkit-scrollbar { display: none; }
+.acv-lane-label { position: absolute; left: 25px; color: var(--acv-faint); font-size: 11px; line-height: 28px; }
+.acv-lane-label.child { left: 38px; }
+.acv-tl-scroll { height: 100%; overflow: auto; background: var(--acv-bg); scrollbar-color: var(--acv-line) var(--acv-bg); }
+.acv-tl-canvas { position: relative; min-width: 920px; isolation: isolate; }
+.acv-tl-static,
+.acv-tl-items { position: absolute; inset: 0; }
+.acv-lane-extent { position: absolute; left: 0; width: 1px; height: 1px; }
+.acv-band { position: absolute; top: 0; bottom: 0; border-inline: 1px solid color-mix(in srgb, var(--sw-info) 18%, transparent); background: color-mix(in srgb, var(--sw-info) 4%, transparent); z-index: 0; }
+.acv-band.alt { background: color-mix(in srgb, var(--acv-kind-agent) 4%, transparent); border-color: color-mix(in srgb, var(--acv-kind-agent) 18%, transparent); }
+.acv-gap { position: absolute; top: 0; bottom: 0; z-index: 0; background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--sw-warn) 3%, transparent) 0 7px, color-mix(in srgb, var(--sw-warn) 9%, transparent) 7px 8px); border-inline: 1px dashed color-mix(in srgb, var(--sw-warn) 35%, transparent); }
+.acv-gap-label { position: absolute; top: 20px; transform: translateX(-50%); color: var(--sw-warn); font-size: 10px; white-space: nowrap; z-index: 4; }
+.acv-tick { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--acv-line); z-index: 1; }
+.acv-tick span { position: absolute; top: 3px; left: 4px; color: var(--acv-faint); font-size: 10px; white-space: nowrap; }
+.acv-track-line { position: absolute; left: 0; right: 0; height: 1px; background: var(--acv-line-soft); z-index: 1; }
+.acv-edges { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: 2; }
+.acv-edge { fill: none; stroke: var(--acv-edge); stroke-width: 1.5; opacity: 0.68; marker-end: url(#acv-arrow-exact); }
+.acv-edge.inferred { stroke: var(--sw-warn); stroke-dasharray: 6 5; marker-end: url(#acv-arrow-inferred); }
+.acv-edge.weak { stroke: var(--acv-fainter); stroke-dasharray: 2 5; marker-end: url(#acv-arrow-weak); }
+.acv-edge.selected { stroke-width: 2.6; opacity: 1; }
+/* The drop into a child ends at the child's own top edge: an arrowhead there
+ reads as a blot, and the direction is told by where the line ends. */
+.acv-edge.drop { marker-end: none; stroke: var(--acv-edge); stroke-dasharray: none; }
+.acv-owns { fill: none; stroke: var(--acv-owns); stroke-width: 1.1; opacity: 0.5; marker-end: url(#acv-arrow-owns); }
+.acv-owns.selected { stroke: var(--acv-focus); stroke-width: 2; opacity: 1; }
+.acv-arrow.exact { fill: var(--acv-edge); }
+.acv-arrow.inferred { fill: var(--sw-warn); }
+.acv-arrow.weak { fill: var(--acv-fainter); }
+.acv-arrow.owns { fill: var(--acv-owns); }
+.acv-clip { position: absolute; z-index: 5; height: 28px; min-width: 6px; overflow: hidden; padding: 0 8px; border: 1px solid color-mix(in srgb, currentColor 45%, transparent); border-radius: var(--acv-radius); background: color-mix(in srgb, currentColor 17%, var(--acv-surface)); font-size: 10px; font-weight: 650; white-space: nowrap; text-overflow: ellipsis; text-align: left; cursor: pointer; box-shadow: 0 3px 8px color-mix(in srgb, var(--acv-text) 12%, transparent); }
+.acv-clip::before { content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 2px; background: currentColor; vertical-align: 1px; }
+.acv-kind-user { color: var(--acv-kind-user); }
+.acv-kind-assistant { color: var(--acv-kind-assistant); }
+.acv-kind-subagent-response { color: var(--acv-kind-subagent-response); }
+.acv-kind-model { color: var(--acv-kind-model); }
+.acv-kind-tool { color: var(--acv-kind-tool); }
+.acv-kind-agent { color: var(--acv-kind-agent); }
+.acv-kind-instruction { color: var(--acv-kind-instruction); }
+.acv-kind-annotation { color: var(--acv-kind-annotation); }
+.acv-kind-error { color: var(--acv-kind-error); }
+.acv-clip.nested { height: 32px; border-width: 2px; border-radius: 7px; background: linear-gradient(180deg, color-mix(in srgb, var(--acv-kind-agent) 28%, var(--acv-surface)), color-mix(in srgb, var(--acv-kind-agent) 13%, var(--acv-surface))); }
+.acv-clip.nested::before { width: 9px; height: 7px; border-radius: 2px 2px 1px 1px; box-shadow: 3px -3px 0 -1px currentColor; }
+/* A clip's colour IS its kind: the kind class sets `color`, and the tint, the
+ border, the mark and the text all follow it — as the transcript's cards do.
+ The clip itself sets no colour, so nothing outranks the kind. */
+.acv-clip.acv-kind-user { color: var(--acv-kind-user); }
+.acv-clip.acv-kind-assistant { color: var(--acv-kind-assistant); }
+.acv-clip.acv-kind-subagent-response { color: var(--acv-kind-subagent-response); }
+.acv-clip.acv-kind-model { color: var(--acv-kind-model); }
+.acv-clip.acv-kind-tool { color: var(--acv-kind-tool); }
+.acv-clip.acv-kind-agent { color: var(--acv-kind-agent); }
+.acv-clip.acv-kind-instruction { color: var(--acv-kind-instruction); }
+.acv-clip.acv-kind-annotation { color: var(--acv-kind-annotation); }
+.acv-clip.acv-kind-error { color: var(--acv-kind-error); }
+/* Selection is the outline: the clip keeps its own colour, because the colour
+ says what kind of step it is and that does not change by being looked at. */
+.acv-clip.selected { z-index: 8; outline: 2px solid var(--acv-focus); outline-offset: 2px; }
+/* What is not related to the selection is faded, not hidden. */
+.acv-clip.dim { opacity: 0.2; }
+.acv-clip.dim:hover { opacity: 1; }
+.acv-playhead { position: absolute; top: 0; bottom: 0; width: 1px; background: color-mix(in srgb, var(--acv-focus) 65%, transparent); pointer-events: none; z-index: 4; }
+.acv-playhead::before { content: ""; position: absolute; top: 0; left: -4px; border: 4px solid transparent; border-top-color: var(--acv-focus); }
+.acv-legend { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 8px 12px; border-top: 1px solid var(--acv-line); color: var(--acv-muted); font-size: 10px; }
+.acv-legend-item { display: inline-flex; align-items: center; gap: 5px; }
+.acv-legend-swatch { width: 9px; height: 9px; border-radius: 2px; background: currentColor; }
+.acv-legend-line { width: 25px; border-top: 2px solid currentColor; }
+.acv-legend-line.dashed { border-top-style: dashed; color: var(--sw-warn); }
+/* Floats under the toolbar, over the lanes: the dock's rows never move. */
+.acv-explain.acv-dock-help { position: absolute; top: 100%; left: 8px; z-index: 30; width: min(720px, calc(100% - 16px)); margin: 4px 0 0; background: color-mix(in srgb, var(--sw-info) 7%, var(--acv-surface)); box-shadow: 0 14px 36px color-mix(in srgb, var(--acv-text) 22%, transparent); }
+.acv-dock-help[hidden] { display: none; }
+
+/* ---------- transcript ---------- */
+.acv-transcript-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 7px 10px; border-bottom: 1px solid var(--acv-line); }
+.acv-transcript-head h2 { margin: 0; font-size: 15px; font-weight: 650; }
+.acv-heading { display: flex; align-items: baseline; gap: 9px; min-width: 0; }
+.acv-talk-caption { overflow: hidden; color: var(--acv-faint); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+.acv-stream-tabs { display: flex; gap: 4px; flex-wrap: wrap; max-width: 60%; overflow: hidden; }
+.acv-stream-btn { border: 1px solid transparent; font-size: 11px; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.acv-stream-btn.active { border-color: var(--acv-line); }
+.acv-stream-btn.up { border-color: color-mix(in srgb, var(--acv-kind-agent) 40%, transparent); background: color-mix(in srgb, var(--acv-kind-agent) 10%, transparent); color: var(--acv-text); }
+.acv-stream-btn.up:hover { background: color-mix(in srgb, var(--acv-kind-agent) 22%, transparent); }
+select.acv-stream-btn { border: 1px solid var(--acv-line); background: var(--acv-surface-2); max-width: 220px; }
+.acv-transcript-list { position: relative; flex: 1; width: 100%; max-width: 1100px; min-height: 0; margin: 0 auto; padding: 14px 18px 28px; overflow: auto; }
+.acv-separator { display: flex; align-items: center; gap: 10px; padding: 13px 8px 7px; color: var(--acv-muted); font-size: 11px; font-weight: 650; }
+.acv-separator::after { content: ""; height: 1px; flex: 1; background: var(--acv-line); }
+.acv-separator.dormant { color: var(--sw-warn); }
+.acv-loose-note { margin: 0 8px 8px; color: var(--acv-faint); font-size: 10px; line-height: 1.45; }
+.acv-fold { display: flex; align-items: center; gap: 9px; width: calc(100% - 24px); margin: 2px 0 8px 22px; padding: 6px 10px; border: 1px dashed var(--acv-line); border-radius: 7px; background: transparent; color: var(--acv-faint); font-size: 11px; cursor: pointer; text-align: left; }
+.acv-fold:hover { border-color: var(--sw-info); color: var(--acv-text); background: color-mix(in srgb, var(--sw-info) 5%, transparent); }
+.acv-fold-mark { color: var(--sw-info); }
+.acv-fold-label { color: var(--acv-muted); }
+.acv-fold-stat { margin-left: auto; font-variant-numeric: tabular-nums; }
+.acv-fold-body { position: relative; margin-left: 22px; padding-left: 10px; border-left: 1px solid var(--acv-line-soft); }
+.acv-card { position: relative; display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 12px; width: 100%; margin: 0 0 8px; padding: 11px 12px; border: 1px solid transparent; border-radius: 9px; background: transparent; color: inherit; text-align: left; cursor: pointer; }
+.acv-card:hover { background: color-mix(in srgb, var(--sw-info) 4%, transparent); }
+.acv-card.selected { outline: 2px solid var(--acv-focus); outline-offset: 2px; }
+.acv-card.dim { opacity: 0.34; }
+.acv-card.dim:hover { opacity: 1; }
+.acv-message { width: min(82%, 820px); padding-block: 14px; }
+/* One axis, read left to right: the right edge is what came from outside,
+ everything to the left of it is the agent and the work it did. */
+.acv-human { margin-left: auto; grid-template-columns: minmax(0, 1fr) 104px; grid-template-areas: "content meta"; border-color: color-mix(in srgb, var(--acv-kind-user) 30%, transparent); border-right: 4px solid var(--acv-kind-user); background: color-mix(in srgb, var(--acv-kind-user) 8%, transparent); }
+.acv-human .acv-time { grid-area: meta; text-align: right; }
+.acv-human .acv-content { grid-area: content; }
+.acv-agent { margin-right: auto; border-color: color-mix(in srgb, var(--acv-kind-assistant) 30%, transparent); border-left: 4px solid var(--acv-kind-assistant); background: color-mix(in srgb, var(--acv-kind-assistant) 8%, transparent); }
+.acv-subagent { margin-right: auto; border-color: color-mix(in srgb, var(--acv-kind-agent) 34%, transparent); border-left: 4px solid var(--acv-kind-agent); background: color-mix(in srgb, var(--acv-kind-agent) 9%, transparent); }
+.acv-instruction { margin-right: auto; border-color: color-mix(in srgb, var(--acv-kind-instruction) 30%, transparent); border-left: 4px solid var(--acv-kind-instruction); background: color-mix(in srgb, var(--acv-kind-instruction) 6%, transparent); }
+.acv-activity { position: relative; width: auto; margin-left: 0; padding-block: 7px; border-radius: 4px; border-bottom: 1px solid var(--acv-line-soft); color: var(--acv-muted); }
+.acv-card[style*="--indent"]::before { content: ""; position: absolute; left: -11px; top: 0; bottom: 0; width: 1px; background: var(--acv-line); }
+.acv-activity.child { margin-left: 54px; width: calc(100% - 86px); }
+.acv-handoff { border: 1px solid color-mix(in srgb, var(--acv-kind-agent) 28%, transparent); border-left: 4px solid var(--acv-kind-agent); border-radius: 8px; background: color-mix(in srgb, var(--acv-kind-agent) 6%, transparent); }
+.acv-stream-link { display: block; width: fit-content; margin: -3px 32px 10px auto; padding: 4px 7px; border: 0; border-radius: 5px; background: transparent; color: var(--acv-kind-agent); font-size: 10px; cursor: pointer; }
+.acv-stream-link:hover { background: color-mix(in srgb, var(--acv-kind-agent) 12%, transparent); }
+.acv-stream-banner { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 2px 0 10px; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--acv-kind-agent) 25%, transparent); border-radius: 7px; background: color-mix(in srgb, var(--acv-kind-agent) 5%, transparent); color: var(--acv-muted); font-size: 10px; }
+.acv-stream-banner strong { color: var(--acv-text); font-size: 11px; }
+.acv-stream-banner button { padding: 4px 7px; border: 0; border-radius: 5px; background: color-mix(in srgb, var(--acv-kind-agent) 11%, transparent); color: var(--acv-text); cursor: pointer; }
+.acv-role { display: block; margin-bottom: 5px; color: var(--acv-muted); font-size: 10px; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; }
+.acv-human .acv-role { color: var(--acv-kind-user); }
+.acv-agent .acv-role { color: var(--acv-kind-assistant); }
+.acv-subagent .acv-role,
+.acv-instruction .acv-role { color: var(--acv-kind-agent); }
+.acv-activity .acv-role { margin-bottom: 3px; color: var(--acv-faint); font-size: 9px; }
+.acv-handoff .acv-role { color: var(--acv-kind-agent); }
+.acv-time { color: var(--acv-faint); font-size: 10px; line-height: 1.5; font-family: var(--acv-mono); }
+.acv-time strong { display: block; color: var(--acv-muted); font-weight: 600; }
+.acv-content { min-width: 0; }
+.acv-title { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; font-size: 12px; font-weight: 650; }
+.acv-type-mark { width: 7px; height: 7px; border-radius: 2px; background: currentColor; }
+.acv-text { display: block; position: relative; margin-top: 5px; max-height: 8.9em; overflow: hidden; color: var(--acv-text); font-size: 12px; line-height: 1.48; white-space: pre-wrap; overflow-wrap: anywhere; }
+.acv-text.clamped::after { content: ""; position: absolute; inset: auto 0 0 0; height: 2.2em; background: linear-gradient(180deg, transparent, var(--acv-surface)); }
+.acv-result-label { display: block; margin-top: 8px; color: var(--acv-faint); font-size: 9px; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; }
+.acv-text.result { margin-top: 3px; padding-left: 8px; border-left: 2px solid var(--acv-line); color: var(--acv-muted); }
+.acv-text.mono { font-family: var(--acv-mono); font-size: 11px; }
+.acv-mini { color: var(--acv-faint); font-size: 9px; font-weight: 500; }
+.acv-result-block { display: block; border-radius: 6px; }
+
+/* ---------- inspector ---------- */
+.acv-inspector-head { padding: 8px 10px 7px; border-bottom: 1px solid var(--acv-line); }
+.acv-inspector-head h2 { min-width: 0; margin: 0; overflow: hidden; color: var(--acv-text); font-size: 14px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
+.acv-inspector-meta { color: var(--acv-muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.acv-tablist { display: flex; gap: 2px; padding: 5px 8px; border-bottom: 1px solid var(--acv-line); overflow-x: auto; }
+.acv-tab { padding-inline: 8px; font-size: 11px; white-space: nowrap; }
+.acv-inspector-body { flex: 1; min-height: 0; padding: 14px; overflow: auto; }
+.acv-definition { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 8px 10px; margin: 0; font-size: 12px; }
+.acv-definition dt { color: var(--acv-faint); }
+.acv-definition dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
+.acv-definition dd.mono { font-family: var(--acv-mono); font-size: 11px; }
+.acv-path { margin-bottom: 13px; padding: 9px 10px; border-left: 3px solid var(--acv-kind-agent); background: var(--acv-surface-2); color: var(--acv-muted); font-size: 10px; line-height: 1.5; overflow-wrap: anywhere; }
+.acv-block { margin: 15px 0 0; padding: 11px; border-left: 3px solid var(--sw-info); background: var(--acv-surface-2); color: var(--acv-text); font-size: 12px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
+.acv-warning { margin-top: 12px; padding: 10px; border: 1px dashed color-mix(in srgb, var(--sw-warn) 55%, transparent); border-radius: 7px; color: var(--acv-text); background: color-mix(in srgb, var(--sw-warn) 8%, transparent); font-size: 11px; line-height: 1.45; }
+.acv-provenance { display: flex; flex-wrap: wrap; gap: 5px; margin: 10px 0; }
+.acv-source-badge { padding: 3px 6px; border: 1px solid var(--acv-line); border-radius: 5px; color: var(--acv-muted); background: var(--acv-surface-2); font-size: 10px; }
+.acv-relation-list { display: grid; gap: 8px; }
+.acv-relation-card { padding: 9px; border-left: 3px solid var(--acv-edge); background: var(--acv-surface-2); }
+.acv-relation-card.inferred { border-color: var(--sw-warn); }
+.acv-relation-card.weak { border-color: var(--acv-fainter); }
+.acv-relation-card button { padding: 0; border: 0; background: transparent; color: var(--acv-text); cursor: pointer; text-align: left; font-size: 12px; }
+.acv-relation-card button[disabled] { cursor: default; color: var(--acv-muted); }
+.acv-relation-card small { display: block; margin-top: 5px; color: var(--acv-muted); line-height: 1.4; }
+.acv-child-about { display: -webkit-box; overflow: hidden; margin-top: 4px; color: var(--acv-muted); font-size: 11px; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
+.acv-dive { display: flex; align-items: center; gap: 10px; margin-top: 16px; flex-wrap: wrap; }
+.acv-ref-row { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 4px; }
+.acv-ref-chip { display: flex; flex-direction: column; gap: 2px; padding: 6px 9px; border: 1px solid var(--acv-line); border-radius: 7px; background: var(--acv-surface-2); color: var(--acv-muted); cursor: pointer; text-align: left; }
+.acv-ref-chip:hover { background: var(--acv-surface-3); color: var(--acv-text); }
+.acv-ref-chip.on { border-color: var(--sw-info); background: color-mix(in srgb, var(--sw-info) 12%, transparent); color: var(--acv-text); }
+.acv-ref-chip b { font-size: 11px; font-weight: 650; }
+.acv-ref-chip span { font-family: var(--acv-mono); font-size: 10px; }
+.acv-explain { margin: 12px 0 4px; border: 1px solid color-mix(in srgb, var(--sw-info) 34%, transparent); border-radius: 8px; background: color-mix(in srgb, var(--sw-info) 7%, transparent); overflow: hidden; }
+.acv-explain-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; border-bottom: 1px solid color-mix(in srgb, var(--sw-info) 22%, transparent); background: color-mix(in srgb, var(--sw-info) 8%, transparent); }
+.acv-explain-title { color: var(--acv-text); font-size: 12px; font-weight: 650; font-family: var(--acv-mono); }
+.acv-explain-rows { display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: 7px 10px; margin: 0; padding: 10px; font-size: 11px; }
+.acv-explain-rows dt { color: var(--acv-faint); }
+.acv-explain-rows dd { margin: 0; min-width: 0; color: var(--acv-text); line-height: 1.45; overflow-wrap: anywhere; }
+.acv-explain-close { padding: 0 6px; border: 0; border-radius: 4px; background: transparent; color: var(--acv-muted); font-size: 15px; line-height: 1; cursor: pointer; }
+.acv-explain-close:hover { background: var(--acv-surface-3); color: var(--acv-text); }
+.acv-jkey { color: var(--sw-info); }
+.acv-jkey.plain { color: var(--acv-faint); }
+.acv-q { display: inline-flex; align-items: center; justify-content: center; width: 13px; height: 13px; margin: 0 1px 0 3px; padding: 0; border: 1px solid color-mix(in srgb, var(--sw-info) 50%, transparent); border-radius: 50%; background: color-mix(in srgb, var(--sw-info) 12%, transparent); color: var(--sw-info); font-size: 9px; font-weight: 700; line-height: 1; vertical-align: 1px; cursor: pointer; font-family: var(--acv-sans); }
+.acv-q:hover,
+.acv-q.on { background: var(--sw-info); border-color: var(--sw-info); color: var(--acv-bg); }
+.acv-jstr { color: var(--sw-ok); }
+.acv-jnum { color: var(--sw-warn); }
+.acv-jbool { color: var(--acv-kind-agent); }
+.acv-jnull { color: var(--acv-faint); }
+.acv-raw { margin: 0; color: var(--acv-muted); font-size: 10px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; }
+
+@media (max-width: 1080px) {
+ .acv-summary { grid-template-columns: minmax(240px, 1.6fr) repeat(2, 1fr); }
+ .acv-main { grid-template-columns: minmax(0, 1fr) 7px min(var(--acv-inspector), 40vw); }
+}
+@media (max-width: 860px) {
+ .acv { height: auto; }
+ .acv-workbench { display: block; flex: none; height: auto; min-height: 0; overflow: visible; }
+ .acv-main { grid-template-columns: 1fr; }
+ .acv-splitter { display: none; }
+ .acv-transcript { border-right: 0; }
+ .acv-inspector { border-top: 1px solid var(--acv-line); }
+ .acv-transcript-list { max-height: 560px; }
+ .acv-dock { display: block; overflow: visible; }
+ .acv-tl-layout { height: 330px; }
+ .acv-picker { grid-template-columns: 1fr; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .acv * { scroll-behavior: auto !important; }
+}
diff --git a/packages/conversation-view/src/types.ts b/packages/conversation-view/src/types.ts
new file mode 100644
index 00000000..f47e53dd
--- /dev/null
+++ b/packages/conversation-view/src/types.ts
@@ -0,0 +1,237 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The `asz.view` 1.0 document, as the AI Sessionizer defines it in
+ * `pkg/sessionview` and the OAP reproduces it key for key. Keys are
+ * `snake_case` as on the wire. A 1.x adds keys and never removes one, so
+ * every field below is what version 1.0 carries and a reader tolerates more.
+ */
+
+export interface AszRef {
+ seq: number;
+ row: number;
+ block?: number;
+}
+
+export interface AszUsage {
+ in?: number;
+ out?: number;
+ cache_read?: number;
+ cache_write?: number;
+}
+
+export interface AszDrop {
+ what: string;
+ bytes: number;
+ why?: string;
+}
+
+export interface AszEdge {
+ type: string;
+ other: string;
+ dir: 'out' | 'in';
+ quality: string;
+ via?: string;
+}
+
+/** One node of a talk tree: a talk, a run, or a step. Keys a node has no value
+ * for are absent, not null. */
+export interface AszNode {
+ id: string;
+ kind: string;
+ parent?: string;
+ stream?: string;
+ /** Epoch ms; 0 when nothing observed a time. */
+ at: number;
+ ref?: AszRef;
+ refs?: AszRef[];
+ attrs?: Record<string, unknown>;
+ /** Readable text of the part the node stands on, clipped to 2,000 bytes. */
+ text?: string;
+ state?: string;
+ /** Full size of the part, so a viewer can say how much of it `text` is. */
+ bytes?: number;
+ usage?: AszUsage;
+ flags?: string[];
+ dropped?: AszDrop[];
+ // A talk adds these.
+ label?: string;
+ reply?: string;
+ runs?: number;
+ steps?: number;
+ tools?: number;
+ from?: number;
+ to?: number;
+ child?: boolean;
+ segment?: string;
+ // A tool or agent call adds these.
+ name?: string;
+ failed?: boolean;
+ result?: string;
+ result_state?: string;
+ result_bytes?: number;
+ request_to_result_ms?: number;
+ request_to_result_join?: string;
+ // A turn.duration step adds these.
+ duration_ms?: number;
+ duration_measured_by?: string;
+ children?: AszNode[];
+ edges?: AszEdge[];
+}
+
+export interface AszOrigin {
+ step: string;
+ stream: string;
+ talk: string;
+ quality: string;
+}
+
+export interface AszStream {
+ id: string;
+ name: string;
+ role: 'main' | 'child' | string;
+ label: string;
+ parent: string;
+ records: number;
+ steps: number;
+ talk: string;
+ named_by: string;
+ opened_by: AszOrigin[];
+}
+
+export interface AszSegment {
+ id: string;
+ state: string;
+ committable: boolean;
+ talks: number;
+ from: number;
+ to: number;
+}
+
+export interface AszRound {
+ round: number;
+ digest: string;
+ previous: string | null;
+ from_seq: number;
+ through_seq: number;
+ input_digest: string;
+ from_time: number | null;
+ through_time: number | null;
+ verified: boolean;
+}
+
+export interface AszFile {
+ file: string;
+ format: 'sd' | 'sf' | string;
+ kind: string;
+ seq: number | null;
+ round: number | null;
+ stream: string | null;
+ run: string | null;
+ lines: number;
+ bytes: number;
+ digest: string;
+ from_time: number | null;
+ through_time: number | null;
+}
+
+export interface AszRelation {
+ id: string;
+ type: string;
+ from: string;
+ to: string;
+ quality: string;
+ via?: string;
+ evidence?: AszRef[];
+}
+
+export interface AszUnresolved {
+ id: string;
+ kind: string;
+ ref: string;
+ reason: string;
+ state: string;
+}
+
+export type AszIntegrityState = 'verified' | 'incomplete' | 'mismatch' | string;
+
+export interface AszSummary {
+ title: string;
+ state: AszIntegrityState;
+ problems: string[];
+ talks: number;
+ steps: number;
+ streams: number;
+ segments: number;
+ rounds: number;
+ unresolved: number;
+ from: number;
+ to: number;
+ kinds: Record<string, number>;
+ relation_types: Record<string, number>;
+ quality: Record<string, number>;
+}
+
+export interface AszViewDocument {
+ format: string;
+ version: string;
+ conversation: string;
+ sessions: string[];
+ head: { round: number; digest: string };
+ parser: string;
+ policy: string;
+ summary: AszSummary;
+ rounds: AszRound[];
+ files: AszFile[];
+ streams: AszStream[];
+ segments: AszSegment[];
+ talks: AszNode[];
+ loose?: AszNode[];
+ relations: AszRelation[];
+ unresolved: AszUnresolved[];
+}
+
+export const ASZ_VIEW_FORMAT = 'asz.view';
+export const ASZ_VIEW_MAJOR_VERSION = 1;
+
+/** Whether a parsed value is a document this renderer can draw: the format
+ * name, and a major version it knows. A reader that meets another major
+ * version stops here, as the format page instructs. */
+export function isSupportedDocument(v: unknown): v is AszViewDocument {
+ if (!v || typeof v !== 'object') return false;
+ const d = v as { format?: unknown; version?: unknown; talks?: unknown };
+ if (d.format !== ASZ_VIEW_FORMAT || typeof d.version !== 'string') return false;
+ const major = Number(d.version.split('.')[0]);
+ return major === ASZ_VIEW_MAJOR_VERSION && Array.isArray(d.talks);
+}
+
+/** A landed record, as a host that can read one by address returns it. The
+ * renderer prints it as JSON; it interprets nothing but `dropped`. */
+export interface LandedRecord {
+ dropped?: AszDrop[];
+ [key: string]: unknown;
+}
+
+/** What the runtime calls each name the model uses, and what each landed-record
+ * field means — as the Sessionizer's `/api/glossary` serves it. Optional: a
+ * host without one (the OAP has none) shows the names and no explanations. */
+export interface Glossary {
+ dialect: string;
+ terms: Record<string, { native?: string; where?: string; note?: string }>;
+ fields: Record<string, string>;
+}
diff --git a/packages/conversation-view/src/view/chrome.ts b/packages/conversation-view/src/view/chrome.ts
new file mode 100644
index 00000000..747dfb6d
--- /dev/null
+++ b/packages/conversation-view/src/view/chrome.ts
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The frame around the workbench: the status strip with the integrity badge,
+ * the overview panel, the talk list, and the talk header with its stream tabs.
+ * The host draws the page header (brand, service, sender, sharing) above.
+ */
+
+import { esc } from '../dom.js';
+import { fill } from '../strings.js';
+import { streamName, type ViewContext } from './context.js';
+
+export function drawStatus(ctx: ViewContext): void {
+ const { model: m, s, f, state } = ctx;
+ const sum = m.doc.summary;
+ const span = sum.to && sum.from ? f.duration(sum.to - sum.from) : '—';
+ const verified = m.doc.rounds.filter((r) => r.verified).length;
+ const integrity =
+ sum.state === 'verified' ? s.integrityVerified : sum.state === 'mismatch' ? s.integrityMismatch : s.integrityIncomplete;
+ ctx.q('.acv-status').innerHTML =
+ `<span class="acv-badge acv-integrity is-${esc(sum.state)}" title="${esc(`${verified}/${m.doc.rounds.length} ${s.roundsVerified} · ${m.doc.files.length} ${s.filesListed}`)}"><span class="acv-dot"></span>${esc(integrity)}${
+ sum.problems.length ? ` · ${sum.problems.length} ${esc(s.problems)}` : ''
+ }</span>` +
+ `<span class="acv-badge"><span class="acv-dot"></span>${esc(s.round)} ${f.number(m.doc.head.round)}</span>` +
+ `<span><strong>${f.number(m.doc.segments.length)}</strong> ${esc(s.segments)}</span>` +
+ `<span><strong>${f.number(m.doc.streams.length)}</strong> ${esc(s.streams)}</span>` +
+ `<span><strong>${f.number(m.talks.length)}</strong> ${esc(s.talks)}</span>` +
+ `<span>${esc(span)} ${esc(s.span)}</span>` +
+ (sum.unresolved ? `<span class="acv-warn">${f.number(sum.unresolved)} ${esc(s.unresolved)}</span>` : '') +
+ `<button type="button" class="acv-overview-toggle" aria-expanded="${state.overviewOpen}" aria-controls="acv-overview">${esc(s.overview)} <span class="acv-chevron" aria-hidden="true">⌄</span></button>`;
+ ctx.q('.acv-overview-toggle').addEventListener('click', () => setOverviewOpen(ctx, !state.overviewOpen));
+ if (sum.problems.length) {
+ ctx.q('.acv-integrity').addEventListener('click', () => setProblemsOpen(ctx, ctx.q('.acv-problems').hidden));
+ ctx.q('.acv-problems-close').addEventListener('click', () => setProblemsOpen(ctx, false));
+ }
+ ctx.q('.acv-problems-title').textContent = `${f.number(sum.problems.length)} ${s.problems}`;
+ ctx.q('.acv-problems-body').innerHTML = sum.problems.length
+ ? `<ul>${sum.problems.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>`
+ : '';
+ // A document that folded nothing has only its problems to show: the box
+ // starts open, so the reader is not left with an empty transcript and a
+ // badge to find.
+ setProblemsOpen(ctx, sum.steps === 0 && sum.problems.length > 0);
+}
+
+/** The problems float under the strip, as the overview does; the two never
+ * show together. */
+export function setProblemsOpen(ctx: ViewContext, open: boolean): void {
+ const box = ctx.q('.acv-problems');
+ box.hidden = !open;
+ if (open && ctx.state.overviewOpen) setOverviewOpen(ctx, false);
+}
+
+/** The overview floats over the workbench; it never pushes it. */
+export function setOverviewOpen(ctx: ViewContext, open: boolean): void {
+ ctx.state.overviewOpen = open;
+ ctx.q('.acv-overview').hidden = !open;
+ if (open) ctx.q('.acv-problems').hidden = true;
+ ctx.q('.acv-overview-toggle').setAttribute('aria-expanded', String(open));
+ if (open) ctx.q<HTMLInputElement>('.acv-talk-filter').focus();
+}
+
+export function drawOverview(ctx: ViewContext): void {
+ const { model: m, s, f } = ctx;
+ const sum = m.doc.summary;
+ const child = m.doc.streams.filter((x) => x.role !== 'main').length;
+ const runs = sum.kinds?.run ?? 0;
+ const quality = Object.entries(sum.quality ?? {})
+ .sort((a, b) => b[1] - a[1])
+ .map(([k, n]) => `${f.number(n)} ${k.replace(/_/g, ' ')}`)
+ .join(' · ');
+ const idle = /idle=([^\s,]+)/.exec(m.doc.policy)?.[1] ?? m.doc.policy;
+ ctx.q('.acv-summary').innerHTML = `
+ <div class="acv-cell acv-cell-primary"><div class="acv-kicker">${esc(s.session)}</div><strong>${esc(m.doc.sessions.join(', ') || m.doc.conversation)}</strong></div>
+ <div class="acv-cell"><div class="acv-kicker">${esc(s.steps)}</div><strong>${f.number(sum.steps)}</strong>
+ <div class="acv-cell-sub">${f.number(m.talks.length)} ${esc(s.talks)} · ${f.number(runs)} ${esc(s.runs)}</div></div>
+ <div class="acv-cell"><div class="acv-kicker">${esc(s.childStreams)}</div><strong>${f.number(child)}</strong>
+ <div class="acv-cell-sub">${esc(s.childStreamsNote)}</div></div>
+ <div class="acv-cell"><div class="acv-kicker">${esc(s.relations)}</div><strong>${f.number(m.doc.relations.length)}</strong>
+ <div class="acv-cell-sub">${esc(quality)}</div></div>
+ <div class="acv-cell"><div class="acv-kicker">${esc(s.segmentList)}</div><strong>${f.number(m.doc.segments.length)}</strong>
+ <div class="acv-cell-sub">${esc(s.activityWindows)}, ${esc(s.idle)} ${esc(idle)}</div></div>`;
+}
+
+/** The talk list. A child stream's talk is work inside a talk, reached from the
+ * nested lane and the stream tabs, so only the conversation's own talks are
+ * listed. */
+export function drawTalkList(ctx: ViewContext): void {
+ const { model: m, s, f, state } = ctx;
+ const filter = ((ctx.q<HTMLInputElement>('.acv-talk-filter').value ?? '') as string).toLowerCase();
+ // A row is a whole talk: what came in, and what the agent answered.
+ const rows = m.talks.filter(
+ (t) => !t.child && (!filter || t.label.toLowerCase().includes(filter) || t.reply.toLowerCase().includes(filter)),
+ );
+ const list = ctx.q('.acv-talk-list');
+ list.innerHTML =
+ rows
+ .map(
+ (t) => `<button type="button" class="acv-pick${state.talk && t.id === state.talk.id ? ' on' : ''}" data-talk-pick="${esc(t.id)}">
+ <b>${esc(t.label || s.noOpeningLine)}</b>
+ ${t.reply ? `<span class="acv-pick-reply">${esc(t.reply)}</span>` : ''}
+ <small>${esc(f.time(t.from))} · ${f.number(t.steps)} ${esc(s.steps.toLowerCase())} · ${t.runs}r${t.tools ? ` · ${t.tools} ${esc(s.tools)}` : ''}</small></button>`,
+ )
+ .join('') || `<div class="acv-empty">${esc(s.noTalkMatches)}</div>`;
+}
+
+/** The streams a talk is read against: the parent that opened this one, the
+ * stream itself, and the children it starts. A talk that fans out to forty
+ * children cannot have forty tabs, so the rest become one list. */
+export function drawStreamTabs(ctx: ViewContext): void {
+ const { model: m, s, f, state } = ctx;
+ const t = state.talk;
+ const own = state.stream ? m.streamByName.get(state.stream) : undefined;
+ const list: Array<{ name: string; role: string; label: string; steps: number; isParent?: boolean; opener?: { step: string; talk: string | null } }> = [];
+ if (own && own.role !== 'main') {
+ const up = m.openerOf(own.name);
+ if (up) {
+ const parent = m.streamByName.get(up.step.stream);
+ if (parent) list.push({ name: parent.name, role: parent.role, label: parent.label, steps: parent.steps, isParent: true, opener: { step: up.step.id, talk: up.talk } });
+ }
+ }
+ if (own) list.push({ name: own.name, role: own.role, label: own.label, steps: own.steps });
+ if (state.stream) {
+ for (const fo of m.foldersFor(state.stream)) {
+ if (!list.some((x) => x.name === fo.stream.name)) list.push({ name: fo.stream.name, role: fo.stream.role, label: fo.stream.label, steps: fo.stream.steps });
+ }
+ }
+ const tabs = list.filter((x) => x.role === 'main' || x.name === state.stream);
+ const rest = list.filter((x) => !tabs.includes(x));
+ let html = tabs
+ .map((x) =>
+ x.isParent
+ ? `<button type="button" class="acv-stream-btn up" data-up-stream="${esc(x.name)}" data-up-step="${esc(x.opener!.step)}" data-up-talk="${esc(x.opener!.talk ?? '')}" title="${esc(`${s.backToStream} ${x.name}`)}">↰ ${esc(streamName(ctx, x.name))}</button>`
+ : `<button type="button" role="tab" class="acv-stream-btn${state.stream === x.name ? ' active' : ''}" aria-selected="${state.stream === x.name}" data-tab-stream="${esc(x.name)}" title="${esc(x.name)}">${x.role === 'main' ? '' : '↳ '}${esc(streamName(ctx, x.name))}</button>`,
+ )
+ .join('');
+ if (rest.length) {
+ html += `<select class="acv-stream-btn acv-child-pick" title="${esc(s.childStreams)}">
+ <option value="">↳ ${rest.length} ${esc(rest.length === 1 ? s.childStreamSingular : s.childStreamPlural)}…</option>
+ ${rest.map((x) => `<option value="${esc(x.name)}">${esc(x.label || `${s.childStreamSingular} ${x.name.slice(0, 6)}`)} · ${f.number(x.steps)} ${esc(s.steps.toLowerCase())}</option>`).join('')}</select>`;
+ }
+ const host = ctx.q('.acv-stream-tabs');
+ host.innerHTML = html;
+ host.querySelectorAll<HTMLElement>('[data-tab-stream]').forEach((b) => (b.onclick = () => ctx.switchStream(b.dataset.tabStream!)));
+ host.querySelectorAll<HTMLElement>('[data-up-stream]').forEach(
+ (b) => (b.onclick = () => ctx.goToOpener(b.dataset.upStream!, b.dataset.upStep!, b.dataset.upTalk || null)),
+ );
+ const pick = host.querySelector<HTMLSelectElement>('.acv-child-pick');
+ if (pick) {
+ pick.onchange = () => {
+ if (!pick.value) return;
+ state.navStack.push({ stream: state.stream!, sel: state.sel, talk: state.talk?.id ?? null });
+ ctx.switchStream(pick.value);
+ };
+ }
+ // What this talk is, not what it says: the opening line is the first card
+ // below and the talk list needs it to tell talks apart.
+ const bits: string[] = [];
+ if (t) {
+ bits.push(own && own.role === 'main' ? s.mainStreamCaption : s.childStreamCaption);
+ if (t.steps) bits.push(`${f.number(t.steps)} ${s.steps.toLowerCase()}`);
+ if (t.runs) bits.push(`${t.runs} ${s.runs}`);
+ if (t.tools) bits.push(`${f.number(t.tools)} ${s.tools}`);
+ if (t.to > t.from) bits.push(f.duration(t.to - t.from));
+ }
+ const caption = ctx.q('.acv-talk-caption');
+ caption.textContent = bits.join(' · ');
+ caption.title = t ? t.id : '';
+}
+
+export function fmtNumberWord(ctx: ViewContext, n: number, singular: string, plural: string): string {
+ return `${ctx.f.number(n)} ${n === 1 ? singular : plural}`;
+}
+
+export { fill };
diff --git a/packages/conversation-view/src/view/context.ts b/packages/conversation-view/src/view/context.ts
new file mode 100644
index 00000000..ce8ac87a
--- /dev/null
+++ b/packages/conversation-view/src/view/context.ts
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { ConversationModel, TalkRow } from '../model.js';
+import type { ViewStrings } from '../strings.js';
+import type { TimeFormatter } from '../format.js';
+import type { AszRef, Glossary, LandedRecord } from '../types.js';
+
+export type InspectorTab = 'details' | 'relations' | 'evidence';
+
+/** What the reader is looking at. The three fields a host round-trips through
+ * its URL are the talk, the selected step and the stream; the rest is local. */
+export interface ViewState {
+ talk: TalkRow | null;
+ stream: string | null;
+ sel: string | null;
+ /** A nested stream picked on the timeline — not entered, only pointed at. */
+ folder: string | null;
+ rawRef: AszRef | null;
+ zoom: number;
+ focus: boolean;
+ /** Whether the selection was the reader's, or the default a talk opens with.
+ * Only a reader's pick fades the rest. */
+ picked: boolean;
+ tab: InspectorTab;
+ navStack: Array<{ stream: string; sel: string | null; talk: string | null }>;
+ openTalks: Set<string>;
+ autoOpen: Set<string>;
+ explain: string | null;
+ overviewOpen: boolean;
+}
+
+/** Everything a view module needs: the model, the words, the state, and the
+ * actions the entry point implements over them. */
+export interface ViewContext {
+ root: HTMLElement;
+ model: ConversationModel;
+ s: ViewStrings;
+ f: TimeFormatter;
+ glossary: Glossary | null;
+ loadRecord?: (ref: AszRef) => Promise<LandedRecord>;
+ state: ViewState;
+ q<T extends Element = HTMLElement>(selector: string): T;
+
+ select(id: string, center: boolean, alsoTranscript?: boolean): void;
+ selectFolder(name: string): void;
+ clearSelection(): void;
+ clearFolder(): void;
+ diveIn(): void;
+ goBack(): void;
+ goToOpener(stream: string, step: string, talk: string | null): void;
+ openTalk(id: string, keepStack?: boolean): void;
+ focusTalk(id: string, end?: string): void;
+ switchStream(name: string): void;
+ showTab(tab: InspectorTab): void;
+ renderAll(recenter?: boolean): void;
+ drawTimeline(): void;
+ drawTranscript(): void;
+ drawInspector(): void;
+ drawStreamTabs(): void;
+ drawTalkList(): void;
+ centerOn(id: string | null, behavior: ScrollBehavior, alsoTranscript?: boolean): void;
+ announce(text: string): void;
+}
+
+/** A stream's display name: the main agent, or a child's label, or a short id. */
+export function streamName(ctx: ViewContext, name: string): string {
+ const st = ctx.model.streamByName.get(name);
+ if (!st) return name;
+ if (st.role === 'main') return ctx.s.mainAgent;
+ return st.label || `${ctx.s.childStreamSingular} ${st.name.slice(0, 6)}`;
+}
diff --git a/packages/conversation-view/src/view/inspector.ts b/packages/conversation-view/src/view/inspector.ts
new file mode 100644
index 00000000..8d357599
--- /dev/null
+++ b/packages/conversation-view/src/view/inspector.ts
@@ -0,0 +1,385 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The inspector answers "where did this come from": what the step is
+ * (Details), what touches it (Relations), and the record it was read from
+ * (Evidence). Everything shown is in the document; a host that can read a
+ * landed record by address may offer the full record beneath the clipped
+ * text, and a host with a glossary gets a "?" beside every name it explains.
+ */
+
+import { esc } from '../dom.js';
+import type { Step } from '../model.js';
+import { fill } from '../strings.js';
+import type { AszRef, LandedRecord } from '../types.js';
+import { kindTitle } from '../vocabulary.js';
+import { streamName, type ViewContext } from './context.js';
+
+function field(dt: string, dd: string, mono = false): string {
+ return `<dt>${esc(dt)}</dt><dd class="${mono ? 'mono' : ''}">${dd}</dd>`;
+}
+
+export function drawInspector(ctx: ViewContext): void {
+ const { s, f, model: m, state } = ctx;
+ if (state.folder) {
+ drawFolderPanel(ctx);
+ return;
+ }
+ const e = m.step(state.sel);
+ const relTab = ctx.q<HTMLButtonElement>('[data-tab="relations"]');
+ const hasRels = !!(e && e.edges.length);
+ relTab.hidden = !hasRels;
+ if (!hasRels && state.tab === 'relations') {
+ ctx.showTab('details');
+ return;
+ }
+ const title = ctx.q('.acv-inspector-title');
+ const meta = ctx.q('.acv-inspector-meta');
+ const body = ctx.q('.acv-inspector-body');
+ if (!e) {
+ title.textContent = '—';
+ meta.textContent = '';
+ body.innerHTML = `<div class="acv-empty">${esc(s.selectAStep)}</div>`;
+ return;
+ }
+ title.textContent = e.name ? `${e.kind} · ${e.name}` : kindTitle(e.kind, s);
+ meta.textContent = `${e.stream.slice(0, 12)} · ${f.time(e.at)}${e.bytes ? ` · ${f.number(e.bytes)} B` : ''}`;
+ if (state.tab === 'details') drawDetails(ctx, body, e);
+ else if (state.tab === 'relations') drawRelations(ctx, body, e);
+ else void drawEvidence(ctx, body, e);
+}
+
+/** What is known about a nested stream, and what opened it. */
+function drawFolderPanel(ctx: ViewContext): void {
+ const { s, f, model: m, state } = ctx;
+ const folder = m.foldersFor(state.stream!).find((x) => x.stream.name === state.folder);
+ if (!folder) {
+ state.folder = null;
+ drawInspector(ctx);
+ return;
+ }
+ const st = folder.stream;
+ const opener = folder.from;
+ ctx.q('.acv-inspector-title').textContent = st.label || `${s.childStreamSingular} ${st.name.slice(0, 6)}`;
+ ctx.q('.acv-inspector-meta').textContent = `${s.nestedStream} · ${f.number(st.steps)} ${s.steps.toLowerCase()} · ${folder.quality}`;
+ const many = folder.candidates > 1;
+ const own = m.openedBy(st.name);
+ const body = ctx.q('.acv-inspector-body');
+ body.innerHTML = `
+ <div class="acv-path">${esc(s.stream.toLowerCase())} ${esc(st.name)} › ${esc(s.openedFrom.toLowerCase())} ${esc(opener.kind)} · ${esc(state.stream ?? '')}</div>
+ <dl class="acv-definition">
+ ${field(s.stream, esc(st.name), true)}
+ ${field(s.role, esc(st.role))}
+ ${field(s.steps, f.number(st.steps))}
+ ${field(
+ folder.back ? s.reportedHereBy : s.openedBy,
+ `<button type="button" class="acv-linkish" data-goto="${esc(opener.id)}">${esc(opener.kind)}${opener.name ? ` · ${esc(opener.name)}` : ''}</button>`,
+ )}
+ ${folder.back ? field(s.note, `<span class="acv-faint">${esc(s.reportedNote)}</span>`) : ''}
+ ${field(s.joinQuality, esc(folder.quality))}
+ ${field(s.openedAt, opener.at ? esc(f.time(opener.at)) : esc(s.unavailable))}
+ ${field(s.itsAnswer, `<span class="acv-faint">${esc(s.itsAnswerNote)}</span>`)}
+ ${field(
+ s.opensInTurn,
+ own.length
+ ? own
+ .slice(0, 6)
+ .map((x) => `<button type="button" class="acv-linkish" data-dive>${esc(x.label || `${s.childStreamSingular} ${x.name.slice(0, 6)}`)}</button>`)
+ .join('<br>') + (own.length > 6 ? `<br><span class="acv-faint">${esc(fill(s.andMore, { n: own.length - 6 }))}</span>` : '')
+ : `<span class="acv-faint">${esc(s.opensNothing)}</span>`,
+ )}
+ </dl>
+ ${many ? `<div class="acv-warning"><strong>${esc(fill(s.poolWarning, { n: folder.candidates }))}</strong><br>${esc(s.poolWarningText)}</div>` : ''}
+ <div class="acv-dive">
+ <button type="button" class="acv-btn primary" data-dive-in>${esc(s.diveIn)}</button>
+ <span class="acv-hint">${esc(s.diveHint)}</span>
+ </div>`;
+ body.querySelector<HTMLElement>('[data-dive-in]')!.onclick = () => ctx.diveIn();
+ // Going to a grandchild means entering the child first; its own timeline is
+ // where that stream is drawn.
+ body.querySelectorAll<HTMLElement>('[data-dive]').forEach((b) => (b.onclick = () => ctx.diveIn()));
+ body.querySelector<HTMLElement>('[data-goto]')?.addEventListener('click', (ev) => ctx.select((ev.currentTarget as HTMLElement).dataset.goto!, true));
+}
+
+function drawDetails(ctx: ViewContext, body: HTMLElement, e: Step): void {
+ const { s, f, model: m, state } = ctx;
+ const st = m.streamByName.get(e.stream);
+ const seg = state.talk ? m.segmentById.get(state.talk.segment) : undefined;
+ const path = [`${m.doc.conversation.slice(0, 8)}`];
+ if (seg) path.push(`${s.segment.toLowerCase()} ${seg.id.replace(/^segment\//, '')}`);
+ path.push(`${s.stream.toLowerCase()} ${e.stream.slice(0, 10)}`);
+ if (e.talk) path.push(`${s.talk.toLowerCase()} ${e.talk.replace(/^talk\//, '').slice(0, 14)}`);
+ if (e.run) path.push(`${s.run.toLowerCase()} ${e.run.replace(/^run\//, '').slice(0, 14)}`);
+ path.push(`${e.kind} ${e.id.slice(0, 20)}`);
+
+ let html = `<div class="acv-path">${path.map(esc).join(' › ')}</div><dl class="acv-definition">`;
+ html += field(s.nodeKind, esc(e.kind));
+ html += field(s.lane, esc(e.track));
+ html += field(s.stream, `${esc(e.stream)}${st ? ` · ${esc(st.role)}` : ''}`, true);
+ html += field(s.segment, seg ? esc(seg.id.replace(/^segment\//, '')) : '—');
+ html += field(s.talk, e.talk ? esc(e.talk.replace(/^talk\//, '')) : '—', true);
+ html += field(s.run, e.run ? esc(e.run.replace(/^run\//, '')) : '—', true);
+ html += field(s.directParent, e.parent ? esc(e.parent) : '—', true);
+ html += field(s.observedAt, e.at ? esc(f.dateTime(e.at)) : esc(s.unavailable));
+ if (e.reqToRes != null) {
+ html += field(s.requestToResult, `${esc(f.duration(e.reqToRes))} <span class="acv-faint">· ${esc(e.reqToResJoin ?? '')}</span>`);
+ }
+ html += field(
+ s.duration,
+ e.durationMs
+ ? `${esc(f.duration(e.durationMs))}${e.durationHow ? ` <span class="acv-faint">· ${esc(e.durationHow)}</span>` : ''}`
+ : `<span class="acv-faint">${esc(s.durationUnavailable)}</span>`,
+ );
+ if (e.reqToRes != null) {
+ html += `<dt></dt><dd><div class="acv-warning" style="margin:0"><strong>${esc(s.requestToResultWhat)}</strong><br>${esc(s.requestToResultText)}</div></dd>`;
+ }
+ if (e.name) html += field(s.name, esc(e.name));
+ if (e.failed !== undefined) html += field(s.failedField, e.failed ? esc(s.yes) : esc(s.no));
+ if (e.state) html += field(s.contentState, esc(e.state));
+ if (e.bytes) html += field(s.contentBytes, f.number(e.bytes));
+ if (e.usage) {
+ html += field(
+ s.tokens,
+ esc(
+ fill(s.tokensText, {
+ in: f.number(e.usage.in ?? 0),
+ out: f.number(e.usage.out ?? 0),
+ cacheRead: f.number(e.usage.cache_read ?? 0),
+ cacheWrite: f.number(e.usage.cache_write ?? 0),
+ }),
+ ),
+ );
+ }
+ html += `</dl>`;
+ if (e.text) html += `<div class="acv-block">${esc(e.text)}</div>`;
+ if (e.result) {
+ html += `<div class="acv-kicker" style="margin-top:14px">${esc(s.result)}${e.failed ? ` · ${esc(s.failed)}` : ''}${
+ e.resultBytes ? ` · ${f.number(e.resultBytes)} B` : ''
+ }</div><div class="acv-block">${esc(e.result)}</div>`;
+ } else if (e.state && e.state !== 'available') {
+ html += `<div class="acv-warning"><strong>${esc(fill(s.contentUnavailable, { state: e.state }))}</strong><br>${esc(s.contentUnavailableText)}</div>`;
+ }
+ // The child agents this call started are relations, read in that tab.
+ const folders = e.edges.filter((g) => g.type === 'starts' && g.dir === 'out');
+ if (folders.length) {
+ html += `<button type="button" class="acv-linkish" data-to-relations style="margin-top:14px">${esc(
+ folders.length === 1 ? s.oneChildAgent : fill(s.childAgents, { n: folders.length }),
+ )} — ${esc(s.openRelations)}</button>`;
+ }
+ if (state.navStack.length) html += `<button type="button" class="acv-btn" style="margin-top:12px" data-back-parent>${esc(s.backToParentStream)}</button>`;
+ body.innerHTML = html;
+ body.querySelector<HTMLElement>('[data-to-relations]')?.addEventListener('click', () => ctx.showTab('relations'));
+ body.querySelector<HTMLElement>('[data-back-parent]')?.addEventListener('click', () => ctx.goBack());
+}
+
+function drawRelations(ctx: ViewContext, body: HTMLElement, e: Step): void {
+ const { s, model: m, state } = ctx;
+ const rels = e.edges;
+ if (!rels.length) {
+ body.innerHTML = `<div class="acv-empty">${esc(s.noRelation)}</div>`;
+ return;
+ }
+ const children = rels.filter((r) => m.streamById.has(r.other));
+ let html = '';
+ if (children.length > 1) {
+ html += `<div class="acv-warning"><strong>${esc(fill(s.agentsCreatedByCall, { n: children.length }))}</strong><br>${esc(s.agentsCreatedText)}</div>`;
+ }
+ html += `<div class="acv-relation-list">${rels
+ .map((r) => {
+ const cls = r.quality === 'exact_unique' ? '' : r.quality === 'strong_inference' ? ' inferred' : ' weak';
+ const st = m.streamById.get(r.other);
+ if (st) {
+ // A relation to a stream is a child agent: named and opened, not shown
+ // as a raw id with nowhere to go. One with no name of its own shows
+ // what was first put into its context, which is at least what it read.
+ const about = !st.label && st.talk ? m.talkById.get(st.talk) : undefined;
+ return `<div class="acv-relation-card${cls}">
+ <button type="button" data-open-child="${esc(st.name)}"><strong>${esc(r.type)} · ${esc(s.childStreamSingular)} ${esc(st.name.slice(0, 6))}</strong><br>${
+ st.label ? `${esc(st.label)} →` : `${esc(s.openStream)} →`
+ }</button>
+ ${about?.label ? `<small class="acv-child-about">${esc(about.label.slice(0, 200))}</small>` : ''}
+ <small>${esc(r.quality)}${r.via ? ` — ${esc(s.joinedOn)} ${esc(r.via)}` : ''}${st.named_by === 'journal' ? ` — ${esc(s.namedFromJournal)}` : ''}</small></div>`;
+ }
+ const known = !!m.step(r.other);
+ return `<div class="acv-relation-card${cls}">
+ <button type="button" ${known ? `data-rel="${esc(r.other)}"` : 'disabled'}><strong>${esc(r.dir === 'out' ? s.outgoing : s.incoming)} · ${esc(r.type)}</strong><br>${esc(r.other)}</button>
+ <small>${esc(r.quality)}${r.via ? ` — ${esc(s.joinedOn)} ${esc(r.via)}` : ''}${known ? '' : `<br>${esc(s.otherEndOutside)}`}</small></div>`;
+ })
+ .join('')}</div>`;
+ body.innerHTML = html;
+ body.querySelectorAll<HTMLElement>('[data-rel]').forEach((b) => (b.onclick = () => ctx.select(b.dataset.rel!, true)));
+ body.querySelectorAll<HTMLElement>('[data-open-child]').forEach(
+ (b) =>
+ (b.onclick = () => {
+ state.navStack.push({ stream: state.stream!, sel: state.sel, talk: state.talk?.id ?? null });
+ ctx.switchStream(b.dataset.openChild!);
+ }),
+ );
+}
+
+/** Where the step was read from, and the text the document carries for it.
+ * The whole record is one more read away, offered only when the host can
+ * make it. */
+async function drawEvidence(ctx: ViewContext, body: HTMLElement, e: Step): Promise<void> {
+ const { s, f, state } = ctx;
+ const refs: AszRef[] = (e.ref ? [e.ref] : []).concat(e.refs ?? []);
+ const seen = new Set<string>();
+ const list = refs.filter((r) => {
+ const k = `${r.seq}/${r.row}/${r.block ?? ''}`;
+ if (seen.has(k)) return false;
+ seen.add(k);
+ return true;
+ });
+ if (!list.length) {
+ body.innerHTML = `<div class="acv-empty">${esc(s.derivedByAssembly)}</div>`;
+ return;
+ }
+ const pick = (state.rawRef && list.find((r) => r.seq === state.rawRef!.seq && r.row === state.rawRef!.row)) ?? list[0]!;
+ const role = (i: number): string =>
+ e.kind === 'tool' || e.kind === 'agent.call' ? (i === 0 ? s.request : s.result) : list.length > 1 ? `${s.part} ${i + 1}` : s.record;
+ const shown = e.text ? new TextEncoder().encode(e.text).length : 0;
+ const clipped = e.bytes && shown && e.bytes > shown;
+ body.innerHTML = `
+ <div class="acv-kicker">${esc(s.landedPositions)}</div>
+ <div class="acv-ref-row">${list
+ .map(
+ (r, i) => `<button type="button" class="acv-ref-chip${r === pick ? ' on' : ''}" data-ref="${r.seq}/${r.row}">
+ <b>${esc(role(i))}</b><span>seq ${r.seq} · row ${r.row}${r.block != null ? ` · block ${r.block}` : ''}</span></button>`,
+ )
+ .join('')}</div>
+ ${
+ e.text
+ ? `<div class="acv-kicker" style="margin-top:12px">${esc(s.clippedText)}${clipped ? ` · ${esc(fill(s.fullTextNote, { shown: f.number(shown), total: f.number(e.bytes!) }))}` : ''}</div>
+ <div class="acv-block">${esc(e.text)}</div>`
+ : ''
+ }
+ ${e.flags?.length ? `<div class="acv-kicker" style="margin-top:12px">${esc(s.flags)}</div><div class="acv-provenance">${e.flags.map((x) => `<span class="acv-source-badge">${esc(x)}</span>`).join('')}</div>` : ''}
+ ${(e.dropped ?? []).map((d) => `<div class="acv-warning">${esc(s.dropped)} ${esc(d.what)} · ${f.number(d.bytes)} B<br>${esc(d.why ?? '')}</div>`).join('')}
+ <div class="acv-explain-box"></div>
+ <div class="acv-record-box">${
+ ctx.loadRecord ? `<button type="button" class="acv-btn" data-load-record>${esc(s.loadFullRecord)}</button>` : ''
+ }</div>`;
+ body.querySelectorAll<HTMLElement>('[data-ref]').forEach(
+ (b) =>
+ (b.onclick = () => {
+ const [seq, row] = b.dataset.ref!.split('/').map(Number);
+ state.rawRef = { seq: seq!, row: row! };
+ state.explain = null;
+ ctx.drawInspector();
+ }),
+ );
+ const loadBtn = body.querySelector<HTMLElement>('[data-load-record]');
+ if (loadBtn && ctx.loadRecord) {
+ loadBtn.onclick = async () => {
+ const box = body.querySelector<HTMLElement>('.acv-record-box')!;
+ box.innerHTML = `<div class="acv-empty">${esc(s.loadingRecord)}</div>`;
+ let rec: LandedRecord;
+ try {
+ rec = await ctx.loadRecord!(pick);
+ } catch {
+ box.innerHTML = `<div class="acv-warning">${esc(s.recordFailed)}</div>`;
+ return;
+ }
+ // The reader may have moved on while the record was read.
+ if (!box.isConnected) return;
+ box.innerHTML = `<div class="acv-kicker" style="margin-top:14px">${esc(s.theLandedRecord)}</div><pre class="acv-raw">${renderJSON(ctx, rec, 0)}</pre>`;
+ box.querySelectorAll<HTMLElement>('[data-term]').forEach(
+ (b) =>
+ (b.onclick = (ev) => {
+ ev.stopPropagation();
+ state.explain = state.explain === b.dataset.term ? null : b.dataset.term!;
+ box.querySelectorAll<HTMLElement>('[data-term]').forEach((x) => x.classList.toggle('on', x.dataset.term === state.explain));
+ drawExplain(ctx, body);
+ }),
+ );
+ };
+ }
+ drawExplain(ctx, body);
+}
+
+/** A name that can be explained carries a question mark. Everything else is
+ * printed plainly, so the marks mark something. */
+function explainable(ctx: ViewContext, key: string): 'term' | 'field' | null {
+ const g = ctx.glossary;
+ if (!g) return null;
+ if (g.terms?.[key]) return 'term';
+ if (g.fields?.[key]) return 'field';
+ return null;
+}
+
+export function renderJSON(ctx: ViewContext, v: unknown, depth: number, key?: string): string {
+ const pad = ' '.repeat(depth);
+ const label =
+ key == null
+ ? ''
+ : explainable(ctx, key)
+ ? `<span class="acv-jkey">"${esc(key)}"</span><button type="button" class="acv-q" data-term="${esc(key)}" aria-label="${esc(fill(ctx.s.whatDoesMean, { key }))}" title="${esc(fill(ctx.s.whatDoesMean, { key }))}">?</button>: `
+ : `<span class="acv-jkey plain">"${esc(key)}"</span>: `;
+ if (v === null) return `${pad}${label}<span class="acv-jnull">null</span>\n`;
+ if (Array.isArray(v)) {
+ if (!v.length) return `${pad}${label}[]\n`;
+ return `${pad}${label}[\n${v.map((x) => renderJSON(ctx, x, depth + 1)).join('')}${pad}]\n`;
+ }
+ if (typeof v === 'object') {
+ const ks = Object.keys(v as object);
+ if (!ks.length) return `${pad}${label}{}\n`;
+ return `${pad}${label}{\n${ks.map((k) => renderJSON(ctx, (v as Record<string, unknown>)[k], depth + 1, k)).join('')}${pad}}\n`;
+ }
+ const cls = typeof v === 'number' ? 'acv-jnum' : typeof v === 'boolean' ? 'acv-jbool' : 'acv-jstr';
+ const text = typeof v === 'string' ? `"${v.length > 300 ? `${v.slice(0, 300)}…` : v}"` : String(v);
+ return `${pad}${label}<span class="${cls}">${esc(text)}</span>\n`;
+}
+
+/** The explanation for whichever name was asked about: which vocabulary it
+ * belongs to, what it means, what the runtime calls it and where that appears.
+ * Where the runtime has no word, it says so rather than leaving the row empty. */
+function drawExplain(ctx: ViewContext, body: HTMLElement): void {
+ const { s, state, glossary: g } = ctx;
+ const host = body.querySelector<HTMLElement>('.acv-explain-box');
+ if (!host) return;
+ const key = state.explain;
+ if (!key || !g) {
+ host.innerHTML = '';
+ return;
+ }
+ const t = g.terms?.[key];
+ const rows: Array<[string, string]> = [];
+ if (t) {
+ rows.push([s.vocabulary, `${esc(s.modelOwnWord)} · <span class="mono">${esc(s.aszTerm)}</span>`]);
+ rows.push([s.aszTerm, `<span class="mono">${esc(key)}</span>`]);
+ rows.push([s.runtimeWord, t.native ? `<span class="mono">${esc(t.native)}</span>` : `<span class="acv-faint">${esc(s.runtimeNoWord)}</span>`]);
+ rows.push([s.whereToLook, t.where ? esc(t.where) : `<span class="acv-faint">${esc(s.nowhereInSource)}</span>`]);
+ if (t.note) rows.push([s.readCarefully, esc(t.note)]);
+ rows.push([s.dialect, `<span class="mono">${esc(g.dialect)}</span>`]);
+ } else {
+ rows.push([s.vocabulary, `${esc(s.landedRecordField)} · <span class="mono">.sd</span>`]);
+ rows.push([s.aszField, `<span class="mono">${esc(key)}</span>`]);
+ rows.push([s.whatItIs, esc(g.fields[key] ?? '')]);
+ rows.push([s.runtimeWord, `<span class="acv-faint">${esc(s.notApplicable)}</span>`]);
+ }
+ host.innerHTML = `<div class="acv-explain">
+ <div class="acv-explain-head"><span class="acv-explain-title">${esc(key)}</span>
+ <button type="button" class="acv-explain-close" aria-label="${esc(s.close)}">×</button></div>
+ <dl class="acv-explain-rows">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('')}</dl></div>`;
+ host.querySelector<HTMLElement>('.acv-explain-close')!.onclick = () => {
+ state.explain = null;
+ drawExplain(ctx, body);
+ };
+}
+
+export { streamName };
diff --git a/packages/conversation-view/src/view/panels.ts b/packages/conversation-view/src/view/panels.ts
new file mode 100644
index 00000000..a1923cc3
--- /dev/null
+++ b/packages/conversation-view/src/view/panels.ts
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The two splitters: the inspector's width and the timeline dock's height.
+ * Both sizes are kept per browser and read back on mount; a stored value from
+ * a much larger window is clamped, so a page opened on a small screen cannot
+ * start with a panel wider than the screen.
+ */
+
+import type { ViewContext } from './context.js';
+
+interface Panel {
+ key: string;
+ cssVar: string;
+ handle: string;
+ axis: 'x' | 'y';
+ min: number;
+ max: () => number;
+ fallback: number | null;
+ measure: (root: HTMLElement, e: PointerEvent) => number;
+ current: (root: HTMLElement) => number;
+}
+
+const PANELS: Panel[] = [
+ {
+ key: 'acv.inspector',
+ cssVar: '--acv-inspector',
+ handle: '.acv-split-inspector',
+ axis: 'x',
+ min: 260,
+ max: () => Math.max(280, (globalThis.innerWidth || 1280) - 420),
+ fallback: 350,
+ measure: (root, e) => root.querySelector('.acv-workbench')!.getBoundingClientRect().right - e.clientX,
+ current: (root) => root.querySelector('.acv-inspector')!.getBoundingClientRect().width,
+ },
+ {
+ key: 'acv.dock',
+ cssVar: '--acv-dock',
+ handle: '.acv-split-dock',
+ axis: 'y',
+ min: 150,
+ max: () => Math.max(160, (globalThis.innerHeight || 800) - 260),
+ fallback: null,
+ measure: (root, e) => root.querySelector('.acv-workbench')!.getBoundingClientRect().bottom - e.clientY,
+ current: (root) => root.querySelector('.acv-dock')!.getBoundingClientRect().height,
+ },
+];
+
+function read(key: string): number {
+ try {
+ return Number(globalThis.localStorage?.getItem(key) ?? 0);
+ } catch {
+ return 0;
+ }
+}
+
+function write(key: string, v: number): void {
+ try {
+ globalThis.localStorage?.setItem(key, String(v));
+ } catch {
+ /* private window */
+ }
+}
+
+export function setupPanels(ctx: ViewContext): () => void {
+ const { root } = ctx;
+ const set = (p: Panel, px: number): number => {
+ const v = Math.round(Math.min(p.max(), Math.max(p.min, px)));
+ root.style.setProperty(p.cssVar, `${v}px`);
+ write(p.key, v);
+ return v;
+ };
+ const onResize = (): void => {
+ for (const p of PANELS) set(p, p.current(root));
+ };
+ for (const p of PANELS) {
+ const stored = read(p.key);
+ if (stored > 0) set(p, stored);
+ else if (p.fallback) set(p, p.fallback);
+ const bar = root.querySelector<HTMLElement>(p.handle);
+ if (!bar) continue;
+ bar.addEventListener('pointerdown', (e) => {
+ e.preventDefault();
+ bar.setPointerCapture(e.pointerId);
+ bar.classList.add('dragging');
+ root.classList.add('acv-resizing');
+ const move = (ev: PointerEvent): void => {
+ set(p, p.measure(root, ev));
+ };
+ const up = (ev: PointerEvent): void => {
+ bar.releasePointerCapture(ev.pointerId);
+ bar.classList.remove('dragging');
+ root.classList.remove('acv-resizing');
+ bar.removeEventListener('pointermove', move);
+ bar.removeEventListener('pointerup', up);
+ // The timeline reads the height it was given, so it is redrawn once
+ // the drag ends rather than on every pointer move.
+ ctx.drawTimeline();
+ };
+ bar.addEventListener('pointermove', move);
+ bar.addEventListener('pointerup', up);
+ });
+ // A splitter is a control, so it answers the arrow keys too.
+ bar.addEventListener('keydown', (e) => {
+ const step = e.shiftKey ? 48 : 12;
+ const grow = p.axis === 'x' ? 'ArrowLeft' : 'ArrowUp';
+ const shrink = p.axis === 'x' ? 'ArrowRight' : 'ArrowDown';
+ if (e.key !== grow && e.key !== shrink && e.key !== 'Home') return;
+ e.preventDefault();
+ if (e.key === 'Home') set(p, p.fallback ?? Math.round((globalThis.innerHeight || 800) * 0.47));
+ else set(p, p.current(root) + (e.key === grow ? step : -step));
+ ctx.drawTimeline();
+ });
+ }
+ globalThis.addEventListener?.('resize', onResize);
+ return () => globalThis.removeEventListener?.('resize', onResize);
+}
diff --git a/packages/conversation-view/src/view/timeline.ts b/packages/conversation-view/src/view/timeline.ts
new file mode 100644
index 00000000..7c2958c4
--- /dev/null
+++ b/packages/conversation-view/src/view/timeline.ts
@@ -0,0 +1,562 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The flow timeline: one stream on a time axis, one lane per kind of step,
+ * the nested streams it started beneath, and the relations of the selected
+ * step drawn between the lanes.
+ *
+ * The axis is gap-compressed — busy stretches take the width and a long pause
+ * is cut to a short marked gap — and the canvas is as wide as the stream needs
+ * at the chosen zoom. A stream can hold fifty thousand steps (measured on a
+ * real corpus), so only the clips and time stamps inside the scrolled window
+ * are in the DOM: positions are laid out for every step once per draw, and the
+ * window is repainted on scroll.
+ */
+
+import { esc } from '../dom.js';
+import type { Folder, Step } from '../model.js';
+import { fill } from '../strings.js';
+import { injectionSays, kindTitle, LANE_H, QUIET_MS, TRACK_NAME, TRACKS, type Track } from '../vocabulary.js';
+import { streamName, type ViewContext } from './context.js';
+
+interface Band {
+ from: number;
+ to: number;
+ left: number;
+ width: number;
+}
+interface Gap {
+ left: number;
+ width: number;
+ ms: number;
+}
+interface Geometry {
+ width: number;
+ bands: Band[];
+ gaps: Gap[];
+ x(t: number): number;
+}
+interface Lane {
+ key: string;
+ label: string;
+ y: number;
+ color?: string;
+ indent: boolean;
+}
+interface Layout {
+ lanes: Lane[];
+ rows: Record<string, number>;
+ streamY: number;
+ nestedY: number | null;
+ height: number;
+}
+interface Pos {
+ x: number;
+ w: number;
+ y: number;
+ cx: number;
+ cy: number;
+}
+interface Mark {
+ x: number;
+ t: number;
+ every: number;
+ newDay: boolean;
+ label: boolean;
+}
+interface ShownFolder extends Folder {
+ count: number;
+ key: string;
+}
+
+/** Everything a repaint of the scrolled window needs, kept from the last draw. */
+interface Paint {
+ stream: string;
+ ev: Step[];
+ pos: Map<string, Pos>;
+ marks: Mark[];
+ folders: ShownFolder[];
+ fpos: Map<string, Pos>;
+ canvasW: number;
+ height: number;
+ near: Set<string> | null;
+}
+
+const paints = new WeakMap<ViewContext, Paint>();
+
+/** Gap-compressed geometry: only the stretches where something happened take
+ * width; wall-clock is linear inside each. */
+function geometry(ctx: ViewContext, ev: Step[]): Geometry {
+ const timed = ev.filter((e) => e.at).sort((a, b) => a.at - b.at);
+ // At 100% one step has about 104px of room — readable on its own.
+ const width = Math.round(Math.max(1280, timed.length * 104) * ctx.state.zoom);
+ const usable = width - 42;
+ const left = 20;
+ if (!timed.length) return { width, bands: [], gaps: [], x: () => left };
+ const runs: Array<{ from: number; to: number }> = [];
+ let cur = { from: timed[0]!.at, to: timed[0]!.at };
+ for (const e of timed) {
+ if (e.at - cur.to > QUIET_MS) {
+ runs.push(cur);
+ cur = { from: e.at, to: e.at };
+ } else cur.to = e.at;
+ }
+ runs.push(cur);
+ const active = runs.reduce((a, r) => a + Math.max(1, r.to - r.from), 0);
+ const gapW = runs.length > 1 ? Math.min(90, usable * 0.07) : 0;
+ const forBands = usable - gapW * (runs.length - 1);
+ let x = left;
+ const bands: Band[] = [];
+ const gaps: Gap[] = [];
+ runs.forEach((r, i) => {
+ const w = Math.max(24, (forBands * Math.max(1, r.to - r.from)) / active);
+ bands.push({ from: r.from, to: r.to, left: x, width: w });
+ x += w;
+ if (i < runs.length - 1) {
+ gaps.push({ left: x, width: gapW, ms: runs[i + 1]!.from - r.to });
+ x += gapW;
+ }
+ });
+ return {
+ width: Math.max(width, x + 20),
+ bands,
+ gaps,
+ x(t: number): number {
+ for (const b of bands) {
+ if (t <= b.to) return b.left + (b.to === b.from ? 0 : ((t - b.from) / (b.to - b.from)) * b.width);
+ }
+ const last = bands[bands.length - 1]!;
+ return last.left + last.width;
+ },
+ };
+}
+
+/** Where each lane sits. A lane is only given room when something is on it,
+ * and the nested lane sits directly under the agents lane, because the calls
+ * that start child streams live there. */
+function layout(ctx: ViewContext, stream: string): Layout {
+ const { s, model: m } = ctx;
+ const used = new Set<Track>(m.flow(stream).map((e) => e.track));
+ const nested = m.foldersFor(stream).length > 0;
+ const lanes: Lane[] = [];
+ // Room for the time stamps floating above; no stream heading row. The dock's
+ // own heading names the stream, and the lanes are the whole of it.
+ let y = 24;
+ const streamY = y;
+ let nestedY: number | null = null;
+ const addNested = (): void => {
+ nestedY = y;
+ lanes.push({ key: 'nested', label: s.laneNested, y, color: 'var(--acv-kind-agent)', indent: true });
+ y += LANE_H;
+ };
+ for (const k of TRACKS) {
+ if (used.has(k)) {
+ lanes.push({ key: k, label: s[TRACK_NAME[k]], y, indent: true });
+ y += LANE_H;
+ }
+ if (k === 'agents' && nested) addNested();
+ }
+ if (nested && nestedY === null) addNested();
+ const rows: Record<string, number> = {};
+ for (const l of lanes) rows[l.key] = l.y;
+ return { lanes, rows, streamY, nestedY, height: y + 10 };
+}
+
+function drawLanes(ctx: ViewContext, L: Layout): void {
+ const { model: m, state } = ctx;
+ const gutter = ctx.q('.acv-lane-labels');
+ const st = state.stream ? m.streamByName.get(state.stream) : undefined;
+ if (!st) {
+ gutter.innerHTML = '';
+ return;
+ }
+ const child = st.role === 'main' ? '' : ' child';
+ let html = '';
+ for (const l of L.lanes) {
+ html += `<div class="acv-lane-label${l.indent ? child : ''}" style="top:${l.y}px${l.color ? `;color:${l.color}` : ''}">${esc(l.label)}</div>`;
+ }
+ html += `<div class="acv-lane-extent" style="top:${L.height - 1}px" aria-hidden="true"></div>`;
+ gutter.innerHTML = html;
+}
+
+export function drawTimeline(ctx: ViewContext): void {
+ const { s, f, model: m, state } = ctx;
+ const stream = state.stream;
+ const canvas = ctx.q('.acv-tl-canvas');
+ const statics = ctx.q('.acv-tl-static');
+ const scroll = ctx.q('.acv-tl-scroll');
+ if (!stream) {
+ statics.innerHTML = '';
+ ctx.q('.acv-tl-items').innerHTML = '';
+ paints.delete(ctx);
+ return;
+ }
+ const L = layout(ctx, stream);
+ drawLanes(ctx, L);
+ const keepLeft = scroll.scrollLeft;
+ const ev = m.flow(stream);
+ const folders = m.foldersFor(stream);
+ const g = geometry(ctx, ev);
+ const st = m.streamByName.get(stream);
+ ctx.q('.acv-tl-scope').textContent = st
+ ? `${streamName(ctx, st.name)} · ${f.number(ev.length)} ${s.events}${folders.length ? ` · ${folders.length} ${s.nested}` : ''}`
+ : '—';
+ // Visible whenever this stream was opened by another, not only when the
+ // reader walked in from it: landing on a child talk directly is the common
+ // way to get here, and it leaves no history to go back through.
+ const up = st && st.role !== 'main' ? m.openerOf(st.name) : null;
+ const back = ctx.q<HTMLButtonElement>('.acv-tl-back');
+ back.hidden = !up && !state.navStack.length;
+ if (up) {
+ back.textContent = `↰ ${streamName(ctx, up.step.stream)}`;
+ back.dataset.upStream = up.step.stream;
+ back.dataset.upStep = up.step.id;
+ back.dataset.upTalk = up.talk ?? '';
+ } else {
+ back.textContent = s.parentTimeline;
+ delete back.dataset.upStream;
+ delete back.dataset.upStep;
+ delete back.dataset.upTalk;
+ }
+
+ // Every candidate time on the axis is collected first, then labelled with a
+ // pass that keeps them apart. Ticks land on real clock marks, and the
+ // interval steps up when a smaller one would put labels too close to read.
+ const LADDER = [1, 2, 5, 10, 15, 30, 60, 120, 360, 720, 1440].map((min) => min * 60_000);
+ const marks: Mark[] = [];
+ for (const b of g.bands) {
+ const perMs = b.width / Math.max(1, b.to - b.from);
+ const every = LADDER.find((ms) => ms * perMs >= 100) ?? LADDER[LADDER.length - 1]!;
+ for (let t = Math.ceil(b.from / every) * every; t <= b.to; t += every) {
+ const prev = t - every;
+ marks.push({ x: b.left + (t - b.from) * perMs, t, every, newDay: prev < b.from || f.day(t) !== f.day(prev), label: false });
+ }
+ }
+ marks.sort((a, b) => a.x - b.x);
+ let lastLabel = -1e9;
+ for (const mk of marks) {
+ mk.label = mk.x - lastLabel >= 92;
+ if (mk.label) lastLabel = mk.x;
+ }
+
+ // Positions, then untangle: several steps can carry the same moment, so a
+ // clip that would land on its lane neighbour is moved just far enough to be
+ // seen, and one that would run into the next is trimmed. The overlap is
+ // taken out, never the time.
+ // A step without a time (the runtime recorded none) is placed at the time
+ // of the step before it in reading order, or the first known one: it stays
+ // on the axis where it was read, instead of at a time that does not exist.
+ const known = new Map<string, number>();
+ let lastKnown = 0;
+ for (const e of m.steps(stream)) {
+ if (e.at) lastKnown = e.at;
+ else known.set(e.id, lastKnown);
+ }
+ const firstKnown = m.flow(stream).find((e) => e.at)?.at ?? 0;
+ const at = (e: Step): number => e.at || known.get(e.id) || firstKnown;
+ const pos = new Map<string, Pos>();
+ for (const e of ev) {
+ const x = g.x(at(e));
+ const y = L.rows[e.track] ?? L.streamY;
+ pos.set(e.id, { x, w: 110, y, cx: x + 55, cy: y + 14 });
+ }
+ const lanes = new Map<string, Step[]>();
+ for (const e of ev) {
+ const k = e.track;
+ const arr = lanes.get(k);
+ if (arr) arr.push(e);
+ else lanes.set(k, [e]);
+ }
+ for (const group of lanes.values()) {
+ let right = -1e9;
+ for (const e of group) {
+ const q = pos.get(e.id)!;
+ if (q.x < right) {
+ q.x = right;
+ q.cx = q.x + q.w / 2;
+ }
+ right = q.x + 24;
+ }
+ group.sort((a, b) => pos.get(a.id)!.x - pos.get(b.id)!.x);
+ for (let i = 0; i < group.length - 1; i++) {
+ const a = pos.get(group[i]!.id)!;
+ const b = pos.get(group[i + 1]!.id)!;
+ const room = b.x - a.x - 3;
+ if (a.w > room) {
+ a.w = Math.max(30, room);
+ a.cx = a.x + a.w / 2;
+ }
+ }
+ }
+
+ // One call that starts a pool of agents is one thing on the nested lane,
+ // not a queue: the clip says how many, the inspector says which.
+ const groups = new Map<string, Folder[]>();
+ for (const fo of folders) {
+ const arr = groups.get(fo.from.id);
+ if (arr) arr.push(fo);
+ else groups.set(fo.from.id, [fo]);
+ }
+ const shown: ShownFolder[] = [...groups.values()].map((list) => ({
+ ...list[0]!,
+ count: list.length,
+ key: list.length > 1 ? `group:${list[0]!.from.id}` : list[0]!.stream.name,
+ }));
+ const fpos = new Map<string, Pos>();
+ let packed = 0;
+ for (const fo of shown.sort((a, b) => (a.from.at || 0) - (b.from.at || 0))) {
+ const w = 132;
+ const x = Math.max(fo.from.at ? g.x(fo.from.at) + 24 : 20, packed);
+ packed = x + w + 5;
+ fpos.set(fo.key, { x, w, y: L.nestedY ?? L.streamY, cx: x + w / 2, cy: (L.nestedY ?? L.streamY) + 16 });
+ }
+ let canvasW = Math.max(g.width, packed + 40);
+ for (const q of pos.values()) canvasW = Math.max(canvasW, q.x + q.w + 40);
+ for (const q of fpos.values()) canvasW = Math.max(canvasW, q.x + q.w + 40);
+
+ // The static layer: bands, gaps, track lines, and the relation edges of the
+ // selection. Edges are few, so they are drawn whole; everything else is cheap.
+ let html = '';
+ g.bands.forEach((b, i) => {
+ html += `<div class="acv-band${i % 2 ? ' alt' : ''}" style="left:${b.left}px;width:${Math.max(1, b.width)}px"></div>`;
+ });
+ for (const gp of g.gaps) {
+ html += `<div class="acv-gap" style="left:${gp.left}px;width:${gp.width}px"></div>`;
+ html += `<div class="acv-gap-label" style="left:${gp.left + gp.width / 2}px">${esc(f.duration(gp.ms))} ${esc(s.quiet)}</div>`;
+ }
+ for (const l of L.lanes) html += `<div class="acv-track-line" style="top:${l.y - 5}px"></div>`;
+ html += edgesSvg(ctx, ev, pos, shown, fpos, canvasW, L.height);
+ statics.innerHTML = html;
+ canvas.style.width = `${canvasW}px`;
+ canvas.style.height = `${L.height}px`;
+
+ paints.set(ctx, {
+ stream,
+ ev,
+ pos,
+ marks,
+ folders: shown,
+ fpos,
+ canvasW,
+ height: L.height,
+ near: m.relatedTo(state.picked && state.focus ? state.sel : null, state.folder, ev),
+ });
+ scroll.scrollLeft = keepLeft;
+ paintViewport(ctx);
+}
+
+/** The relation edges of the selection, and the ownership curves from a model
+ * call to what it produced: containment is not a relation, so it carries no
+ * edge, but it is exactly the dependency a reader looks for between lanes. */
+function edgesSvg(ctx: ViewContext, ev: Step[], pos: Map<string, Pos>, shown: ShownFolder[], fpos: Map<string, Pos>, w: number, h: number): string {
+ const { state } = ctx;
+ let html = `<svg class="acv-edges" viewBox="0 0 ${w} ${h}"><defs>
+ <marker id="acv-arrow-exact" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" class="acv-arrow exact"></path></marker>
+ <marker id="acv-arrow-inferred" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" class="acv-arrow inferred"></path></marker>
+ <marker id="acv-arrow-weak" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" class="acv-arrow weak"></path></marker>
+ <marker id="acv-arrow-owns" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="4" markerHeight="4" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" class="acv-arrow owns"></path></marker></defs>`;
+ if (!state.sel && !state.folder) return `${html}</svg>`;
+ const byId = new Map(ev.map((e) => [e.id, e]));
+ for (const e of ev) {
+ const parent = e.parent ? byId.get(e.parent) : undefined;
+ if (!parent || parent.track === e.track) continue;
+ if (e.id !== state.sel && parent.id !== state.sel) continue;
+ const a = pos.get(parent.id);
+ const b = pos.get(e.id);
+ if (!a || !b) continue;
+ const down = a.cy < b.cy;
+ const sx = a.cx;
+ const sy = a.cy + (down ? 14 : -14);
+ const ex = b.cx;
+ const ey = b.cy + (down ? -14 : 14);
+ const lift = Math.max(10, Math.abs(ey - sy) * 0.5);
+ html += `<path class="acv-owns selected" d="M ${sx} ${sy} C ${sx} ${sy + (down ? lift : -lift)}, ${ex} ${ey - (down ? lift : -lift)}, ${ex} ${ey}"></path>`;
+ }
+ const onScreen = new Set(ev.map((e) => e.id));
+ const drawn = new Set<string>();
+ for (const e of ev) {
+ for (const rel of e.edges) {
+ if (rel.dir !== 'out' || !onScreen.has(rel.other)) continue;
+ if (e.id !== state.sel && rel.other !== state.sel) continue;
+ const key = `${e.id}>${rel.other}`;
+ if (drawn.has(key)) continue;
+ drawn.add(key);
+ const a = pos.get(e.id);
+ const b = pos.get(rel.other);
+ if (!a || !b) continue;
+ const sx = a.x + a.w;
+ const sy = a.cy;
+ const ex = b.x;
+ const ey = b.cy;
+ const bend = Math.max(18, Math.abs(ex - sx) * 0.42);
+ const cls = rel.quality === 'exact_unique' ? '' : rel.quality === 'strong_inference' ? ' inferred' : ' weak';
+ html += `<path class="acv-edge${cls} selected" d="M ${sx} ${sy} C ${sx + bend} ${sy}, ${ex - bend} ${ey}, ${ex} ${ey}"></path>`;
+ }
+ }
+ // A nested stream's own link: the call that opened it, and nothing else
+ // that call opened. It drops out of the call into the top of the child, and
+ // climbs the other way for a stream reporting back.
+ for (const fo of shown) {
+ if (state.folder ? state.folder !== fo.stream.name : fo.from.id !== state.sel) continue;
+ const a = pos.get(fo.from.id);
+ const b = fpos.get(fo.key);
+ if (!a || !b) continue;
+ const sx = fo.back ? b.x + 26 : a.cx;
+ const sy = fo.back ? b.y : a.y + 28;
+ const ex = fo.back ? a.cx : b.x + 26;
+ const ey = fo.back ? a.y + 28 : b.y;
+ const lift = Math.max(12, Math.abs(ey - sy) * 0.5);
+ const down = sy < ey ? 1 : -1;
+ const cls = fo.quality === 'exact_unique' ? '' : fo.quality === 'strong_inference' ? ' inferred' : ' weak';
+ html += `<path class="acv-edge drop${cls} selected" d="M ${sx} ${sy} C ${sx} ${sy + down * lift}, ${ex} ${ey - down * lift}, ${ex} ${ey}"></path>`;
+ }
+ return `${html}</svg>`;
+}
+
+/** Repaint the scrolled window: the ticks, the clips and the nested-stream
+ * clips whose x falls within a screen of the viewport on either side, plus
+ * the playhead. With no measured width (a test, a detached mount) everything
+ * is painted. */
+export function paintViewport(ctx: ViewContext): void {
+ const p = paints.get(ctx);
+ if (!p) return;
+ const { s, f, state } = ctx;
+ const scroll = ctx.q('.acv-tl-scroll');
+ const view = scroll.clientWidth;
+ const lo = view ? scroll.scrollLeft - view : -Infinity;
+ const hi = view ? scroll.scrollLeft + 2 * view : Infinity;
+ const inView = (x: number, w: number): boolean => x + w >= lo && x <= hi;
+ let html = '';
... 2175 lines suppressed ...