From c6423b70708974ef494ba5aa8823a3c6eb36bef1 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:56:34 -0800 Subject: [PATCH] fix(activity): Moved forward reads a post as one plain line, and the prod check measures Activity at its frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The d215 prod check for activity-busy missed by a wide margin on prod (1347 helper words against a target of 94, and scroll 29.9× against 1×). Two causes. The scorer measured the whole history. Needs you pages itself through every open item, which is 429 on lily-shen, and the frame draws 4. The Artifacts guard from #3376 is now a per-surface table, BUSY. For Activity it refuses the error line and the spinner, refuses a list shorter than the frame, and then measures 4 Needs you rows and 5 Moved forward lines. The table is keyed by surface name, so the prod surface list can't drop it. The page drew whole agent posts in Moved forward: at 390 a line ran five rows, with raw ** and backticks. The frame draws one sentence. Each line now: - reads the post as plain text: link text kept, markdown marks and list bullets dropped (snake_case survives); - marks a post the recap clipped at 180 characters with an ellipsis, and drops any mark the clip left unpaired; - stops at two rows (line clamp). After the fix, measured against prod data (own-origin vite, read-only): activity-busy HITs at 1440 (32/94, scroll 1) and at 390 (28/84, scroll 1.23/1.31). Page overflow is 0 in light and dark at both widths. artifacts-busy-390 is one helper word over its target (35/34) because the data has two-word author names. That's left as it is. Follow-up from the review round, folded in: From the review of the previous commit: - The two-row clamp hides the end of a long line, so the line carries the full author and text as its title. - Agent posts arrive with their whitespace already folded. Mid-line headings are dropped, a list reads "Done: a - b - c", and a test now uses that folded shape. - A post that is only a code fence keeps the code's words, not an empty line. A link the 180-character clip cut keeps its text. A clip that splits an emoji drops the lone half before the ellipsis. - Emphasis underscores are matched against any letter (\p{L}), so 中_文 survives, and __init__.py stays a file name. - The scorer test pins each BUSY selector and checks it is a real class in the page source. A mistyped refusal selector now fails it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DT6tbhKm6UhsmFhLk9 --- docs/design/artboards/d215/README.md | 2 +- .../artboards/d215/cap/measure_after.js | 54 +++++++++++------- .../artboards/d215/cap/measure_after.test.js | 57 +++++++++++++------ .../src/v2/__tests__/V2ActivityPage.test.tsx | 30 +++++++++- .../v2/__tests__/v2-layout-invariants.test.ts | 7 +++ frontend/src/v2/components/V2ActivityPage.css | 3 +- frontend/src/v2/components/V2ActivityPage.tsx | 40 ++++++++++++- 7 files changed, 151 insertions(+), 42 deletions(-) diff --git a/docs/design/artboards/d215/README.md b/docs/design/artboards/d215/README.md index 7279963ca3..0fbcb17ade 100644 --- a/docs/design/artboards/d215/README.md +++ b/docs/design/artboards/d215/README.md @@ -11,7 +11,7 @@ The spec for D215 (artboard v2). Open `index.html` in a browser. The toggle at t - `before/` holds prod captures from 2026-10-31, blurred so they show layout and density only. - `targets.json` holds the helper-word and scroll targets per frame. `cap/measure_after.js` writes it from `index.html`. - `cap/` holds the measuring scripts. See the header of `measure_after.js` for its env. -- Every frame without `data-state` must be listed in `measure_after.js`, either in `SURFACES` (scored) or in `SKIPPED` (not scored, with a reason). The two trace "before" frames, `run-trace-before` and `chat-trace-before`, draw the old pages and are skipped: they are never a target. On prod, `activity-busy` is measured at `/v2/activity` and `artifacts-busy` at `/v2/artifacts`; the two day-zero frames need an account with nothing in it, so they are artboard-only. The Artifacts prod check refuses the error line, the spinner and any list shorter than the frame's six rows. It then measures the first six rows, because prod returns up to 50 and the frame draws six. `node --test cap/measure_after.test.js` fails on any frame that is in neither list. +- Every frame without `data-state` must be listed in `measure_after.js`, either in `SURFACES` (scored) or in `SKIPPED` (not scored, with a reason). The two trace "before" frames, `run-trace-before` and `chat-trace-before`, draw the old pages and are skipped: they are never a target. On prod, `activity-busy` is measured at `/v2/activity` and `artifacts-busy` at `/v2/artifacts`; the two day-zero frames need an account with nothing in it, so they are artboard-only. The Activity and Artifacts prod checks (`BUSY` in `measure_after.js`) refuse the error line, the spinner and any list shorter than the frame. They then measure only as many rows as the frame draws, because prod shows the account's whole history. Artifacts is cut to 6 file rows. Activity is cut to 4 Needs you rows and 5 Moved forward lines; its Needs you list pages through every open item, which was 429 on lily-shen on 11-04. `node --test cap/measure_after.test.js` fails on any frame that is in neither list. ## Where each frame came from diff --git a/docs/design/artboards/d215/cap/measure_after.js b/docs/design/artboards/d215/cap/measure_after.js index 3cbea9ee31..d46b899135 100644 --- a/docs/design/artboards/d215/cap/measure_after.js +++ b/docs/design/artboards/d215/cap/measure_after.js @@ -190,16 +190,21 @@ async function checkSurface(p, [name, route, opt = {}], vp, targets) { if (!(await p.locator(opt.list390.list).first().isVisible())) throw new Error(`${name}-${vp}: pods list ${opt.list390.list} is not visible`); } assertLanded(url, p.url()); - if (opt.busy) { - const B = opt.busy; - if (await p.locator(B.refuse).first().isVisible()) throw new Error(`${name}-${vp}: the list did not load (${B.refuse} on screen in place of rows)`); - const n = await p.locator(B.rows).count(); - if (n < B.keep) throw new Error(`${name}-${vp}: ${n} ${B.rows} rows on the page; the busy frame draws ${B.keep}, so this is not the busy state`); - await p.evaluate(([rows, group, keep]) => { - document.querySelectorAll(rows).forEach((el, i) => { if (i >= keep) el.remove(); }); - document.querySelectorAll(group).forEach(g => { if (!g.querySelector(rows)) g.remove(); }); - }, [B.rows, B.group, B.keep]); + const B = BUSY[name]; + if (B) { + if (await p.locator(B.refuse).first().isVisible()) throw new Error(`${name}-${vp}: the page did not load (${B.refuse} on screen in place of rows)`); + for (const T of B.trim) { + const n = await p.locator(T.rows).count(); + if (n < T.keep) throw new Error(`${name}-${vp}: ${n} ${T.rows} rows on the page; the busy frame draws ${T.keep}, so this is not the busy state`); + } + await p.evaluate(trim => { + for (const { rows, group, keep } of trim) { + document.querySelectorAll(rows).forEach((el, i) => { if (i >= keep) el.remove(); }); + if (group) document.querySelectorAll(group).forEach(g => { if (!g.querySelector(rows)) g.remove(); }); + } + }, B.trim); } + const m = await p.evaluate(METRICS); const key = `${name}-${vp}`, t = targets[key]; return { key, m, t, ok: m.helperWords <= t.helperWords && m.innerRatio <= t.innerRatio }; @@ -214,15 +219,24 @@ const SIDEBAR_390 = { failed: '.v2-shell--list .v2-pods__empty:not(.v2-pods__list .v2-pods__empty)', }; -// Artifacts' busy frame draws six file rows; prod returns a page of up to 50. The scorer refuses -// the error line and the spinner, refuses a list shorter than the frame (an empty account or the -// day-zero page), and then measures the first six rows only, the way chat's scroll target is -// measured with its fold applied. Groups left with no rows go too. -const ARTIFACTS_BUSY = { - rows: '.v2a-file', - group: '.v2a-group', - refuse: '.v2a-state, .v2a-loading', - keep: 6, +// A busy frame draws a handful of rows; prod draws the account's whole history (Artifacts pages +// 50 files, Activity's Needs you pages itself through every open item: 429 on lily-shen, 11-04). +// For these surfaces the scorer refuses the error line and the spinner, refuses a list shorter +// than the frame (an empty account or the day-zero page), and then measures the frame's row +// count only, the way chat's scroll target is measured with its fold applied. A group left with +// no rows goes too. Keyed by surface name, so a prod surface list cannot drop it. +const BUSY = { + 'artifacts-busy': { + refuse: '.v2a-state, .v2a-loading', + trim: [{ rows: '.v2a-file', group: '.v2a-group', keep: 6 }], + }, + 'activity-busy': { + refuse: '.v2-activity__error, .v2-activity__loading', + trim: [ + { rows: '.v2-activity__queue > .v2-activity__queue-row', keep: 4 }, + { rows: '.v2-activity__moved-line', group: '.v2-activity__moved-group', keep: 5 }, + ], + }, }; async function prod(only) { @@ -241,7 +255,7 @@ async function prod(only) { ['connect', () => '/v2/connect'], ['team', () => '/v2/agents'], ['activity-busy', () => '/v2/activity'], - ['artifacts-busy', () => '/v2/artifacts', { busy: ARTIFACTS_BUSY }], + ['artifacts-busy', () => '/v2/artifacts'], ['chat', () => `/v2/pods/${pod}`], ['sidebar', vp => (vp === '1440' ? `/v2/pods/${side}` : '/v2'), { list390: SIDEBAR_390 }], ]; @@ -261,7 +275,7 @@ async function prod(only) { process.exit(miss ? 1 : 0); } -module.exports = { ARTIFACTS_BUSY, parseArgs, validateTargets, unknownFrameError, keyFrames, checkSurface, EXPECTED, SURFACES, SKIPPED, PROD_SURFACES, SIDEBAR_390 }; +module.exports = { BUSY, parseArgs, validateTargets, unknownFrameError, keyFrames, checkSurface, EXPECTED, SURFACES, SKIPPED, PROD_SURFACES, SIDEBAR_390 }; if (require.main === module) { let args; diff --git a/docs/design/artboards/d215/cap/measure_after.test.js b/docs/design/artboards/d215/cap/measure_after.test.js index d61219d507..4d48fc0d91 100644 --- a/docs/design/artboards/d215/cap/measure_after.test.js +++ b/docs/design/artboards/d215/cap/measure_after.test.js @@ -3,7 +3,7 @@ const test = require('node:test'); const assert = require('node:assert'); const fs = require('fs'); const path = require('path'); -const { ARTIFACTS_BUSY, parseArgs, validateTargets, unknownFrameError, keyFrames, checkSurface, EXPECTED, SURFACES, SKIPPED, PROD_SURFACES, SIDEBAR_390 } = require('./measure_after'); +const { BUSY, parseArgs, validateTargets, unknownFrameError, keyFrames, checkSurface, EXPECTED, SURFACES, SKIPPED, PROD_SURFACES, SIDEBAR_390 } = require('./measure_after'); const { assertLanded, BASE } = require('./lib'); const argv = (...a) => ['node', 'measure_after.js', ...a]; @@ -194,19 +194,20 @@ test('only the /v2 pod auto-select counts as landing', () => { assert.throws(() => assertLanded('/v2/settings', BASE + '/v2/pods/abc123'), /redirected/); }); -// The Artifacts page on its route, with `rows` file rows and optionally the error line or the -// spinner on screen. `trimmed` records the trim the scorer asked for before measuring. -const fakeArtifacts = ({ rows, refused = false }) => { +// A busy page on its route. `rows` maps each trim selector to its row count, and `refused` puts +// the error line or the spinner on screen. `trimmed` records the trim the scorer asked for. +const fakeBusy = (name, route, { rows, refused = false }) => { let url = 'about:blank'; + const B = BUSY[name]; const page = { trimmed: null, measured: 0, - goto: async () => { url = BASE + '/v2/artifacts'; return { ok: () => true, status: () => 200 }; }, + goto: async () => { url = BASE + route; return { ok: () => true, status: () => 200 }; }, waitForTimeout: async () => {}, url: () => url, locator: sel => { - if (sel === ARTIFACTS_BUSY.refuse) return { first: () => ({ isVisible: async () => refused }) }; - if (sel === ARTIFACTS_BUSY.rows) return { count: async () => rows }; + if (sel === B.refuse) return { first: () => ({ isVisible: async () => refused }) }; + if (sel in rows) return { count: async () => rows[sel] }; throw new Error(`unexpected locator ${sel}`); }, evaluate: async (fn, arg) => { @@ -218,17 +219,41 @@ const fakeArtifacts = ({ rows, refused = false }) => { return page; }; -test('artifacts-busy scores only a loaded list as long as the frame, measured at the frame\'s six rows', async () => { +test('a busy surface scores only a loaded list as long as its frame, trimmed to the frame', async () => { const t = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'targets.json'), 'utf8')); - const surf = ['artifacts-busy', () => '/v2/artifacts', { busy: ARTIFACTS_BUSY }]; - const failed = fakeArtifacts({ rows: 0, refused: true }); - await assert.rejects(checkSurface(failed, surf, '1440', t), /did not load/); + // Keyed by name: the plain prod tuple gets the guard, with nothing for SURF to forget. + const artifacts = ['artifacts-busy', () => '/v2/artifacts']; + const failed = fakeBusy('artifacts-busy', '/v2/artifacts', { rows: { '.v2a-file': 0 }, refused: true }); + await assert.rejects(checkSurface(failed, artifacts, '1440', t), /did not load/); assert.strictEqual(failed.measured, 0); - const short = fakeArtifacts({ rows: 2 }); - await assert.rejects(checkSurface(short, surf, '390', t), /2 \.v2a-file rows .* not the busy state/); + const short = fakeBusy('artifacts-busy', '/v2/artifacts', { rows: { '.v2a-file': 2 } }); + await assert.rejects(checkSurface(short, artifacts, '390', t), /2 \.v2a-file rows .* not the busy state/); assert.strictEqual(short.measured, 0); - const busy = fakeArtifacts({ rows: 50 }); - assert.strictEqual((await checkSurface(busy, surf, '1440', t)).ok, true); - assert.deepStrictEqual(busy.trimmed, ['.v2a-file', '.v2a-group', 6]); + const busy = fakeBusy('artifacts-busy', '/v2/artifacts', { rows: { '.v2a-file': 50 } }); + assert.strictEqual((await checkSurface(busy, artifacts, '1440', t)).ok, true); + assert.deepStrictEqual(busy.trimmed, [{ rows: '.v2a-file', group: '.v2a-group', keep: 6 }]); assert.strictEqual(busy.measured, 1); + + // Activity: Needs you pages itself through every open item, so both lists are trimmed to the frame. + const activity = ['activity-busy', () => '/v2/activity']; + const queue = '.v2-activity__queue > .v2-activity__queue-row', moved = '.v2-activity__moved-line'; + const thin = fakeBusy('activity-busy', '/v2/activity', { rows: { [queue]: 429, [moved]: 3 } }); + await assert.rejects(checkSurface(thin, activity, '390', t), /3 \.v2-activity__moved-line rows/); + const full = fakeBusy('activity-busy', '/v2/activity', { rows: { [queue]: 429, [moved]: 5 } }); + assert.strictEqual((await checkSurface(full, activity, '1440', t)).ok, true); + assert.deepStrictEqual(full.trimmed.map(x => [x.rows, x.keep]), [[queue, 4], [moved, 5]]); + // Every other prod surface is measured as it stands. + assert.deepStrictEqual(Object.keys(BUSY).sort(), ['activity-busy', 'artifacts-busy']); + // The selectors are the pages' real classes: a renamed or mistyped class would leave the + // refusal looking for nothing, so each one is pinned here and found in the page source. + assert.strictEqual(BUSY['activity-busy'].refuse, '.v2-activity__error, .v2-activity__loading'); + assert.strictEqual(BUSY['artifacts-busy'].refuse, '.v2a-state, .v2a-loading'); + const src = { + 'activity-busy': fs.readFileSync(path.join(__dirname, '../../../../../frontend/src/v2/components/V2ActivityPage.tsx'), 'utf8'), + 'artifacts-busy': fs.readFileSync(path.join(__dirname, '../../../../../frontend/src/v2/components/V2ArtifactsPage.tsx'), 'utf8'), + }; + for (const [name, B] of Object.entries(BUSY)) { + const sels = [B.refuse, ...B.trim.flatMap(x => [x.rows, x.group].filter(Boolean))].join(' '); + for (const cls of sels.match(/\.[\w-]+/g)) assert.ok(new RegExp(`className="[^"]*\\b${cls.slice(1)}\\b`).test(src[name]), `${name}: ${cls} is not a class in the page`); + } }); diff --git a/frontend/src/v2/__tests__/V2ActivityPage.test.tsx b/frontend/src/v2/__tests__/V2ActivityPage.test.tsx index 6ab7a13c15..cf0540fd39 100644 --- a/frontend/src/v2/__tests__/V2ActivityPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ActivityPage.test.tsx @@ -4,7 +4,7 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-librar import { MemoryRouter, useLocation } from 'react-router-dom'; import axios from 'axios'; import i18n, { i18nReady } from '../../i18n'; -import V2ActivityPage from '../components/V2ActivityPage'; +import V2ActivityPage, { plainLine } from '../components/V2ActivityPage'; import { ATTENTION_CHANGED } from '../hooks/useV2PodAttention'; import { AuthContext } from '../../context/AuthContext'; import { setupFocusManagement } from '../../utils/focusUtils'; @@ -1692,6 +1692,34 @@ describe('V2ActivityPage', () => { await waitFor(() => expect(within(group).getByRole('button', { name: '17 more in launch pod' })).toHaveFocus()); }); + test('a Moved forward line reads an agent post as one plain line, not raw markdown', async () => { + expect(plainLine('F82 walk — **correction:** the [Atlassian tools](https://x.test) are *on*\n\n- one\n- `two`')).toBe('F82 walk — correction: the Atlassian tools are on one two'); + expect(plainLine('## Shipped\n> quoted __done__ ~~old~~')).toBe('Shipped quoted done old'); + expect(plainLine('see ```js\nconst a = 1;\n``` then 2 * 3 = 6')).toBe('see then 2 * 3 = 6'); + expect(plainLine('snake_case_name stays')).toBe('snake_case_name stays'); + // The recap clips at 180: a clipped post drops the half-open mark and ends on an ellipsis. + const clipped = `What I saw:\n- \`atlassian_search ${'x'.repeat(200)}`.slice(0, 180); + expect(plainLine(clipped)).toMatch(/^What I saw: atlassian_search x+…$/); + expect(plainLine('Short and done.')).toBe('Short and done.'); + expect(plainLine('What I saw: - `atla')).toBe('What I saw: atla'); + expect(plainLine('pages 2 - 4 moved')).toBe('pages 2 - 4 moved'); + // The shape agent posts arrive in: whitespace already folded by the recap. + expect(plainLine('Done: - a - b - c ## Shipped **ok**')).toBe('Done: a - b - c Shipped ok'); + expect(plainLine('```\nlog line\n```')).toBe('log line'); + expect(plainLine('中_文_字 and __init__.py stay')).toBe('中_文_字 and __init__.py stay'); + const link = `${'y'.repeat(160)} see [the doc](https://example.com/aaaaaaaaaaaa`.slice(0, 180); + expect(plainLine(link)).toMatch(/ see the doc…$/); + const emoji = `${'x'.repeat(179)}🎉`.slice(0, 180); + expect(plainLine(emoji)).toBe(`${'x'.repeat(179)}…`); + const updates = [{ id: 'md-1', podId: 'pod-1', podName: 'Launch pod', content: '**Walked** the [connectors](https://x.test) page\n\n- all green', timestamp: '2026-08-26T11:00:00.000Z' }]; + mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue' ? decisionQueue : { ...recap, agents: [{ ...recap.agents[0], updates }], board: [] } })); + renderPage(); + expect(await screen.findByText('Walked the connectors page all green')).toBeInTheDocument(); + expect(screen.queryByText(/\*\*/)).toBeNull(); + // The clamp hides the end of a long line; the whole line stays in the title. + expect(screen.getByText('Walked the connectors page all green').closest('.v2-activity__moved-text')).toHaveAttribute('title', expect.stringContaining('Walked the connectors page all green')); + }); + test('revalidates the loaded Back extent and preserves the same account draft', async () => { const loaded = Array.from({ length: 56 }, (_, index) => ({ id: `revalidated-${index}`, attentionItemId: `507f1f77bcf86cd7994390${String(index).padStart(2, '0')}`, diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 9a93ff8e6b..90fbf73dce 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -5998,6 +5998,13 @@ describe('Activity built to the d215 signal frames (#s-activity-signal)', () => const phone = blockContaining(css, '@media (max-width: 760px)', '.v2-activity__body'); const rule = (selector: string, within?: string) => ruleBody(css, selector, within ? { within } : {}); + test('a Moved forward line stops at two lines, the way the frame draws one sentence', () => { + const text = rule('.v2-activity__moved-text'); + expect(text).toContain('-webkit-line-clamp: 2'); + expect(text).toContain('display: -webkit-box'); + expect(text).toContain('overflow: hidden'); + }); + test('the page owns its pane: the head stays, the body scrolls, the column caps at 760', () => { expect(rule('.v2-feature--activity > .v2-feature__body')).toContain('overflow: hidden'); expect(rule('.v2-activity__body')).toContain('overflow-y: auto'); diff --git a/frontend/src/v2/components/V2ActivityPage.css b/frontend/src/v2/components/V2ActivityPage.css index 33b97ba50c..9afbf4b60d 100644 --- a/frontend/src/v2/components/V2ActivityPage.css +++ b/frontend/src/v2/components/V2ActivityPage.css @@ -265,7 +265,8 @@ .v2-activity__moved-head span + span { margin-left: auto; color: var(--v2-text-muted); } .v2-activity__moved-line { display: grid; grid-template-columns: 44px minmax(0, 1fr); gap: 12px; padding: 9px 14px; border-top: 1px solid var(--v2-border-soft); font: 400 13.5px/20px var(--v2-font); color: var(--v2-ink-2); } .v2-activity__moved-line time { font: 500 11.5px/20px var(--v2-font-mono); color: var(--v2-text-muted); white-space: nowrap; } -.v2-activity__moved-text { min-width: 0; overflow-wrap: anywhere; } +/* d215 draws each line as one sentence; a long agent post stops at two lines. */ +.v2-activity__moved-text { min-width: 0; overflow: hidden; overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; } /* zh times ("28分钟前") run 49px, so the column widens to keep the 12px gap. */ .v2-root:lang(zh) .v2-activity__moved-line { grid-template-columns: 52px minmax(0, 1fr); } .v2-activity__moved-text strong { font-weight: 600; color: var(--v2-text-primary); } diff --git a/frontend/src/v2/components/V2ActivityPage.tsx b/frontend/src/v2/components/V2ActivityPage.tsx index 43fe3c1e68..8765747710 100644 --- a/frontend/src/v2/components/V2ActivityPage.tsx +++ b/frontend/src/v2/components/V2ActivityPage.tsx @@ -152,6 +152,40 @@ const readActivitySnapshot = (accountId: string): ActivitySnapshot | null => { } }; +// The recap clips each agent post and task note at 180 characters, and folds an agent post's +// whitespace to single spaces first (backend/services/activityService.ts, `content.slice(0, 180)` +// and `lastUpdate.text.slice(0, 180)`). Keep the two in step. +const RECAP_CLIP = 180; + +// A Moved forward line is a one-line digest of an agent post or a task note, so the post's +// markdown is read as plain text: link text kept, emphasis and code marks dropped, headings and +// a leading list bullet dropped, every line break folded into one space. Agent posts arrive +// already folded, so a list inside one reads "Done: a - b - c": the first bullet after the +// colon goes, the rest stay as dashes. A mark or link the clip left half-open goes too, a +// clipped post ends on an ellipsis, and a post that was only code keeps its words. The CSS +// clamps the line to two rows, and the full line is the element's title. +export const plainLine = (value: string): string => { + const clipped = value.length >= RECAP_CLIP; + const fold = (text: string) => text.replace(/\s+/g, ' ').trim(); + const fence = /```[^\n]*\n?([\s\S]*?)(?:```|$)/g; + const prose = value.replace(fence, ' '); + let text = (fold(prose) ? prose : value.replace(fence, ' $1 ')) + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1'); + if (clipped) text = text.replace(/!?\[([^\]]*)\]\([^)]*$/, '$1').replace(/!?\[([^\]]*)$/, '$1'); + text = text + .replace(/^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+|\d+\.\s+)/gm, '') + .replace(/(^|\s)#{1,6}\s+/g, '$1') + .replace(/(\*\*|\*|~~|`)(?=\S)([^\n]*?\S)\1/g, '$2') + // Underscores mark emphasis only at a word edge, so snake_case, 中_文 and __init__.py survive. + .replace(/(^|[^\p{L}\p{N}_])(__|_)(?=\S)([^\n]*?\S)\2(?![\p{L}\p{N}_]|\.[\p{L}\p{N}])/gu, '$1$3') + .replace(/\*\*|`|~~/g, '') + .replace(/:\s+[-*+]\s+/g, ': '); + text = fold(text); + if (!clipped) return text; + // The backend cuts in UTF-16 units, so a clip can end on half an emoji. + return `${text.replace(/[\uD800-\uDBFF]$/, '').replace(/[\s\-–—:;,.]+$/, '')}…`; +}; + const relativeTime = (value: string | null | undefined): string => { if (!value) return ''; const elapsed = Math.max(0, Date.now() - new Date(value).getTime()); @@ -964,7 +998,7 @@ const V2ActivityPage: React.FC = () => { recap.agents.forEach((agent) => agent.updates.forEach((update) => add(update.podId, update.podName, { id: `agent:${agent.id}:${update.id}`, author: agent.name, - text: update.content, + text: plainLine(update.content), timestamp: update.timestamp, }))); recap.board.forEach((item) => { @@ -972,7 +1006,7 @@ const V2ActivityPage: React.FC = () => { add(item.podId, item.podName, { id: `board:${item.id}`, author: item.lastUpdate.author, - text: `${item.title} — ${item.lastUpdate.text}`, + text: `${item.title} — ${plainLine(item.lastUpdate.text)}`, timestamp: item.lastUpdate.createdAt || item.updatedAt, }); }); @@ -1652,7 +1686,7 @@ const V2ActivityPage: React.FC = () => { {visible.map((line) => (
- {line.author}{' '}{line.text} + {line.author}{' '}{line.text}
))} {visibleCount > 3 &&