mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-29 20:12:37 +00:00
feat(chat): keep the subagent detail transcript fully expanded without the run-end footer (#260)
* feat(chat): keep the subagent detail transcript fully expanded without the run-end footer * feat(chat): pin activity runs open in the subagent detail inspector * feat(chat): restore notification order in inspector blocks, pin thinking open, demote force-open heads to captions
This commit is contained in:
parent
d237663264
commit
824dca3db8
15 changed files with 233 additions and 36 deletions
5
.changeset/agent-detail-inspector.md
Normal file
5
.changeset/agent-detail-inspector.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"kimi-code-app": patch
|
||||
---
|
||||
|
||||
子代理详情面板不再折叠工作过程,并移除回合结尾的时间栏。
|
||||
|
|
@ -41,8 +41,11 @@ const props = withDefaults(
|
|||
may append). Keeps the row expanded and the summary in live shape even
|
||||
across the brief gap between two tool calls. */
|
||||
streaming?: boolean;
|
||||
/** Inspector mode (subagent detail panel): pin the body open — the head
|
||||
becomes a plain caption (no toggle, no chevron). */
|
||||
forceOpen?: boolean;
|
||||
}>(),
|
||||
{ mobile: false, streaming: false },
|
||||
{ mobile: false, streaming: false, forceOpen: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -78,6 +81,8 @@ const runStatus = computed<'running' | 'error' | 'done'>(() => {
|
|||
|
||||
// The default applies only at mount; manual toggles stick.
|
||||
const open = ref(runStatus.value === 'running');
|
||||
// Inspector mode pins the body open no matter what the fold state says.
|
||||
const effectiveOpen = computed(() => props.forceOpen || open.value);
|
||||
|
||||
const pinScroll = inject<(el: HTMLElement, ms?: number) => void>('pinScroll', () => {});
|
||||
const headEl = ref<HTMLElement | null>(null);
|
||||
|
|
@ -136,6 +141,7 @@ watch(
|
|||
);
|
||||
|
||||
function toggle(): void {
|
||||
if (props.forceOpen) return;
|
||||
open.value = !open.value;
|
||||
// A streaming run keeps growing on its own — no pin: the follow (or native
|
||||
// anchoring off-follow) absorbs the toggle (same rule as ThinkingBlock).
|
||||
|
|
@ -200,8 +206,15 @@ function isThinkingStreaming(item: ActivityItem): boolean {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="activity-run" :class="{ open }">
|
||||
<button ref="headEl" class="ar-head" type="button" :aria-expanded="open" @click="toggle">
|
||||
<div class="activity-run" :class="{ open: effectiveOpen }">
|
||||
<component
|
||||
:is="forceOpen ? 'div' : 'button'"
|
||||
ref="headEl"
|
||||
class="ar-head"
|
||||
:class="{ 'is-static': forceOpen }"
|
||||
v-bind="forceOpen ? {} : { type: 'button', 'aria-expanded': effectiveOpen }"
|
||||
@click="toggle"
|
||||
>
|
||||
<span
|
||||
class="ar-glyph"
|
||||
:class="{ run: runStatus === 'running', err: runStatus === 'error', ok: runStatus === 'done' }"
|
||||
|
|
@ -220,9 +233,9 @@ function isThinkingStreaming(item: ActivityItem): boolean {
|
|||
>
|
||||
</template>
|
||||
</span>
|
||||
<Icon class="ar-car" name="chevron-right" size="sm" aria-hidden="true" />
|
||||
</button>
|
||||
<div class="ar-body" :class="{ open }" :inert="!open">
|
||||
<Icon v-if="!forceOpen" class="ar-car" name="chevron-right" size="sm" aria-hidden="true" />
|
||||
</component>
|
||||
<div class="ar-body" :class="{ open: effectiveOpen }" :inert="!effectiveOpen">
|
||||
<div class="ar-body-inner">
|
||||
<template v-for="item in items" :key="itemKey(item)">
|
||||
<ThinkingBlock
|
||||
|
|
@ -232,6 +245,7 @@ function isThinkingStreaming(item: ActivityItem): boolean {
|
|||
:streaming="isThinkingStreaming(item)"
|
||||
:started-at="item.startedAt"
|
||||
:duration-ms="item.durationMs"
|
||||
:force-open="forceOpen"
|
||||
/>
|
||||
<ToolCall
|
||||
v-else
|
||||
|
|
@ -282,6 +296,12 @@ function isThinkingStreaming(item: ActivityItem): boolean {
|
|||
.ar-head:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
/* Inspector mode: the head is a plain caption, not a toggle. */
|
||||
.ar-head.is-static,
|
||||
.ar-head.is-static:hover {
|
||||
cursor: default;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
.ar-head:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent-soft);
|
||||
|
|
|
|||
|
|
@ -386,6 +386,7 @@ const subtitle = computed(() => {
|
|||
:loading-more-error="loadMoreError"
|
||||
:is-following="following"
|
||||
read-only
|
||||
inspector
|
||||
@load-older-messages="emit('loadOlderMessages')"
|
||||
@open-agent="emit('openAgent', $event)"
|
||||
@open-file="emit('openFile', $event)"
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { getKimiWebApi } from '../../api';
|
|||
import {
|
||||
formatTokens,
|
||||
isoMs,
|
||||
flattenAssistantFold,
|
||||
renderBlockKey,
|
||||
splitAssistantFold,
|
||||
turnActivitySeedMs,
|
||||
|
|
@ -36,7 +37,7 @@ import {
|
|||
turnToMarkdown,
|
||||
turnVisibleFinalText,
|
||||
} from '../chatTurnRendering';
|
||||
import type { AssistantFold, TurnFileChange } from '../chatTurnRendering';
|
||||
import type { AssistantFold, AssistantRenderBlock, TurnFileChange } from '../chatTurnRendering';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
|
@ -121,6 +122,10 @@ const props = withDefaults(
|
|||
isFollowing?: boolean;
|
||||
/** Suppress main-conversation mutation affordances in transcript-only views. */
|
||||
readOnly?: boolean;
|
||||
/** Inspector mode (subagent detail panel): keep every turn fully expanded
|
||||
(no TurnFold row) and skip the run-end footer — an inspection view
|
||||
exists to show the whole trajectory, so nothing folds away. */
|
||||
inspector?: boolean;
|
||||
/**
|
||||
* Pending user messages queued while the session is busy. Rendered inline
|
||||
* at the tail of the transcript (after the running turn) — click to edit,
|
||||
|
|
@ -168,6 +173,7 @@ const props = withDefaults(
|
|||
loadingMoreError: false,
|
||||
isFollowing: false,
|
||||
readOnly: false,
|
||||
inspector: false,
|
||||
queued: () => [],
|
||||
undoHintTurnId: null,
|
||||
interruptedTurnId: null,
|
||||
|
|
@ -837,6 +843,14 @@ function assistantFold(turn: ChatTurn): AssistantFold {
|
|||
return splitAssistantFold(turn);
|
||||
}
|
||||
|
||||
/** The blocks rendered inline after any TurnFold row. Inspector mode renders
|
||||
the folded prefix inline too — flattened back into source order (see
|
||||
flattenAssistantFold). Nothing folds away in an inspection view. */
|
||||
function assistantVisibleBlocks(turn: ChatTurn): AssistantRenderBlock[] {
|
||||
const fold = assistantFold(turn);
|
||||
return props.inspector ? flattenAssistantFold(fold) : fold.visible;
|
||||
}
|
||||
|
||||
/** The sourceIndex of the turn's live tail block while it streams; null for
|
||||
every settled turn (the TurnFold's streaming vocabulary). A settled
|
||||
thinking tail — or a tool tail awaiting a pending approval/question —
|
||||
|
|
@ -1052,7 +1066,7 @@ function streamingTailIndex(turn: ChatTurn): number | null {
|
|||
<span>{{ t('conversation.goal.continuation') }}</span>
|
||||
</div>
|
||||
<TurnFold
|
||||
v-if="assistantFold(turn).folded.length > 0"
|
||||
v-if="!inspector && assistantFold(turn).folded.length > 0"
|
||||
:items="assistantFold(turn).folded"
|
||||
mobile
|
||||
:streaming-tail-index="streamingTailIndex(turn)"
|
||||
|
|
@ -1066,14 +1080,15 @@ function streamingTailIndex(turn: ChatTurn): number | null {
|
|||
@open-file="onOpenFile"
|
||||
@open-agent="onOpenAgent"
|
||||
/>
|
||||
<template v-for="(blk, bi) in assistantFold(turn).visible" :key="renderBlockKey(blk, bi)">
|
||||
<ThinkingBlock v-if="blk.kind === 'thinking'" :text="blk.thinking" mobile :streaming="isStreamingRenderBlock(turn, blk)" :started-at="blk.startedAt" :duration-ms="blk.durationMs" />
|
||||
<template v-for="(blk, bi) in assistantVisibleBlocks(turn)" :key="renderBlockKey(blk, bi)">
|
||||
<ThinkingBlock v-if="blk.kind === 'thinking'" :text="blk.thinking" mobile :streaming="isStreamingRenderBlock(turn, blk)" :started-at="blk.startedAt" :duration-ms="blk.durationMs" :force-open="inspector" />
|
||||
<div v-else-if="blk.kind === 'text' && blk.text" class="msg"><Markdown :text="blk.text" :streaming="isStreamingRenderBlock(turn, blk)" :open-file="onOpenFile" /></div>
|
||||
<ActivityRun
|
||||
v-else-if="blk.kind === 'activity-run'"
|
||||
:items="blk.items"
|
||||
mobile
|
||||
:streaming="isStreamingActivityRun(turn, blk)"
|
||||
:force-open="inspector"
|
||||
@open-media="onOpenMedia"
|
||||
@open-file="onOpenFile"
|
||||
@open-agent="onOpenAgent"
|
||||
|
|
@ -1089,7 +1104,7 @@ function streamingTailIndex(turn: ChatTurn): number | null {
|
|||
@open-diff="onOpenTurnDiff"
|
||||
@open-file="onOpenFile"
|
||||
/>
|
||||
<div v-if="turn.id !== streamingTurnId && isAssistantRunEnd(ti) && assistantRunHasOutput(ti) && (assistantRunFinalText(ti).trim().length > 0 || turnTimeLabel(turn))" class="a-msg-ft">
|
||||
<div v-if="!inspector && turn.id !== streamingTurnId && isAssistantRunEnd(ti) && assistantRunHasOutput(ti) && (assistantRunFinalText(ti).trim().length > 0 || turnTimeLabel(turn))" class="a-msg-ft">
|
||||
<span v-if="turnTimeLabel(turn)" class="a-time">{{ turnTimeLabel(turn) }}</span>
|
||||
<Tooltip :text="t('filePreview.copy')">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -24,11 +24,16 @@ const props = withDefaults(
|
|||
(ISO) and how long it streamed (ms). Absent for history. */
|
||||
startedAt?: string;
|
||||
durationMs?: number;
|
||||
/** Inspector mode (subagent detail panel): pin the body open — the head
|
||||
becomes a plain caption (no toggle, no chevron). */
|
||||
forceOpen?: boolean;
|
||||
}>(),
|
||||
{ mobile: false, streaming: false, startedAt: undefined, durationMs: undefined },
|
||||
{ mobile: false, streaming: false, startedAt: undefined, durationMs: undefined, forceOpen: false },
|
||||
);
|
||||
|
||||
const open = ref(false);
|
||||
// Inspector mode pins the body open no matter what the fold state says.
|
||||
const effectiveOpen = computed(() => props.forceOpen || open.value);
|
||||
const { t } = useI18n();
|
||||
|
||||
// Thinking is done once the stream moves past this block: fold back even if
|
||||
|
|
@ -84,6 +89,7 @@ const bodyInnerEl = ref<HTMLElement | null>(null);
|
|||
const instant = ref(false);
|
||||
|
||||
function onHeadClick(): void {
|
||||
if (props.forceOpen) return;
|
||||
if (!open.value) {
|
||||
const tall =
|
||||
(bodyInnerEl.value?.scrollHeight ?? 0) >
|
||||
|
|
@ -101,14 +107,21 @@ function onHeadClick(): void {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="think" :class="{ mob: mobile, open, streaming }">
|
||||
<button ref="headEl" class="think-head" type="button" :aria-expanded="open" @click="onHeadClick">
|
||||
<div class="think" :class="{ mob: mobile, open: effectiveOpen, streaming }">
|
||||
<component
|
||||
:is="forceOpen ? 'div' : 'button'"
|
||||
ref="headEl"
|
||||
class="think-head"
|
||||
:class="{ 'is-static': forceOpen }"
|
||||
v-bind="forceOpen ? {} : { type: 'button', 'aria-expanded': effectiveOpen }"
|
||||
@click="onHeadClick"
|
||||
>
|
||||
<Icon class="think-bulb" name="thinking" size="sm" />
|
||||
<span class="think-title">{{ streaming ? t('thinking.streaming') : t('thinking.panelTitle') }}</span>
|
||||
<span v-if="elapsedLabel" class="think-time">{{ elapsedLabel }}</span>
|
||||
<Icon class="think-car" name="chevron-right" size="sm" />
|
||||
</button>
|
||||
<div class="think-body" :class="{ open, instant }" :inert="!open">
|
||||
<Icon v-if="!forceOpen" class="think-car" name="chevron-right" size="sm" />
|
||||
</component>
|
||||
<div class="think-body" :class="{ open: effectiveOpen, instant }" :inert="!effectiveOpen">
|
||||
<div ref="bodyInnerEl" class="think-body-inner">
|
||||
<pre class="think-text">{{ text }}</pre>
|
||||
</div>
|
||||
|
|
@ -148,6 +161,12 @@ function onHeadClick(): void {
|
|||
.think-head:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
/* Inspector mode: the head is a plain caption, not a toggle. */
|
||||
.think-head.is-static,
|
||||
.think-head.is-static:hover {
|
||||
cursor: default;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
.think-head:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent-soft);
|
||||
|
|
|
|||
|
|
@ -208,6 +208,17 @@ function computeAssistantFold(turn: ChatTurn): AssistantFold {
|
|||
return { folded, visible };
|
||||
}
|
||||
|
||||
/** Inspector-mode flattening of the fold split: every block inline, back in
|
||||
source order (the split punches notification cards out of the folded
|
||||
prefix into the visible tail, so a plain concat would misplace them). */
|
||||
export function flattenAssistantFold(fold: AssistantFold): AssistantRenderBlock[] {
|
||||
// An activity-run spans several source items and carries no index of its
|
||||
// own — its first item's position anchors it.
|
||||
const indexOf = (block: AssistantRenderBlock): number =>
|
||||
block.kind === 'activity-run' ? (block.items[0]?.sourceIndex ?? -1) : block.sourceIndex;
|
||||
return [...fold.folded, ...fold.visible].sort((a, b) => indexOf(a) - indexOf(b));
|
||||
}
|
||||
|
||||
/** Earliest thinking-block streaming-open stamp across the turn (renderer-
|
||||
measured, live sessions only) — seeds the turn-fold clock so the measured
|
||||
span covers the first step too. Undefined for history turns. */
|
||||
|
|
|
|||
|
|
@ -1314,7 +1314,7 @@ onUnmounted(() => {
|
|||
<li>Clicking a line <b>expands it in place</b>; the detail hangs below at the line's own left edge (no inset), so it reads as part of the stream rather than as a separate card. Details are one of: the mono output panel (content-well surface, hairline edge, 12-line scroll cap), the inline diff, or clickable match / file lists (<code>path:line</code> opens the preview at that line). Code-bearing details — the Read content, the Edit diff, the Write content — are <b>syntax-highlighted by file type</b> (github-light / github-dark, following the colour scheme), with the Read output's real line numbers as the gutter; highlighting mounts lazily on first expand and degrades to plain text for unknown languages or oversized content.</li>
|
||||
<li>Rows sit <b>flush with the message stream's left edge</b> (same alignment as prose and the thinking row): no inset, no hover wash, and the glyph rides the thinking row's 4px icon-to-text rhythm with no padded slot. Expanded rows inside a group stack directly on the shared rhythm — no dividers.</li>
|
||||
<li>Consecutive activity — thinking segments and tool calls of ANY kind, quiet lines and richer cards alike — <b>folds into ONE activity-run row</b>: a smart summary sentence that aggregates the run per tool kind in first-appearance order (<code>Read 2 files · Ran 5 commands (1 failed) · 26s</code>), the failure clause hanging on its kind in danger red, the total span faint at the tail — one line, ellipsis-truncated, the full sentence in the title tooltip. Thinking items fold into the run but are not narrated in the sentence. The row shares the thinking row's language (borderless faint text row, text-colour hover only, one whole-row button with a rotating chevron) but rides a roomier 8px vertical padding — 30px against the quiet lines' 22px, so the turn-level summary keeps its presence between prose paragraphs; while the turn streams through the run the row stays expanded and the summary turns live (current action + cumulative per-kind stats + ticking whole seconds), and once every item settles it folds itself back — even if the user expanded it mid-run (the thinking block's vocabulary); a settled → running transition (the stream appending to the same run) reopens it. The glyph carries the state: the current step's own icon breathing while running, green ✓ / red ✕ once settled. A run needs <b>≥ 2 steps</b> — a lone step renders standalone as the block it always was. <b>Text never folds</b> (it breaks the run), and neither do successful media tools (no card — inline media is the turn's output); everything else folds, cards included: Todo / Goal progress narration, the sub-agent identity card, Question / Swarm cards and unrecognized kinds (skills, MCP tools) all join the run — the stay-expanded-while-live rule keeps a card visible exactly while it is active. The expanded run is the items flat in order (thinking rows + tool rows), each with its own in-row details intact — the lines keep their own 4px row rhythm but breathe 8px apart, with a small inset below the head.</li>
|
||||
<li><b>Above the activity run sits the turn fold</b> (<code>TurnFold.vue</code>): when an assistant turn settles, every block before the LAST text block — thinking segments, activity runs, interim text paragraphs, Todo / Goal / sub-agent cards — folds into a single bare row reading <code>Worked 4m57s</code> (whole seconds, no glyph, no summary sentence), expanding into the folded blocks in order, each with its own rendering intact. The span is the turn's ELAPSED time (<code>turnWorkMs</code>): it ticks from the stamped start while the turn is open — approval/question waits included by design, so no park bookkeeping exists — then reads the daemon's own <code>durationMs</code> once settled (the server message stamps for history turns); the wall clock only feeds the live tick, so throttled tabs, session switches and remounts cannot corrupt the settled value. Without any stamp the row falls back to the generic <code>Work details</code>. Streaming turns show no row and a forced-open body — the live transcript is untouched, the fold lands only when the stream moves past the turn (or the turn parks). The split never hides the turn's output: the final text block and any trailing blocks (inline media, standalone cards) stay visible, and a text-only turn folds nothing. Fold state is a plain component ref — nothing persists, switching sessions resets to folded. Inside the right-side sub-agent transcript, disclosure bodies open instantly while their chevrons retain the standard rotation: animating the height of a full historical stream would relayout the entire panel on every animation frame.</li>
|
||||
<li><b>Above the activity run sits the turn fold</b> (<code>TurnFold.vue</code>): when an assistant turn settles, every block before the LAST text block — thinking segments, activity runs, interim text paragraphs, Todo / Goal / sub-agent cards — folds into a single bare row reading <code>Worked 4m57s</code> (whole seconds, no glyph, no summary sentence), expanding into the folded blocks in order, each with its own rendering intact. The span is the turn's ELAPSED time (<code>turnWorkMs</code>): it ticks from the stamped start while the turn is open — approval/question waits included by design, so no park bookkeeping exists — then reads the daemon's own <code>durationMs</code> once settled (the server message stamps for history turns); the wall clock only feeds the live tick, so throttled tabs, session switches and remounts cannot corrupt the settled value. Without any stamp the row falls back to the generic <code>Work details</code>. Streaming turns show no row and a forced-open body — the live transcript is untouched, the fold lands only when the stream moves past the turn (or the turn parks). The split never hides the turn's output: the final text block and any trailing blocks (inline media, standalone cards) stay visible, and a text-only turn folds nothing. Fold state is a plain component ref — nothing persists, switching sessions resets to folded. Inside the right-side sub-agent transcript (ChatPane's inspector mode), the turn fold and the run-end footer are suppressed entirely and activity runs and thinking blocks stay pinned open (their heads demoted to plain captions) — an inspection view exists to show the whole trajectory, so nothing folds away — and disclosure bodies open instantly while their chevrons retain the standard rotation: animating the height of a full historical stream would relayout the entire panel on every animation frame.</li>
|
||||
<li><b>A sub-agent delegation is an identity card</b> — never a quiet line: the card carries the TASK as its title and the agent type as a quiet meta line, while the orchestrator's full prompt stays out of the stream on purpose. The whole card is one action (the quiet shell vocabulary: raised surface, hairline edge, large radius, no shadow): click to open the subagent's live progress in the side panel — there is no in-stream expansion.</li>
|
||||
<li>Status keeps the shared vocabulary: running (pulsing accent dot) / done (green ✓) / failed (red ✗), at the line's right edge. <b>Only two types keep a full card</b>: <code>Question</code> and <code>Approval</code> — they genuinely need the user's attention. The Swarm composite keeps one quiet card (raised surface, 0.5px hairline, large radius) for its phase overview + member accordion.</li>
|
||||
<li><b>A task notification is a status card, not a quiet line</b> (<code>NotificationCard.vue</code>): the hidden <code><notification></code> injections (background-task / sub-agent settlement) render where they landed in the turn — a 28px status chip + title/sub head tinted with the toast status token pairs (completed → success, failed / timed_out / lost → danger, killed → warning, else neutral surface), expanding in place to the fields, the body, an output-file row (copy path) and the raw payload. ≥2 CONSECUTIVE notifications merge into one neutral group card (count + per-item status dots + compact rows, each expanding on its own). Notifications break the activity run but are never turn boundaries, and they <b>never fold</b> — a notification is an event worth noticing, not process noise, so it punches out of the turn fold and renders right after the fold row, in order.</li>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
formatTokens,
|
||||
rendersToolCard,
|
||||
renderBlockKey,
|
||||
flattenAssistantFold,
|
||||
splitAssistantFold,
|
||||
turnActivitySeedMs,
|
||||
turnBlocks,
|
||||
|
|
@ -724,3 +725,24 @@ describe('turnHasOutput', () => {
|
|||
expect(turnHasOutput(assistantTurn([ntfBlock('n')]))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenAssistantFold', () => {
|
||||
it('restores source order for notifications punched out of the folded prefix', () => {
|
||||
// A notification BETWEEN two tool runs is punched out of the folded
|
||||
// prefix into the visible tail by the split — a plain concat would move
|
||||
// it after the second run.
|
||||
const fold = splitAssistantFold(assistantTurn([
|
||||
toolBlock('a'),
|
||||
ntfBlock('n1'),
|
||||
toolBlock('b'),
|
||||
{ kind: 'text', text: 'done' },
|
||||
]));
|
||||
expect(fold.folded.map((b) => b.kind)).toEqual(['tool', 'tool']);
|
||||
expect(fold.visible[0]?.kind).toBe('notification');
|
||||
const flat = flattenAssistantFold(fold);
|
||||
expect(flat.map((b) => b.kind)).toEqual(['tool', 'notification', 'tool', 'text']);
|
||||
expect(
|
||||
flat.map((b) => (b.kind === 'activity-run' ? (b.items[0]?.sourceIndex ?? -1) : b.sourceIndex)),
|
||||
).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,8 +41,11 @@ const props = withDefaults(
|
|||
may append). Keeps the row expanded and the summary in live shape even
|
||||
across the brief gap between two tool calls. */
|
||||
streaming?: boolean;
|
||||
/** Inspector mode (subagent detail panel): pin the body open — the head
|
||||
becomes a plain caption (no toggle, no chevron). */
|
||||
forceOpen?: boolean;
|
||||
}>(),
|
||||
{ mobile: false, streaming: false },
|
||||
{ mobile: false, streaming: false, forceOpen: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -78,6 +81,8 @@ const runStatus = computed<'running' | 'error' | 'done'>(() => {
|
|||
|
||||
// The default applies only at mount; manual toggles stick.
|
||||
const open = ref(runStatus.value === 'running');
|
||||
// Inspector mode pins the body open no matter what the fold state says.
|
||||
const effectiveOpen = computed(() => props.forceOpen || open.value);
|
||||
|
||||
const pinScroll = inject<(el: HTMLElement, ms?: number) => void>('pinScroll', () => {});
|
||||
const headEl = ref<HTMLElement | null>(null);
|
||||
|
|
@ -136,6 +141,7 @@ watch(
|
|||
);
|
||||
|
||||
function toggle(): void {
|
||||
if (props.forceOpen) return;
|
||||
open.value = !open.value;
|
||||
// A streaming run keeps growing on its own — no pin: the follow (or native
|
||||
// anchoring off-follow) absorbs the toggle (same rule as ThinkingBlock).
|
||||
|
|
@ -200,8 +206,15 @@ function isThinkingStreaming(item: ActivityItem): boolean {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="activity-run" :class="{ open }">
|
||||
<button ref="headEl" class="ar-head" type="button" :aria-expanded="open" @click="toggle">
|
||||
<div class="activity-run" :class="{ open: effectiveOpen }">
|
||||
<component
|
||||
:is="forceOpen ? 'div' : 'button'"
|
||||
ref="headEl"
|
||||
class="ar-head"
|
||||
:class="{ 'is-static': forceOpen }"
|
||||
v-bind="forceOpen ? {} : { type: 'button', 'aria-expanded': effectiveOpen }"
|
||||
@click="toggle"
|
||||
>
|
||||
<span
|
||||
class="ar-glyph"
|
||||
:class="{ run: runStatus === 'running', err: runStatus === 'error', ok: runStatus === 'done' }"
|
||||
|
|
@ -220,9 +233,9 @@ function isThinkingStreaming(item: ActivityItem): boolean {
|
|||
>
|
||||
</template>
|
||||
</span>
|
||||
<Icon class="ar-car" name="chevron-right" size="sm" aria-hidden="true" />
|
||||
</button>
|
||||
<div class="ar-body" :class="{ open }" :inert="!open">
|
||||
<Icon v-if="!forceOpen" class="ar-car" name="chevron-right" size="sm" aria-hidden="true" />
|
||||
</component>
|
||||
<div class="ar-body" :class="{ open: effectiveOpen }" :inert="!effectiveOpen">
|
||||
<div class="ar-body-inner">
|
||||
<template v-for="item in items" :key="itemKey(item)">
|
||||
<ThinkingBlock
|
||||
|
|
@ -232,6 +245,7 @@ function isThinkingStreaming(item: ActivityItem): boolean {
|
|||
:streaming="isThinkingStreaming(item)"
|
||||
:started-at="item.startedAt"
|
||||
:duration-ms="item.durationMs"
|
||||
:force-open="forceOpen"
|
||||
/>
|
||||
<ToolCall
|
||||
v-else
|
||||
|
|
@ -282,6 +296,12 @@ function isThinkingStreaming(item: ActivityItem): boolean {
|
|||
.ar-head:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
/* Inspector mode: the head is a plain caption, not a toggle. */
|
||||
.ar-head.is-static,
|
||||
.ar-head.is-static:hover {
|
||||
cursor: default;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
.ar-head:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent-soft);
|
||||
|
|
|
|||
|
|
@ -385,6 +385,7 @@ const subtitle = computed(() => {
|
|||
:loading-more-error="loadMoreError"
|
||||
:is-following="following"
|
||||
read-only
|
||||
inspector
|
||||
@load-older-messages="emit('loadOlderMessages')"
|
||||
@open-agent="emit('openAgent', $event)"
|
||||
@open-file="emit('openFile', $event)"
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { getKimiWebApi } from '../../api';
|
|||
import {
|
||||
formatTokens,
|
||||
isoMs,
|
||||
flattenAssistantFold,
|
||||
renderBlockKey,
|
||||
splitAssistantFold,
|
||||
turnActivitySeedMs,
|
||||
|
|
@ -36,7 +37,7 @@ import {
|
|||
turnToMarkdown,
|
||||
turnVisibleFinalText,
|
||||
} from '../chatTurnRendering';
|
||||
import type { AssistantFold, TurnFileChange } from '../chatTurnRendering';
|
||||
import type { AssistantFold, AssistantRenderBlock, TurnFileChange } from '../chatTurnRendering';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
|
@ -121,6 +122,10 @@ const props = withDefaults(
|
|||
isFollowing?: boolean;
|
||||
/** Suppress main-conversation mutation affordances in transcript-only views. */
|
||||
readOnly?: boolean;
|
||||
/** Inspector mode (subagent detail panel): keep every turn fully expanded
|
||||
(no TurnFold row) and skip the run-end footer — an inspection view
|
||||
exists to show the whole trajectory, so nothing folds away. */
|
||||
inspector?: boolean;
|
||||
/**
|
||||
* Pending user messages queued while the session is busy. Rendered inline
|
||||
* at the tail of the transcript (after the running turn) — click to edit,
|
||||
|
|
@ -168,6 +173,7 @@ const props = withDefaults(
|
|||
loadingMoreError: false,
|
||||
isFollowing: false,
|
||||
readOnly: false,
|
||||
inspector: false,
|
||||
queued: () => [],
|
||||
undoHintTurnId: null,
|
||||
interruptedTurnId: null,
|
||||
|
|
@ -838,6 +844,14 @@ function assistantFold(turn: ChatTurn): AssistantFold {
|
|||
return splitAssistantFold(turn);
|
||||
}
|
||||
|
||||
/** The blocks rendered inline after any TurnFold row. Inspector mode renders
|
||||
the folded prefix inline too — flattened back into source order (see
|
||||
flattenAssistantFold). Nothing folds away in an inspection view. */
|
||||
function assistantVisibleBlocks(turn: ChatTurn): AssistantRenderBlock[] {
|
||||
const fold = assistantFold(turn);
|
||||
return props.inspector ? flattenAssistantFold(fold) : fold.visible;
|
||||
}
|
||||
|
||||
/** The sourceIndex of the turn's live tail block while it streams; null for
|
||||
every settled turn (the TurnFold's streaming vocabulary). A settled
|
||||
thinking tail — or a tool tail awaiting a pending approval/question —
|
||||
|
|
@ -1053,7 +1067,7 @@ function streamingTailIndex(turn: ChatTurn): number | null {
|
|||
<span>{{ t('conversation.goal.continuation') }}</span>
|
||||
</div>
|
||||
<TurnFold
|
||||
v-if="assistantFold(turn).folded.length > 0"
|
||||
v-if="!inspector && assistantFold(turn).folded.length > 0"
|
||||
:items="assistantFold(turn).folded"
|
||||
mobile
|
||||
:streaming-tail-index="streamingTailIndex(turn)"
|
||||
|
|
@ -1067,14 +1081,15 @@ function streamingTailIndex(turn: ChatTurn): number | null {
|
|||
@open-file="onOpenFile"
|
||||
@open-agent="onOpenAgent"
|
||||
/>
|
||||
<template v-for="(blk, bi) in assistantFold(turn).visible" :key="renderBlockKey(blk, bi)">
|
||||
<ThinkingBlock v-if="blk.kind === 'thinking'" :text="blk.thinking" mobile :streaming="isStreamingRenderBlock(turn, blk)" :started-at="blk.startedAt" :duration-ms="blk.durationMs" />
|
||||
<template v-for="(blk, bi) in assistantVisibleBlocks(turn)" :key="renderBlockKey(blk, bi)">
|
||||
<ThinkingBlock v-if="blk.kind === 'thinking'" :text="blk.thinking" mobile :streaming="isStreamingRenderBlock(turn, blk)" :started-at="blk.startedAt" :duration-ms="blk.durationMs" :force-open="inspector" />
|
||||
<div v-else-if="blk.kind === 'text' && blk.text" class="msg"><Markdown :text="blk.text" :streaming="isStreamingRenderBlock(turn, blk)" :open-file="onOpenFile" /></div>
|
||||
<ActivityRun
|
||||
v-else-if="blk.kind === 'activity-run'"
|
||||
:items="blk.items"
|
||||
mobile
|
||||
:streaming="isStreamingActivityRun(turn, blk)"
|
||||
:force-open="inspector"
|
||||
@open-media="onOpenMedia"
|
||||
@open-file="onOpenFile"
|
||||
@open-agent="onOpenAgent"
|
||||
|
|
@ -1090,7 +1105,7 @@ function streamingTailIndex(turn: ChatTurn): number | null {
|
|||
@open-diff="onOpenTurnDiff"
|
||||
@open-file="onOpenFile"
|
||||
/>
|
||||
<div v-if="turn.id !== streamingTurnId && isAssistantRunEnd(ti) && assistantRunHasOutput(ti) && (assistantRunFinalText(ti).trim().length > 0 || turnTimeLabel(turn))" class="a-msg-ft">
|
||||
<div v-if="!inspector && turn.id !== streamingTurnId && isAssistantRunEnd(ti) && assistantRunHasOutput(ti) && (assistantRunFinalText(ti).trim().length > 0 || turnTimeLabel(turn))" class="a-msg-ft">
|
||||
<span v-if="turnTimeLabel(turn)" class="a-time">{{ turnTimeLabel(turn) }}</span>
|
||||
<Tooltip :text="t('filePreview.copy')">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -23,11 +23,16 @@ const props = withDefaults(
|
|||
(ISO) and how long it streamed (ms). Absent for history. */
|
||||
startedAt?: string;
|
||||
durationMs?: number;
|
||||
/** Inspector mode (subagent detail panel): pin the body open — the head
|
||||
becomes a plain caption (no toggle, no chevron). */
|
||||
forceOpen?: boolean;
|
||||
}>(),
|
||||
{ mobile: false, streaming: false, startedAt: undefined, durationMs: undefined },
|
||||
{ mobile: false, streaming: false, startedAt: undefined, durationMs: undefined, forceOpen: false },
|
||||
);
|
||||
|
||||
const open = ref(false);
|
||||
// Inspector mode pins the body open no matter what the fold state says.
|
||||
const effectiveOpen = computed(() => props.forceOpen || open.value);
|
||||
const { t } = useI18n();
|
||||
|
||||
// Thinking is done once the stream moves past this block: fold back even if
|
||||
|
|
@ -83,6 +88,7 @@ const bodyInnerEl = ref<HTMLElement | null>(null);
|
|||
const instant = ref(false);
|
||||
|
||||
function onHeadClick(): void {
|
||||
if (props.forceOpen) return;
|
||||
if (!open.value) {
|
||||
const tall =
|
||||
(bodyInnerEl.value?.scrollHeight ?? 0) >
|
||||
|
|
@ -97,14 +103,21 @@ function onHeadClick(): void {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="think" :class="{ mob: mobile, open, streaming }">
|
||||
<button ref="headEl" class="think-head" type="button" :aria-expanded="open" @click="onHeadClick">
|
||||
<div class="think" :class="{ mob: mobile, open: effectiveOpen, streaming }">
|
||||
<component
|
||||
:is="forceOpen ? 'div' : 'button'"
|
||||
ref="headEl"
|
||||
class="think-head"
|
||||
:class="{ 'is-static': forceOpen }"
|
||||
v-bind="forceOpen ? {} : { type: 'button', 'aria-expanded': effectiveOpen }"
|
||||
@click="onHeadClick"
|
||||
>
|
||||
<Icon class="think-bulb" name="thinking" size="sm" />
|
||||
<span class="think-title">{{ streaming ? t('thinking.streaming') : t('thinking.panelTitle') }}</span>
|
||||
<span v-if="elapsedLabel" class="think-time">{{ elapsedLabel }}</span>
|
||||
<Icon class="think-car" name="chevron-right" size="sm" />
|
||||
</button>
|
||||
<div class="think-body" :class="{ open, instant }" :inert="!open">
|
||||
<Icon v-if="!forceOpen" class="think-car" name="chevron-right" size="sm" />
|
||||
</component>
|
||||
<div class="think-body" :class="{ open: effectiveOpen, instant }" :inert="!effectiveOpen">
|
||||
<div ref="bodyInnerEl" class="think-body-inner">
|
||||
<pre class="think-text">{{ text }}</pre>
|
||||
</div>
|
||||
|
|
@ -144,6 +157,12 @@ function onHeadClick(): void {
|
|||
.think-head:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
/* Inspector mode: the head is a plain caption, not a toggle. */
|
||||
.think-head.is-static,
|
||||
.think-head.is-static:hover {
|
||||
cursor: default;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
.think-head:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent-soft);
|
||||
|
|
|
|||
|
|
@ -208,6 +208,17 @@ function computeAssistantFold(turn: ChatTurn): AssistantFold {
|
|||
return { folded, visible };
|
||||
}
|
||||
|
||||
/** Inspector-mode flattening of the fold split: every block inline, back in
|
||||
source order (the split punches notification cards out of the folded
|
||||
prefix into the visible tail, so a plain concat would misplace them). */
|
||||
export function flattenAssistantFold(fold: AssistantFold): AssistantRenderBlock[] {
|
||||
// An activity-run spans several source items and carries no index of its
|
||||
// own — its first item's position anchors it.
|
||||
const indexOf = (block: AssistantRenderBlock): number =>
|
||||
block.kind === 'activity-run' ? (block.items[0]?.sourceIndex ?? -1) : block.sourceIndex;
|
||||
return [...fold.folded, ...fold.visible].sort((a, b) => indexOf(a) - indexOf(b));
|
||||
}
|
||||
|
||||
/** Earliest thinking-block streaming-open stamp across the turn (renderer-
|
||||
measured, live sessions only) — seeds the turn-fold clock so the measured
|
||||
span covers the first step too. Undefined for history turns. */
|
||||
|
|
|
|||
|
|
@ -1300,7 +1300,7 @@ onUnmounted(() => {
|
|||
<li>Clicking a line <b>expands it in place</b>; the detail hangs below at the line's own left edge (no inset), so it reads as part of the stream rather than as a separate card. Details are one of: the mono output panel (content-well surface, hairline edge, 12-line scroll cap), the inline diff, or clickable match / file lists (<code>path:line</code> opens the preview at that line). Code-bearing details — the Read content, the Edit diff, the Write content — are <b>syntax-highlighted by file type</b> (github-light / github-dark, following the colour scheme), with the Read output's real line numbers as the gutter; highlighting mounts lazily on first expand and degrades to plain text for unknown languages or oversized content.</li>
|
||||
<li>Rows sit <b>flush with the message stream's left edge</b> (same alignment as prose and the thinking row): no inset, no hover wash, and the glyph rides the thinking row's 4px icon-to-text rhythm with no padded slot. Expanded rows inside a group stack directly on the shared rhythm — no dividers.</li>
|
||||
<li>Consecutive activity — thinking segments and tool calls of ANY kind, quiet lines and richer cards alike — <b>folds into ONE activity-run row</b>: a smart summary sentence that aggregates the run per tool kind in first-appearance order (<code>Read 2 files · Ran 5 commands (1 failed) · 26s</code>), the failure clause hanging on its kind in danger red, the total span faint at the tail — one line, ellipsis-truncated, the full sentence in the title tooltip. Thinking items fold into the run but are not narrated in the sentence. The row shares the thinking row's language (borderless faint text row, text-colour hover only, one whole-row button with a rotating chevron) but rides a roomier 8px vertical padding — 30px against the quiet lines' 22px, so the turn-level summary keeps its presence between prose paragraphs; while the turn streams through the run the row stays expanded and the summary turns live (current action + cumulative per-kind stats + ticking whole seconds), and once every item settles it folds itself back — even if the user expanded it mid-run (the thinking block's vocabulary); a settled → running transition (the stream appending to the same run) reopens it. The glyph carries the state: the current step's own icon breathing while running, green ✓ / red ✕ once settled. A run needs <b>≥ 2 steps</b> — a lone step renders standalone as the block it always was. <b>Text never folds</b> (it breaks the run), and neither do successful media tools (no card — inline media is the turn's output); everything else folds, cards included: Todo / Goal progress narration, the sub-agent identity card, Question / Swarm cards and unrecognized kinds (skills, MCP tools) all join the run — the stay-expanded-while-live rule keeps a card visible exactly while it is active. The expanded run is the items flat in order (thinking rows + tool rows), each with its own in-row details intact — the lines keep their own 4px row rhythm but breathe 8px apart, with a small inset below the head.</li>
|
||||
<li><b>Above the activity run sits the turn fold</b> (<code>TurnFold.vue</code>): when an assistant turn settles, every block before the LAST text block — thinking segments, activity runs, interim text paragraphs, Todo / Goal / sub-agent cards — folds into a single bare row reading <code>Worked 4m57s</code> (whole seconds, no glyph, no summary sentence), expanding into the folded blocks in order, each with its own rendering intact. The span is the turn's ELAPSED time (<code>turnWorkMs</code>): it ticks from the stamped start while the turn is open — approval/question waits included by design, so no park bookkeeping exists — then reads the daemon's own <code>durationMs</code> once settled (the server message stamps for history turns); the wall clock only feeds the live tick, so throttled tabs, session switches and remounts cannot corrupt the settled value. Without any stamp the row falls back to the generic <code>Work details</code>. Streaming turns show no row and a forced-open body — the live transcript is untouched, the fold lands only when the stream moves past the turn (or the turn parks). The split never hides the turn's output: the final text block and any trailing blocks (inline media, standalone cards) stay visible, and a text-only turn folds nothing. Fold state is a plain component ref — nothing persists, switching sessions resets to folded. Inside the right-side sub-agent transcript, disclosure bodies open instantly while their chevrons retain the standard rotation: animating the height of a full historical stream would relayout the entire panel on every animation frame.</li>
|
||||
<li><b>Above the activity run sits the turn fold</b> (<code>TurnFold.vue</code>): when an assistant turn settles, every block before the LAST text block — thinking segments, activity runs, interim text paragraphs, Todo / Goal / sub-agent cards — folds into a single bare row reading <code>Worked 4m57s</code> (whole seconds, no glyph, no summary sentence), expanding into the folded blocks in order, each with its own rendering intact. The span is the turn's ELAPSED time (<code>turnWorkMs</code>): it ticks from the stamped start while the turn is open — approval/question waits included by design, so no park bookkeeping exists — then reads the daemon's own <code>durationMs</code> once settled (the server message stamps for history turns); the wall clock only feeds the live tick, so throttled tabs, session switches and remounts cannot corrupt the settled value. Without any stamp the row falls back to the generic <code>Work details</code>. Streaming turns show no row and a forced-open body — the live transcript is untouched, the fold lands only when the stream moves past the turn (or the turn parks). The split never hides the turn's output: the final text block and any trailing blocks (inline media, standalone cards) stay visible, and a text-only turn folds nothing. Fold state is a plain component ref — nothing persists, switching sessions resets to folded. Inside the right-side sub-agent transcript (ChatPane's inspector mode), the turn fold and the run-end footer are suppressed entirely and activity runs and thinking blocks stay pinned open (their heads demoted to plain captions) — an inspection view exists to show the whole trajectory, so nothing folds away — and disclosure bodies open instantly while their chevrons retain the standard rotation: animating the height of a full historical stream would relayout the entire panel on every animation frame.</li>
|
||||
<li><b>A sub-agent delegation is an identity card</b> — never a quiet line: the card carries the TASK as its title and the agent type as a quiet meta line, while the orchestrator's full prompt stays out of the stream on purpose. The whole card is one action (the quiet shell vocabulary: raised surface, hairline edge, large radius, no shadow): click to open the subagent's live progress in the side panel — there is no in-stream expansion.</li>
|
||||
<li>Status keeps the shared vocabulary: running (pulsing accent dot) / done (green ✓) / failed (red ✗), at the line's right edge. <b>Only two types keep a full card</b>: <code>Question</code> and <code>Approval</code> — they genuinely need the user's attention. The Swarm composite keeps one quiet card (raised surface, 0.5px hairline, large radius) for its phase overview + member accordion.</li>
|
||||
<li><b>A task notification is a status card, not a quiet line</b> (<code>NotificationCard.vue</code>): the hidden <code><notification></code> injections (background-task / sub-agent settlement) render where they landed in the turn — a 28px status chip + title/sub head tinted with the toast status token pairs (completed → success, failed / timed_out / lost → danger, killed → warning, else neutral surface), expanding in place to the fields, the body, an output-file row (copy path) and the raw payload. ≥2 CONSECUTIVE notifications merge into one neutral group card (count + per-item status dots + compact rows, each expanding on its own). Notifications break the activity run but are never turn boundaries, and they <b>never fold</b> — a notification is an event worth noticing, not process noise, so it punches out of the turn fold and renders right after the fold row, in order.</li>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
formatTokens,
|
||||
rendersToolCard,
|
||||
renderBlockKey,
|
||||
flattenAssistantFold,
|
||||
splitAssistantFold,
|
||||
turnActivitySeedMs,
|
||||
turnBlocks,
|
||||
|
|
@ -24,6 +25,22 @@ function toolBlock(id: string, over: Partial<ToolCall> = {}): Extract<TurnBlock,
|
|||
return { kind: 'tool', tool: tool(id, over) };
|
||||
}
|
||||
|
||||
function ntfBlock(id: string, type = 'task.completed'): Extract<TurnBlock, { kind: 'notification' }> {
|
||||
return {
|
||||
kind: 'notification',
|
||||
notification: {
|
||||
id,
|
||||
category: 'task',
|
||||
type,
|
||||
sourceKind: 'background_task',
|
||||
sourceId: id,
|
||||
title: 'task',
|
||||
body: `task ${id}`,
|
||||
raw: `<notification id="${id}"></notification>`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assistantTurn(blocks: TurnBlock[], over: Partial<ChatTurn> = {}): ChatTurn {
|
||||
return { id: 't1', role: 'assistant', no: 1, text: '', blocks, ...over };
|
||||
}
|
||||
|
|
@ -619,3 +636,24 @@ describe('turnFileChanges', () => {
|
|||
expect(change.diff!.some((l) => l.type === 'hunk')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenAssistantFold', () => {
|
||||
it('restores source order for notifications punched out of the folded prefix', () => {
|
||||
// A notification BETWEEN two tool runs is punched out of the folded
|
||||
// prefix into the visible tail by the split — a plain concat would move
|
||||
// it after the second run.
|
||||
const fold = splitAssistantFold(assistantTurn([
|
||||
toolBlock('a'),
|
||||
ntfBlock('n1'),
|
||||
toolBlock('b'),
|
||||
{ kind: 'text', text: 'done' },
|
||||
]));
|
||||
expect(fold.folded.map((b) => b.kind)).toEqual(['tool', 'tool']);
|
||||
expect(fold.visible[0]?.kind).toBe('notification');
|
||||
const flat = flattenAssistantFold(fold);
|
||||
expect(flat.map((b) => b.kind)).toEqual(['tool', 'notification', 'tool', 'text']);
|
||||
expect(
|
||||
flat.map((b) => (b.kind === 'activity-run' ? (b.items[0]?.sourceIndex ?? -1) : b.sourceIndex)),
|
||||
).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue