From e841e256737ecf8a44fd038a9601f601c17ff7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=85=E5=87=A4?= Date: Thu, 3 Sep 2026 04:00:33 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(web):=20=E8=81=8A=E5=A4=A9=E8=BF=87?= =?UTF-8?q?=E7=A8=8B=E7=BB=84=E5=A4=B4=E6=94=B9=E4=B8=BA=E4=B8=A4=E5=B1=82?= =?UTF-8?q?=E8=87=AA=E9=80=82=E5=BA=94=E6=A0=87=E9=A2=98=20+=20composer=20?= =?UTF-8?q?=E5=B8=B8=E9=A9=BB=E8=BF=90=E8=A1=8C=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 组头(运行中)从「只滚动最新一次调用」改为两层:上层=段级现在时动词 (Exploring/探索中,shimmer)+ 实时 bucket 计数,下层=now 行滚动当前调用 (复用 live-peek-line;展开组体时隐藏,避免一个状态两处渲染)。沉淀后组头 恢复最终聚合并追加段级 diff 汇总(+N/-N)。 composer dock 新增 runtime line:streaming 且未阻塞于审批/ask_user 时显示 工作中 + 已耗时 + 步数;跟随 composer 不随消息流滚走,回答「还活着吗」。 token 用量流里还没有,耗时+步数是诚实的客户端代理信号。 i18n 三语同改:新增 chat.process.phase.* 与 chat.runtime.working。 --- apps/web/src/i18n/locales/en.json | 14 ++ apps/web/src/i18n/locales/ja.json | 16 ++- apps/web/src/i18n/locales/zh.json | 14 ++ .../src/pages/home/components/chat-pane.vue | 35 +++++ .../pages/home/components/composer-dock.vue | 27 ++++ .../home/components/composer-runtime-line.vue | 89 ++++++++++++ .../home/components/tool-call-group.test.ts | 131 ++++++++++++++++-- .../pages/home/components/tool-call-group.vue | 116 ++++++++++++++-- 8 files changed, 415 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/pages/home/components/composer-runtime-line.vue diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 2d9554e09..d2e7fdb5e 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -666,6 +666,17 @@ "thought": "Thought", "thoughtBriefly": "Thought for a couple of seconds", "thoughtSeconds": "Thought for {seconds}s", + "phase": { + "browse": "Exploring", + "edit": "Editing", + "run": "Running", + "message": "Messaging", + "schedule": "Scheduling", + "media": "Generating", + "agent": "Delegating", + "gui": "Browsing", + "other": "Working" + }, "thinkingHints": [ "Connecting the dots", "Piecing it together", @@ -677,6 +688,9 @@ "Taking a closer look" ] }, + "runtime": { + "working": "Working" + }, "currentBot": "Current Bot", "inputPlaceholder": "Ask anything…", "readonlyHint": "This chat is read-only", diff --git a/apps/web/src/i18n/locales/ja.json b/apps/web/src/i18n/locales/ja.json index f6b437260..4e5209cff 100644 --- a/apps/web/src/i18n/locales/ja.json +++ b/apps/web/src/i18n/locales/ja.json @@ -649,7 +649,21 @@ "steps": "{count} ステップ", "thought": "考え", "thoughtBriefly": "少し考えた", - "thoughtSeconds": "{seconds}秒考えました" + "thoughtSeconds": "{seconds}秒考えました", + "phase": { + "browse": "探索中", + "edit": "編集中", + "run": "実行中", + "message": "送信中", + "schedule": "変更中", + "media": "生成中", + "agent": "委譲中", + "gui": "閲覧中", + "other": "作業中" + } + }, + "runtime": { + "working": "作業中" }, "currentBot": "現在のBot", "inputPlaceholder": "質問を入力してください…", diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json index 73c2fca8e..735408013 100644 --- a/apps/web/src/i18n/locales/zh.json +++ b/apps/web/src/i18n/locales/zh.json @@ -666,6 +666,17 @@ "thought": "已思考", "thoughtBriefly": "思考了几秒", "thoughtSeconds": "已思考 {seconds} 秒", + "phase": { + "browse": "探索中", + "edit": "修改中", + "run": "运行中", + "message": "发送中", + "schedule": "调整中", + "media": "生成中", + "agent": "委派中", + "gui": "浏览中", + "other": "处理中" + }, "thinkingHints": [ "正在连接线索", "正在拼合信息", @@ -677,6 +688,9 @@ "正在仔细查看" ] }, + "runtime": { + "working": "工作中" + }, "currentBot": "当前 Bot", "inputPlaceholder": "问点什么…", "readonlyHint": "该聊天为只读", diff --git a/apps/web/src/pages/home/components/chat-pane.vue b/apps/web/src/pages/home/components/chat-pane.vue index 0de2f20f2..d56104958 100644 --- a/apps/web/src/pages/home/components/chat-pane.vue +++ b/apps/web/src/pages/home/components/chat-pane.vue @@ -379,6 +379,9 @@ :command-panel="composerCommandPanel" :error-message="composerError" :pending-user-input="pendingUserInput" + :runtime-active="runtimeLineActive" + :runtime-started-at="runtimeLineStartedAt" + :runtime-steps="runtimeLineSteps" @select-command-item="selectCommandResultItem" @dismiss-command="clearCurrentCommandEvent" @reveal-composer="handleDockRevealComposer" @@ -1142,6 +1145,38 @@ const { items: pendingApprovals } = usePendingApprovals(messages) const hasPendingToolApproval = computed(() => pendingApprovals.value.length > 0) +// Inputs for the dock's runtime line. The line claims "the agent is working", +// so its active gate is stricter than `streaming` alone: a turn blocked on an +// approval or an ask_user answer is waiting on the USER, and the panel for +// that decision is already telling it — the line would argue with it. +const runtimeLineActive = computed(() => + streaming.value && !pendingUserInput.value && !hasPendingToolApproval.value) + +// The streaming assistant turn, when one is visible in this pane. Its +// timestamp is the turn's real start (survives pane remounts mid-stream); +// its tool-block count is the line's growing "steps" signal. +const streamingTurn = computed(() => { + if (!streaming.value) return null + for (let i = messages.value.length - 1; i >= 0; i -= 1) { + const message = messages.value[i]! + if (message.role === 'assistant' && message.streaming) return message + } + return null +}) + +const runtimeLineStartedAt = computed(() => { + const timestamp = streamingTurn.value?.timestamp + if (!timestamp) return null + const ms = Date.parse(timestamp) + return Number.isFinite(ms) ? ms : null +}) + +const runtimeLineSteps = computed(() => { + const turn = streamingTurn.value + if (!turn || turn.role !== 'assistant') return 0 + return turn.messages.filter(block => block.type === 'tool').length +}) + const canForkAssistant = computed(() => !streaming.value && !loadingMessages.value diff --git a/apps/web/src/pages/home/components/composer-dock.vue b/apps/web/src/pages/home/components/composer-dock.vue index 6dca61038..a80d0ec9b 100644 --- a/apps/web/src/pages/home/components/composer-dock.vue +++ b/apps/web/src/pages/home/components/composer-dock.vue @@ -26,6 +26,26 @@ :user-input="pendingUserInput" @reveal-composer="handleUserInputReveal" /> + + + +
() const emit = defineEmits<{ diff --git a/apps/web/src/pages/home/components/composer-runtime-line.vue b/apps/web/src/pages/home/components/composer-runtime-line.vue new file mode 100644 index 000000000..8f3562018 --- /dev/null +++ b/apps/web/src/pages/home/components/composer-runtime-line.vue @@ -0,0 +1,89 @@ + + + diff --git a/apps/web/src/pages/home/components/tool-call-group.test.ts b/apps/web/src/pages/home/components/tool-call-group.test.ts index 27ae39afd..f4e2a3990 100644 --- a/apps/web/src/pages/home/components/tool-call-group.test.ts +++ b/apps/web/src/pages/home/components/tool-call-group.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { createApp, defineComponent, h } from 'vue' +import { createApp, defineComponent, h, nextTick } from 'vue' import { createI18n } from 'vue-i18n' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ContentBlock, ThinkingBlock as ThinkingBlockType, ToolCallBlock as ToolCallBlockType } from '@/store/chat-list' @@ -14,10 +14,19 @@ vi.mock('@/i18n', () => ({ })) import ToolCallGroup from './tool-call-group.vue' -// Pins the ghost-"Thinking…" fix: the collapsed header is a live ticker only -// while the turn streams (`active`). A background task that outlives the turn -// must not pin the ticker — its `running` block used to keep the header saying -// "Thinking…" indefinitely on a finished turn (issue #1106). +// Two contracts pinned here: +// +// 1. Ghost-"Thinking…" fix (issue #1106): the collapsed header is a live +// two-layer summary only while the turn streams (`active`). A background +// task that outlives the turn must not pin the live header — its `running` +// block used to keep the header saying "Thinking…" indefinitely on a +// finished turn. +// +// 2. The two-layer adaptive header: while a segment streams, the header names +// the phase + live tally and a "now" line below rolls the current call; +// once the segment settles, the header shows the final tally plus the diff +// totals. This guards against regressing to a bare ticker of the latest +// call (which hid the aggregate view until the segment settled). interface MountedGroup { app: ReturnType @@ -33,6 +42,7 @@ afterEach(() => { } }) +let nextBlockId = 0 let nextMessageId = 0 function mountGroup(items: ContentBlock[], active: boolean | undefined): HTMLDivElement { @@ -50,8 +60,25 @@ function mountGroup(items: ContentBlock[], active: boolean | undefined): HTMLDiv en: { chat: { thinkingInProgress: 'Thinking', - process: { thought: 'Thought', steps: '{count} steps', run: 'Ran {count} commands' }, - tools: { run: 'Run' }, + process: { + thought: 'Thought', + steps: '{count} steps', + browse: 'Read {count} files', + edit: 'Edited {count} files', + run: 'Ran {count} commands', + phase: { + browse: 'Exploring', + edit: 'Editing', + run: 'Running', + message: 'Messaging', + schedule: 'Scheduling', + media: 'Generating', + agent: 'Delegating', + gui: 'Browsing', + other: 'Working', + }, + }, + tools: { run: 'Run', read: 'Read', exec: 'Run', edit: 'Edit' }, }, }, }, @@ -70,6 +97,10 @@ function headerText(root: HTMLElement): string { return header?.textContent ?? '' } +function nowLine(root: HTMLElement): HTMLElement | null { + return root.querySelector('.live-peek-line') +} + function bgExecTool(id: number, running: boolean): ToolCallBlockType { return { id, @@ -83,25 +114,103 @@ function bgExecTool(id: number, running: boolean): ToolCallBlockType { } as unknown as ToolCallBlockType } +function toolBlock(toolName: string, input: unknown, running = false): ToolCallBlockType { + const id = ++nextBlockId + return { + id, + type: 'tool', + name: toolName, + toolName, + input, + running, + done: !running, + tool_call_id: `tc-${id}`, + toolCallId: `tc-${id}`, + result: null, + } +} + function reasoning(id: number): ThinkingBlockType { return { id, type: 'reasoning', content: 'planning the tunnel' } as ThinkingBlockType } -describe('ToolCallGroup header ticker', () => { - it('shows the ticker label while the turn is streaming', () => { +describe('ToolCallGroup live header gating (#1106)', () => { + it('keeps the live two-layer header while the turn is streaming', () => { const root = mountGroup([bgExecTool(1, true), reasoning(2)], true) - expect(headerText(root)).toBe('Thinking') + // Header = the segment's phase (a command run), now line = the model's + // current step — the layering the old ticker-only header couldn't express. + expect(headerText(root)).toBe('Running') + expect(nowLine(root)?.textContent).toContain('Thinking') }) - it('drops the ticker when the turn finished even if a background tool is still running', () => { + it('drops the live header when the turn finished even if a background tool is still running', () => { const root = mountGroup([bgExecTool(1, true), reasoning(2)], false) // Aggregate summary, not "Thinking" — the model is done; only the spawned // command is alive, and its own row reports that. expect(headerText(root)).not.toBe('Thinking') + expect(nowLine(root)).toBeNull() }) it('shows the aggregate summary once the turn finished and tools settled', () => { const root = mountGroup([bgExecTool(1, false), reasoning(2)], false) expect(headerText(root)).not.toBe('Thinking') + expect(nowLine(root)).toBeNull() + }) +}) + +describe('ToolCallGroup adaptive header layers', () => { + it('names the phase and live tally in the header while streaming, and rolls the current call below', () => { + const root = mountGroup([ + toolBlock('read', { path: 'src/auth/session.ts' }), + toolBlock('read', { path: 'src/auth/cookie.ts' }), + toolBlock('exec', { command: 'pnpm test' }, true), + ], true) + + expect(headerText(root)).toContain('Exploring') + expect(headerText(root)).toContain('Read 2 files') + expect(headerText(root)).toContain('Ran 1 command') + expect(nowLine(root)?.textContent).toContain('pnpm test') + }) + + it('settles to the final tally with diff totals and no now line', () => { + const root = mountGroup([ + toolBlock('edit', { path: 'a.ts', old_text: 'l1\nl2\nl3', new_text: 'n1\nn2\nn3\nn4\nn5' }), + toolBlock('edit', { path: 'b.ts', old_text: 'l1\nl2', new_text: 'n1\nn2\nn3\nn4' }), + ], false) + + const header = headerText(root) + expect(header).toContain('Edited 2 files') + expect(header).not.toContain('Editing') + expect(nowLine(root)).toBeNull() + // Diff totals are separate mono spans next to the label, not part of the + // label text — assert them on the header row as a whole. + const row = root.querySelector('button') + expect(row?.textContent).toContain('+9') + expect(row?.textContent).toContain('-5') + }) + + it('keeps diff totals hidden while the segment is still streaming', () => { + const root = mountGroup([ + toolBlock('edit', { path: 'a.ts', old_text: 'l1', new_text: 'n1\nn2' }), + toolBlock('edit', { path: 'b.ts', old_text: 'l1', new_text: 'n1\nn2' }), + ], true) + + const row = root.querySelector('button') + expect(headerText(root)).toContain('Editing') + expect(row?.textContent).not.toContain('+4') + }) + + it('hides the now line once the user opens the body', async () => { + const root = mountGroup([ + toolBlock('read', { path: 'a.ts' }), + toolBlock('read', { path: 'b.ts' }), + toolBlock('exec', { command: 'ls' }, true), + ], true) + expect(nowLine(root)).not.toBeNull() + + root.querySelector('button')?.click() + await nextTick() + + expect(nowLine(root)).toBeNull() }) }) diff --git a/apps/web/src/pages/home/components/tool-call-group.vue b/apps/web/src/pages/home/components/tool-call-group.vue index ee0e6b89a..86ee43a05 100644 --- a/apps/web/src/pages/home/components/tool-call-group.vue +++ b/apps/web/src/pages/home/components/tool-call-group.vue @@ -38,12 +38,34 @@ class="min-w-0 truncate tracking-[0.01em]" :class="anyToolRunning || active ? 'tool-shimmer-text' : ''" >{{ headerLabel }} + + +{{ diffTotals.add }} + -{{ diffTotals.remove }} + + + - - -
() const emit = defineEmits<{ diff --git a/apps/web/src/pages/home/components/composer-runtime-line.vue b/apps/web/src/pages/home/components/composer-runtime-line.vue deleted file mode 100644 index 8f3562018..000000000 --- a/apps/web/src/pages/home/components/composer-runtime-line.vue +++ /dev/null @@ -1,89 +0,0 @@ - - - diff --git a/apps/web/src/pages/home/components/tool-call-group.test.ts b/apps/web/src/pages/home/components/tool-call-group.test.ts index f4e2a3990..7997b390a 100644 --- a/apps/web/src/pages/home/components/tool-call-group.test.ts +++ b/apps/web/src/pages/home/components/tool-call-group.test.ts @@ -22,11 +22,12 @@ import ToolCallGroup from './tool-call-group.vue' // block used to keep the header saying "Thinking…" indefinitely on a // finished turn. // -// 2. The two-layer adaptive header: while a segment streams, the header names -// the phase + live tally and a "now" line below rolls the current call; -// once the segment settles, the header shows the final tally plus the diff -// totals. This guards against regressing to a bare ticker of the latest -// call (which hid the aggregate view until the segment settled). +// 2. The two-layer adaptive header: while a segment streams, a muted phase +// verb ("Exploring") leads the header with live bare-count details +// ("2 files, ran 1 command"), and a "now" line below rolls the current +// call; once the segment settles, the verb turns past tense ("Explored") +// and diff totals appear. This guards against regressing to a bare ticker +// of the latest call (which hid the aggregate view until settle). interface MountedGroup { app: ReturnType @@ -63,22 +64,36 @@ function mountGroup(items: ContentBlock[], active: boolean | undefined): HTMLDiv process: { thought: 'Thought', steps: '{count} steps', - browse: 'Read {count} files', - edit: 'Edited {count} files', - run: 'Ran {count} commands', + fragmentSeparator: ', ', phase: { - browse: 'Exploring', - edit: 'Editing', - run: 'Running', - message: 'Messaging', - schedule: 'Scheduling', - media: 'Generating', - agent: 'Delegating', - gui: 'Browsing', - other: 'Working', + browse: { ing: 'Exploring', done: 'Explored' }, + edit: { ing: 'Editing', done: 'Edited' }, + run: { ing: 'Running', done: 'Ran' }, + message: { ing: 'Messaging', done: 'Sent' }, + schedule: { ing: 'Scheduling', done: 'Scheduled' }, + media: { ing: 'Generating', done: 'Generated' }, + agent: { ing: 'Delegating', done: 'Delegated' }, + gui: { ing: 'Browsing', done: 'Browsed' }, + other: { ing: 'Working', done: 'Worked' }, }, + frag: { + files: '{count} files', + searches: '{count} searches', + commands: '{count} commands', + messages: '{count} messages', + schedules: '{count} schedules', + media: '{count} media files', + agents: '{count} agents', + sites: '{count} sites', + }, + }, + tools: { + run: 'Run', + read: 'Read', + exec: 'Run', + edit: 'Edit', + pending: { generic: 'Working…' }, }, - tools: { run: 'Run', read: 'Read', exec: 'Run', edit: 'Edit' }, }, }, }, @@ -88,17 +103,15 @@ function mountGroup(items: ContentBlock[], active: boolean | undefined): HTMLDiv return root } +// The header row is the toggleable button; its whole text covers verb + +// details + diffs. function headerText(root: HTMLElement): string { - // The header label is the span inside the toggleable header row; the capsule - // body holds the per-item rows, so restricting to the first button-bound - // region would be brittle — take the first span with the tracking class. - const spans = [...root.querySelectorAll('span')] - const header = spans.find(span => (span.textContent ?? '').length > 0) - return header?.textContent ?? '' + const button = root.querySelector('button') + return button?.textContent ?? '' } function nowLine(root: HTMLElement): HTMLElement | null { - return root.querySelector('.live-peek-line') + return root.querySelector('.now-line') } function bgExecTool(id: number, running: boolean): ToolCallBlockType { @@ -137,21 +150,22 @@ function reasoning(id: number): ThinkingBlockType { describe('ToolCallGroup live header gating (#1106)', () => { it('keeps the live two-layer header while the turn is streaming', () => { const root = mountGroup([bgExecTool(1, true), reasoning(2)], true) - // Header = the segment's phase (a command run), now line = the model's - // current step — the layering the old ticker-only header couldn't express. - expect(headerText(root)).toBe('Running') + // Single-tool segment: header keeps the tool's own specific label; the now + // line carries the model's current step. No phase verb stutter ("Running + // Run xray run") and no bare ticker of the latest item. + expect(headerText(root)).toContain('xray run') expect(nowLine(root)?.textContent).toContain('Thinking') }) it('drops the live header when the turn finished even if a background tool is still running', () => { const root = mountGroup([bgExecTool(1, true), reasoning(2)], false) - // Aggregate summary, not "Thinking" — the model is done; only the spawned - // command is alive, and its own row reports that. + // Settled single-tool label, not "Thinking" — the model is done; only the + // spawned command is alive, and its own row reports that. expect(headerText(root)).not.toBe('Thinking') expect(nowLine(root)).toBeNull() }) - it('shows the aggregate summary once the turn finished and tools settled', () => { + it('shows the settled label once the turn finished and tools settled', () => { const root = mountGroup([bgExecTool(1, false), reasoning(2)], false) expect(headerText(root)).not.toBe('Thinking') expect(nowLine(root)).toBeNull() @@ -159,34 +173,33 @@ describe('ToolCallGroup live header gating (#1106)', () => { }) describe('ToolCallGroup adaptive header layers', () => { - it('names the phase and live tally in the header while streaming, and rolls the current call below', () => { + it('names the phase and live bare counts in the header, and rolls the current call below', () => { const root = mountGroup([ toolBlock('read', { path: 'src/auth/session.ts' }), toolBlock('read', { path: 'src/auth/cookie.ts' }), toolBlock('exec', { command: 'pnpm test' }, true), ], true) - expect(headerText(root)).toContain('Exploring') - expect(headerText(root)).toContain('Read 2 files') - expect(headerText(root)).toContain('Ran 1 command') + const header = headerText(root) + expect(header).toContain('Exploring') + expect(header).toContain('2 files') + expect(header).toContain('1 command') expect(nowLine(root)?.textContent).toContain('pnpm test') }) - it('settles to the final tally with diff totals and no now line', () => { + it('settles to a past-tense verb with final counts and diff totals', () => { const root = mountGroup([ toolBlock('edit', { path: 'a.ts', old_text: 'l1\nl2\nl3', new_text: 'n1\nn2\nn3\nn4\nn5' }), toolBlock('edit', { path: 'b.ts', old_text: 'l1\nl2', new_text: 'n1\nn2\nn3\nn4' }), ], false) const header = headerText(root) - expect(header).toContain('Edited 2 files') + expect(header).toContain('Edited') + expect(header).toContain('2 files') + expect(header).toContain('+9') + expect(header).toContain('-5') expect(header).not.toContain('Editing') expect(nowLine(root)).toBeNull() - // Diff totals are separate mono spans next to the label, not part of the - // label text — assert them on the header row as a whole. - const row = root.querySelector('button') - expect(row?.textContent).toContain('+9') - expect(row?.textContent).toContain('-5') }) it('keeps diff totals hidden while the segment is still streaming', () => { @@ -195,9 +208,42 @@ describe('ToolCallGroup adaptive header layers', () => { toolBlock('edit', { path: 'b.ts', old_text: 'l1', new_text: 'n1\nn2' }), ], true) - const row = root.querySelector('button') - expect(headerText(root)).toContain('Editing') - expect(row?.textContent).not.toContain('+4') + const header = headerText(root) + expect(header).toContain('Editing') + expect(header).not.toContain('+4') + }) + + it('keeps a lone live tool on its own specific label, no phase verb', () => { + const root = mountGroup([ + toolBlock('read', { path: 'src/main.ts' }), + reasoning(2), + ], true) + + const header = headerText(root) + expect(header).toContain('main.ts') + expect(header).not.toContain('Exploring') + expect(nowLine(root)?.textContent).toContain('Thinking') + }) + + it('keeps a lone settled tool on its own specific label, no verb', () => { + const root = mountGroup([ + toolBlock('exec', { command: 'pnpm build' }), + reasoning(2), + ], false) + + const header = headerText(root) + expect(header).toContain('pnpm build') + expect(header).not.toContain('Ran') + }) + + it('shows the pending label in the now line while a tool input is still streaming in', () => { + const root = mountGroup([ + toolBlock('read', { path: 'a.ts' }), + toolBlock('read', { path: 'b.ts' }), + toolBlock('exec', {}, true), + ], true) + + expect(nowLine(root)?.textContent).toContain('Working…') }) it('hides the now line once the user opens the body', async () => { diff --git a/apps/web/src/pages/home/components/tool-call-group.vue b/apps/web/src/pages/home/components/tool-call-group.vue index 86ee43a05..af90b7a84 100644 --- a/apps/web/src/pages/home/components/tool-call-group.vue +++ b/apps/web/src/pages/home/components/tool-call-group.vue @@ -34,7 +34,20 @@ :connector="item" />
+ + {{ headerLabel }} @@ -56,15 +69,21 @@ - +
+ class="now-line relative h-[1lh] overflow-hidden mt-0.5 text-cop-title" + > + +
+ +