From 7e5384ca072114293fda91c23d50fa98565d683c Mon Sep 17 00:00:00 2001 From: xarmian Date: Thu, 29 Oct 2026 02:59:03 -0400 Subject: [PATCH] feat(chat): refs in chat bodies, and the Chat entries in /console and the Sidebar (TASK-4044 PR 3c) (#2340) * feat(chat): ref tokenising and the Go-parity trim for chat bodies (TASK-4044 PR 3c) refs.ts and refs.test.ts from the parked 98247acb8, with one deliberate divergence (D4): the three String.trim() calls are presence.ts trimSpace, matching Go's strings.TrimSpace on U+FEFF and U+0085. Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v * feat(chat): the thread collects ref verdicts from every page and send (TASK-4044 PR 3c) Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v * feat(chat): render visible refs as links and restricted ones as a placeholder (TASK-4044 PR 3c) Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v * feat(chat): the Chat entry in the /console nav bar (TASK-4044 PR 3c) Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v * feat(chat): the Chat entry in the workspace sidebar, gated on sign-in (TASK-4044 PR 3c) Option C, its own commit so it can be dropped alone. Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v * test(chat): e2e for refs in bodies and the Chat entries (TASK-4044 PR 3c) Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v * docs(chat): the refs disclosure names arrival-order merging (TASK-4044 PR 3c) Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v --- web/e2e/chat-entries.spec.ts | 130 +++++++ web/e2e/chat-thread-refs.spec.ts | 196 +++++++++++ web/src/lib/chat/refs.test.ts | 120 +++++++ web/src/lib/chat/refs.ts | 323 ++++++++++++++++++ web/src/lib/chat/sourceGuards.test.ts | 27 ++ web/src/lib/components/chat/ChatLink.svelte | 13 +- .../lib/components/chat/MessageBubble.svelte | 30 +- .../chat/MessageBubble.svelte.test.ts | 93 ++++- .../layout/Sidebar.chatEntry.svelte.test.ts | 79 +++++ web/src/lib/components/layout/Sidebar.svelte | 22 ++ web/src/lib/stores/chatThread.svelte.test.ts | 259 +++++++++++++- web/src/lib/stores/chatThread.svelte.ts | 67 +++- web/src/routes/console/+layout.svelte | 30 ++ .../console/consoleChatEntry.svelte.test.ts | 68 ++++ web/src/routes/messages/[n]/+page.svelte | 2 + 15 files changed, 1436 insertions(+), 23 deletions(-) create mode 100644 web/e2e/chat-entries.spec.ts create mode 100644 web/e2e/chat-thread-refs.spec.ts create mode 100644 web/src/lib/chat/refs.test.ts create mode 100644 web/src/lib/chat/refs.ts create mode 100644 web/src/lib/chat/sourceGuards.test.ts create mode 100644 web/src/lib/components/layout/Sidebar.chatEntry.svelte.test.ts create mode 100644 web/src/routes/console/consoleChatEntry.svelte.test.ts diff --git a/web/e2e/chat-entries.spec.ts b/web/e2e/chat-entries.spec.ts new file mode 100644 index 000000000..e4da09331 --- /dev/null +++ b/web/e2e/chat-entries.spec.ts @@ -0,0 +1,130 @@ +import { test, expect } from './fixtures'; +import type { APIRequestContext, Page } from '@playwright/test'; + +/** + * TASK-4044 PR 3c (ruling 687eb810, Option C): the Chat entries on the + * workspace sidebar and on the /console nav bar, against the REAL server. + * + * Layout is the question (the sidebar footer row once overflowed on desktop, + * comment 89264cdd), and jsdom does no layout, so this runs in a real engine at + * the 1280 desktop width (255 px sidebar) and in the mobile drawer. The badge is + * compared with GET /api/v1/me/chat/unread `total`, never with a constant. + */ + +type Fixture = { apiToken: string; baseURL: string; workspaceSlug: string; adminUsername: string }; + +const headers = (f: Fixture, agent?: string) => ({ + Authorization: `Bearer ${f.apiToken}`, + 'Content-Type': 'application/json', + ...(agent ? { 'X-Pad-Agent': agent } : {}) +}); + +async function seedUnread(request: APIRequestContext, f: Fixture): Promise { + const agent = `e2e-entry-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; + const dm = await request.post('/api/v1/me/conversations', { headers: headers(f), data: { agent_key: agent } }); + expect(dm.ok(), await dm.text()).toBe(true); + const n = ((await dm.json()) as { conversation: { number: number } }).conversation.number; + for (const body of ['one', 'two']) { + const res = await request.post(`/api/v1/me/conversations/${n}/messages`, { + headers: headers(f, agent), + data: { body } + }); + expect(res.status(), await res.text()).toBe(201); + } +} + +async function unreadTotal(request: APIRequestContext, f: Fixture): Promise { + const res = await request.get('/api/v1/me/chat/unread', { headers: headers(f) }); + expect(res.ok(), await res.text()).toBe(true); + return ((await res.json()) as { total: number }).total; +} + +const label = (total: number) => (total > 99 ? '99+' : String(total)); + +async function expectBadgeMatchesServer(page: Page, request: APIRequestContext, f: Fixture, entry: string) { + // Other specs share this admin's inbox, so the server total is read again on each poll. + await expect + .poll( + async () => { + const total = await unreadTotal(request, f); + const badge = page.locator(`${entry} .unread-badge`); + const shown = (await badge.count()) ? ((await badge.first().textContent()) ?? '').trim() : ''; + return shown === (total > 0 ? label(total) : ''); + }, + { timeout: 15_000 } + ) + .toBe(true); +} + +async function noHorizontalOverflow(page: Page, selector: string) { + const m = await page.locator(selector).first().evaluate((e) => ({ sw: e.scrollWidth, cw: e.clientWidth })); + expect(m.sw, `${selector} scrollWidth ${m.sw} > clientWidth ${m.cw}`).toBeLessThanOrEqual(m.cw); +} + +test('E9: the sidebar Chat entry at desktop 1280 (255 px sidebar)', async ({ page, request, fixture }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'desktop only'); + await seedUnread(request, fixture); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}`); + const entry = 'nav.collection-nav a.nav-item[href="/messages"]'; + await expect(page.locator(entry)).toBeVisible(); + await expect(page.locator(entry)).toHaveAttribute('href', '/messages'); + const sidebar = await page.locator('aside.sidebar').evaluate((e) => e.getBoundingClientRect().width); + // Measured 260 px outer (255 px inside its 1 px border and scrollbar allowance). + expect(Math.round(sidebar)).toBeGreaterThanOrEqual(255); + expect(Math.round(sidebar)).toBeLessThanOrEqual(260); + expect(await unreadTotal(request, fixture)).toBeGreaterThan(0); + await expectBadgeMatchesServer(page, request, fixture, entry); + await noHorizontalOverflow(page, 'nav.collection-nav'); + await noHorizontalOverflow(page, 'aside.sidebar'); + await noHorizontalOverflow(page, 'aside.sidebar .sidebar-inner'); + await page.locator(entry).click(); + await page.waitForURL((u) => u.pathname === '/messages'); +}); + +test('E9: the sidebar Chat entry in the mobile drawer', async ({ page, request, fixture }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'one project drives both widths'); + await seedUnread(request, fixture); + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}`); + // The drawer starts closed; open it as sidebar-footer-row.spec.ts does. + const toggle = page.locator('button[aria-label*="idebar" i]').first(); + if (await toggle.count()) await toggle.click().catch(() => {}); + const entry = 'nav.collection-nav a.nav-item[href="/messages"]'; + await expect(page.locator(entry)).toBeVisible(); + await expect(page.locator(entry)).toHaveAttribute('href', '/messages'); + await expectBadgeMatchesServer(page, request, fixture, entry); + await noHorizontalOverflow(page, 'nav.collection-nav'); + await noHorizontalOverflow(page, 'aside.sidebar'); +}); + +test('E9: the /console nav shows the Chat link with the same badge', async ({ page, request, fixture }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'desktop only'); + await seedUnread(request, fixture); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto('/console'); + const entry = '.console-nav .nav-links a[href="/messages"]'; + await expect(page.locator(entry)).toBeVisible(); + await expect(page.locator(entry)).toHaveText(/Chat/); + await expectBadgeMatchesServer(page, request, fixture, entry); + await noHorizontalOverflow(page, '.console-nav'); + await noHorizontalOverflow(page, '.console-nav .nav-links'); + // Sits right after "Needs you". + const hrefs = await page.locator('.console-nav .nav-links a').evaluateAll((els) => els.map((e) => e.getAttribute('href'))); + expect(hrefs.indexOf('/messages')).toBe(hrefs.indexOf('/console/needs') + 1); +}); + +test('E9: the /console Chat link works from the mobile menu', async ({ page, request, fixture }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'one project drives both widths'); + await seedUnread(request, fixture); + // The console menu collapses at max-width 640px; the sidebar's drawer at 768px. + await page.setViewportSize({ width: 600, height: 900 }); + await page.goto('/console'); + await page.locator('.mobile-hamburger').click(); + const entry = '.console-nav .nav-links.open a[href="/messages"]'; + await expect(page.locator(entry)).toBeVisible(); + await expectBadgeMatchesServer(page, request, fixture, entry); + await noHorizontalOverflow(page, '.console-nav .nav-links.open'); + await page.locator(entry).click(); + await page.waitForURL((u) => u.pathname === '/messages'); +}); diff --git a/web/e2e/chat-thread-refs.spec.ts b/web/e2e/chat-thread-refs.spec.ts new file mode 100644 index 000000000..66c6cc72a --- /dev/null +++ b/web/e2e/chat-thread-refs.spec.ts @@ -0,0 +1,196 @@ +import { test, expect } from './fixtures'; +import type { APIRequestContext, Page } from '@playwright/test'; +import { seedPermissionWalk } from './lib/permission-walk'; + +/** + * TASK-4044 PR 3c (rulings 687eb810, 463abd0c): how /messages/[n] renders the + * `[[REF]]` and `[[workspace::REF]]` links in a message body, against the REAL + * server. Each case asserts the API's `state` for the ref FIRST, then the DOM: + * the server decides, the page only draws. + * + * The cases are N9 (a)-(e), not the first E7 (463abd0c): an invented REF in a + * workspace the reader can see is `restricted`, not `unresolved`, because the + * server answers absence and denial with one projection (TASK-4039). Only a + * bare `[[REF]]` with no visible context workspace is `unresolved`. + * + * VISIBILITY GATE (687eb810): the rendered node for a ref in an EXISTING + * workspace the reader cannot see (b) and for an invented REF (c) must be + * byte-identical, or the page would tell the two apart. + */ + +type Fixture = { apiToken: string; baseURL: string; workspaceSlug: string; adminUsername: string }; +type Ref = { workspace: string; ref: string; state: string; title?: string; collection_slug?: string }; +type Row = { seq: number; body: string; deleted: boolean; context_workspace?: { slug: string } }; +type Page2 = { messages: Row[]; refs: Ref[] }; + +const headers = (f: Fixture, agent?: string) => ({ + Authorization: `Bearer ${f.apiToken}`, + 'Content-Type': 'application/json', + ...(agent ? { 'X-Pad-Agent': agent } : {}) +}); + +const uniqueAgent = (tag: string) => + `e2e-${tag}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; + +async function createDM(request: APIRequestContext, f: Fixture, agent: string): Promise { + const res = await request.post('/api/v1/me/conversations', { headers: headers(f), data: { agent_key: agent } }); + expect(res.ok(), await res.text()).toBe(true); + return ((await res.json()) as { conversation: { number: number } }).conversation.number; +} + +/** A send; `context_workspace` rides in the POST body exactly as a client sends it. */ +async function send( + request: APIRequestContext, + f: Fixture, + n: number, + body: string, + opts: { agent?: string; context_workspace?: string } = {} +): Promise { + const res = await request.post(`/api/v1/me/conversations/${n}/messages`, { + headers: headers(f, opts.agent), + data: { body, ...(opts.context_workspace ? { context_workspace: opts.context_workspace } : {}) } + }); + expect(res.status(), await res.text()).toBe(201); + return ((await res.json()) as { message: Row }).message; +} + +async function listPage(request: APIRequestContext, f: Fixture, n: number): Promise { + const res = await request.get(`/api/v1/me/conversations/${n}/messages?limit=200`, { headers: headers(f) }); + expect(res.ok(), await res.text()).toBe(true); + return (await res.json()) as Page2; +} + +async function createTask(request: APIRequestContext, f: Fixture, title: string): Promise<{ ref: string; title: string }> { + const res = await request.post(`/api/v1/workspaces/${f.workspaceSlug}/collections/tasks/items`, { + headers: headers(f), + data: { title } + }); + expect(res.status(), await res.text()).toBe(201); + const item = (await res.json()) as { ref?: string; issue_id?: string; item_number?: number; title: string }; + const ref = item.ref ?? item.issue_id ?? ''; + expect(ref, JSON.stringify(item)).toMatch(/^[A-Z]+-\d+$/); + return { ref, title: item.title }; +} + +const bubble = (page: Page, seq: number) => page.locator(`article[data-seq="${seq}"]`); + +async function waitForThread(page: Page, n: number, rowsWanted: number) { + await page.goto(`/messages/${n}`); + await expect(page.locator('article.bubble')).toHaveCount(rowsWanted); +} + +test.describe('E7: refs in message bodies', () => { + test.beforeEach(({}, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'desktop only'); + }); + + test('(a) a visible ref is a link and the click lands on the item', async ({ page, request, fixture }) => { + const task = await createTask(request, fixture, `E7 target ${Date.now()}`); + const agent = uniqueAgent('refa'); + const n = await createDM(request, fixture, agent); + const row = await send(request, fixture, n, `see [[${fixture.workspaceSlug}::${task.ref}]] please`, { agent }); + + const api = (await listPage(request, fixture, n)).refs.find((r) => r.ref === task.ref); + expect(api).toMatchObject({ workspace: fixture.workspaceSlug, state: 'visible', title: task.title }); + + await waitForThread(page, n, 1); + const link = bubble(page, row.seq).locator('.body a'); + await expect(link).toHaveCount(1); + await expect(link).toHaveAttribute('href', `/-/r/${fixture.workspaceSlug}/${task.ref}`); + await expect(link).toHaveAttribute('title', task.title); + await expect(link).toHaveText(task.ref); + await expect(bubble(page, row.seq).locator('.body')).toHaveText(`see ${task.ref} please`); + + await link.click(); + await page.waitForURL((u) => u.pathname.startsWith(`/${fixture.adminUsername}/${fixture.workspaceSlug}/`)); + await expect(page.locator('body')).toContainText(task.title); + }); + + test('(b) and (c): a denied-workspace ref and an invented ref render identical markup', async ({ + page, + request, + fixture + }) => { + const walk = await seedPermissionWalk(); // a workspace the fixture admin is NOT in + const foreign = `${walk.workspaceSlug}::${walk.tasks[0].ref}`; + const invented = `${fixture.workspaceSlug}::TASK-999999`; + const agent = uniqueAgent('refbc'); + const n = await createDM(request, fixture, agent); + const b = await send(request, fixture, n, `[[${foreign}]]`, { agent }); + const c = await send(request, fixture, n, `[[${invented}]]`, { agent }); + + const refs = (await listPage(request, fixture, n)).refs; + expect(refs.find((r) => r.workspace === walk.workspaceSlug)).toEqual({ + workspace: walk.workspaceSlug, + ref: walk.tasks[0].ref, + state: 'restricted' + }); + expect(refs.find((r) => r.ref === 'TASK-999999')).toEqual({ + workspace: fixture.workspaceSlug, + ref: 'TASK-999999', + state: 'restricted' + }); + + await waitForThread(page, n, 2); + const nodeB = bubble(page, b.seq).locator('.body .ref-restricted'); + const nodeC = bubble(page, c.seq).locator('.body .ref-restricted'); + await expect(nodeB).toHaveCount(1); + await expect(nodeC).toHaveCount(1); + const htmlB = await nodeB.evaluate((e) => e.outerHTML); + const htmlC = await nodeC.evaluate((e) => e.outerHTML); + console.log(`E7 (b) outerHTML: ${htmlB}`); + console.log(`E7 (c) outerHTML: ${htmlC}`); + expect(htmlB).toBe(htmlC); + // The whole body too: nothing about the ref survives in either bubble. + const bodyB = await bubble(page, b.seq).locator('.body').evaluate((e) => e.outerHTML); + const bodyC = await bubble(page, c.seq).locator('.body').evaluate((e) => e.outerHTML); + expect(bodyB).toBe(bodyC); + for (const node of [nodeB, nodeC]) { + await expect(node).toHaveText('restricted item'); + expect(await node.evaluate((e) => e.getAttributeNames())).toEqual(['class']); + } + await expect(page.locator('.body a')).toHaveCount(0); + const shown = (await page.locator('article.bubble').allInnerTexts()).join('\n'); + expect(shown).not.toContain(walk.workspaceSlug); + expect(shown).not.toContain('TASK-999999'); + }); + + test('(d) a bare ref with no context workspace is unresolved and stays literal', async ({ page, request, fixture }) => { + const agent = uniqueAgent('refd'); + const n = await createDM(request, fixture, agent); + const row = await send(request, fixture, n, 'maybe [[TASK-1]] or not', { agent }); + expect(row.context_workspace).toBeUndefined(); + + const api = (await listPage(request, fixture, n)).refs.find((r) => r.ref === 'TASK-1'); + expect(api).toEqual({ workspace: '', ref: 'TASK-1', state: 'unresolved' }); + + await waitForThread(page, n, 1); + const body = bubble(page, row.seq).locator('.body'); + await expect(body).toHaveText('maybe [[TASK-1]] or not'); + await expect(body.locator('a, span')).toHaveCount(0); + }); + + test('(e) a ref in a deleted message renders nothing', async ({ page, request, fixture }) => { + const task = await createTask(request, fixture, `E7 deleted ${Date.now()}`); + const agent = uniqueAgent('refe'); + const n = await createDM(request, fixture, agent); + const row = await send(request, fixture, n, `gone [[${fixture.workspaceSlug}::${task.ref}]]`, { + agent, + context_workspace: fixture.workspaceSlug + }); + const del = await request.delete(`/api/v1/me/conversations/${n}/messages/${row.seq}`, { + headers: headers(fixture, agent) + }); + expect(del.ok(), await del.text()).toBe(true); + + const after = await listPage(request, fixture, n); + expect(after.messages.find((m) => m.seq === row.seq)).toMatchObject({ deleted: true, body: '' }); + expect(after.refs).toEqual([]); + + await waitForThread(page, n, 1); + const article = bubble(page, row.seq); + await expect(article).toContainText('Message deleted'); + await expect(article).not.toContainText(task.ref); + await expect(article.locator('a, .ref-restricted')).toHaveCount(0); + }); +}); diff --git a/web/src/lib/chat/refs.test.ts b/web/src/lib/chat/refs.test.ts new file mode 100644 index 000000000..3d6f70a9b --- /dev/null +++ b/web/src/lib/chat/refs.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { canonicalizeRef, extractRefs, tokenizeBody } from './refs'; +import type { ChatRef } from '$lib/types'; + +const found = (c: string) => extractRefs(c).map((r) => (r.workspace ? `${r.workspace}::${r.ref}` : r.ref)); + +// Cases below are copied from internal/links/extract_test.go +// (TestExtractWikiLinks_CodeBlocksExcluded, _WorkspaceRefForms). +describe('extractRefs matches the Go extractor', () => { + const cases: Array<[string, string, string[]]> = [ + ['fenced block', 'Real: [[OUTSIDE-1]]\n```\nExample: [[INSIDE-1]] and [[INSIDE-2]]\n```\nAfter: [[OUTSIDE-2]]', ['OUTSIDE-1', 'OUTSIDE-2']], + ['fence with language tag', 'Before: [[A-1]]\n```bash\necho [[B-1]]\n```\nAfter: [[C-1]]', ['A-1', 'C-1']], + ['inline code', 'The `[[FAKE-1]]` syntax has shipped; see [[REAL-2]] for examples.', ['REAL-2']], + ['unclosed fence runs to EOF', 'Before: [[A-1]]\n```\nAfter: [[B-1]] dangling', ['A-1']], + ['inline code spans a single newline', 'Outside [[A-1]]\nstart `pre\n[[INSIDE-1]]\npost` end\nOutside [[B-1]]', ['A-1', 'B-1']], + ['inline code breaks at a blank line', 'Outside [[A-1]]\nopen `unclosed-span\n\nnew paragraph [[REAL-1]] more text\nOutside [[B-1]]', ['A-1', 'REAL-1', 'B-1']], + ['inline code breaks at a whitespace-only line', 'open `unclosed-span\n \t \nafter-blank [[REAL-1]]', ['REAL-1']], + ['closer matches opener length', 'Before [[A-1]] ``code with ` inside and [[INSIDE-1]]`` after [[B-1]]', ['A-1', 'B-1']], + ['single-backtick span next to a double run', 'Try `code [[INSIDE-1]] ``not-closer` then [[REAL-1]]', ['REAL-1']], + ['multi-backtick span', '``see [[INSIDE-1]]`` and [[REAL-1]]', ['REAL-1']], + ['tilde fence', 'Real: [[OUTSIDE-1]]\n~~~\nExample: [[INSIDE-1]]\n~~~\nAfter: [[OUTSIDE-2]]', ['OUTSIDE-1', 'OUTSIDE-2']], + ['mixed fence types do not pair', 'Before [[A-1]]\n```\n~~~ not a closer\n[[INSIDE-1]]\n```\nAfter [[B-1]]', ['A-1', 'B-1']], + ['fence closer needs only the fence', 'Real: [[OUTSIDE-1]]\n```\n```not-closed\n[[INSIDE-1]] still inside\n```\nAfter: [[OUTSIDE-2]]', ['OUTSIDE-1', 'OUTSIDE-2']], + ['closer trailing spaces ok', 'Before [[A-1]]\n```\n[[INSIDE-1]]\n``` \nAfter [[B-1]]', ['A-1', 'B-1']], + ['indented fence (0-3 spaces)', 'Before [[A-1]]\n ```\n echo [[INSIDE-1]]\n ```\nAfter [[B-1]]', ['A-1', 'B-1']], + ['four-space indent is not a fence', 'Before\n ```\n[[REAL-1]]\n', ['REAL-1']], + ['fence at start of content', '```\n[[INSIDE-1]]\n```\n[[OUTSIDE-1]]', ['OUTSIDE-1']], + ['ref after an inline span', '[[A-1]] `x` `[[G-1]]` [[H-1]]', ['A-1', 'H-1']], + ['workspace ref', 'Cross [[other-ws::TASK-9]] over.', ['other-ws::TASK-9']], + ['workspace ref with display', 'Cross [[other-ws::TASK-9|see this]] too.', ['other-ws::TASK-9']], + ['mixed-case bare ref is canonicalised', '[[task-5]]', ['TASK-5']], + ['invalid workspace slug is a title, not a ref', '[[INVALID::TASK-1]]', []], + ['title links are not refs', '[[Some Title]] and [[docs/Other]]', []] + ]; + for (const [name, content, want] of cases) { + it(name, () => expect(found(content)).toEqual(want)); + } + + // TASK-4044 3c D4: the trim is Go's strings.TrimSpace (presence.ts::trimSpace), not + // String.trim(). Go keeps U+FEFF and strips U+0085, in either engine. + it('a BOM before the ref is not a ref (Go keeps U+FEFF)', () => { + expect(found('[[\uFEFFTASK-1]]')).toEqual([]); + expect(found('[[\uFEFFpad::TASK-1]]')).toEqual([]); + expect(found('[[pad::\uFEFFTASK-1]]')).toEqual([]); + }); + + it('a NEL around the ref is trimmed (Go strips U+0085)', () => { + expect(found('[[\u0085TASK-1]]')).toEqual(['TASK-1']); + expect(found('[[\u0085pad::\u0085TASK-1\u0085]]')).toEqual(['pad::TASK-1']); + }); + + it('keeps the display override and raw text', () => { + const [r] = extractRefs('x [[other-ws::TASK-9|see this]] y'); + expect(r.display).toBe('see this'); + expect(r.raw).toBe('[[other-ws::TASK-9|see this]]'); + expect(r.start).toBe(2); + }); + + it('canonicalizeRef upper-cases the prefix only', () => { + expect(canonicalizeRef('task-5')).toBe('TASK-5'); + expect(canonicalizeRef('Ab-12')).toBe('AB-12'); + }); +}); + +describe('tokenizeBody', () => { + const refs: ChatRef[] = [ + { workspace: 'pad', ref: 'TASK-1', state: 'visible', title: 'Do it', collection_slug: 'tasks' }, + { workspace: 'other', ref: 'BUG-2', state: 'restricted' }, + { workspace: 'pad', ref: 'TASK-3', state: 'unresolved' } + ]; + const ctx = { slug: 'pad', name: 'Pad' }; + const tok = (body: string, context_workspace?: { slug: string; name: string }) => + tokenizeBody({ body, context_workspace, deleted: false }, refs); + + it('links a visible bare ref through the context workspace', () => { + expect(tok('see [[task-1]] now', ctx)).toEqual([ + { type: 'text', text: 'see ' }, + { type: 'link', text: 'TASK-1', href: '/-/r/pad/TASK-1', title: 'Do it', workspace: 'pad', ref: 'TASK-1' }, + { type: 'text', text: ' now' } + ]); + }); + + it('uses the display override as the link label', () => { + const segs = tok('[[pad::TASK-1|the task]]'); + expect(segs).toEqual([ + { type: 'link', text: 'the task', href: '/-/r/pad/TASK-1', title: 'Do it', workspace: 'pad', ref: 'TASK-1' } + ]); + }); + + it('renders a restricted ref as a title-less placeholder', () => { + expect(tok('[[other::BUG-2]]')).toEqual([ + { type: 'restricted', text: 'restricted item', workspace: 'other', ref: 'BUG-2' } + ]); + }); + + it('leaves an unresolved ref as plain text', () => { + expect(tok('x [[TASK-3]] y', ctx)).toEqual([{ type: 'text', text: 'x [[TASK-3]] y' }]); + }); + + it('leaves a ref with no entry (dropped by the server) as plain text', () => { + expect(tok('[[pad::TASK-99]]')).toEqual([{ type: 'text', text: '[[pad::TASK-99]]' }]); + }); + + it('leaves a bare ref with no context workspace as plain text', () => { + expect(tok('[[TASK-1]]')).toEqual([{ type: 'text', text: '[[TASK-1]]' }]); + }); + + it('an explicit workspace overrides the context workspace', () => { + const segs = tok('[[other::BUG-2]]', ctx); + expect(segs[0].type).toBe('restricted'); + }); + + it('does not link inside code', () => { + expect(tok('`[[pad::TASK-1]]`')).toEqual([{ type: 'text', text: '`[[pad::TASK-1]]`' }]); + }); + + it('returns nothing for a tombstone', () => { + expect(tokenizeBody({ body: '', context_workspace: undefined, deleted: true }, refs)).toEqual([]); + }); +}); diff --git a/web/src/lib/chat/refs.ts b/web/src/lib/chat/refs.ts new file mode 100644 index 000000000..969307889 --- /dev/null +++ b/web/src/lib/chat/refs.ts @@ -0,0 +1,323 @@ +// Chat message ref tokenising and resolution (TASK-4044). +// +// extractRefs is a port of internal/links/extract.go::ExtractWikiLinks, +// narrowed to the two kinds the chat enrichment looks at (`[[REF]]` and +// `[[workspace::REF]]`). It must find exactly the links the server found, or a +// body would render a link the page-level `refs` list has no entry for. The +// code-span and fence scanners are line-for-line ports of fencedCodeRanges, +// findFenceCloser and inlineCodeRanges there; the web renderer's own +// markdown.ts::codeRanges is a deliberately simpler variant and is NOT used. + +import type { ChatMessage, ChatRef } from '$lib/types'; +import { trimSpace } from './presence'; + +// Same production as links.wikiLinkPattern / markdown.ts::WIKI_LINK_PATTERN_SOURCE. +const WIKI_LINK = String.raw`\[\[((?:\\[^\n]|[^\]\\])+)\]\]`; +const REF_PATTERN = /^[A-Za-z][A-Za-z0-9]*-\d+$/; +const WORKSPACE_SLUG = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; + +type Range = [number, number]; + +function nextLine(content: string, i: number): number { + const nl = content.indexOf('\n', i); + return nl < 0 ? -1 : nl + 1; +} + +function findFenceCloser(content: string, start: number, tickCount: number, fenceChar: string): number { + const n = content.length; + let i = start; + while (i < n) { + const nl = content.indexOf('\n', i); + if (nl < 0) return -1; + const lineStart = nl + 1; + if (lineStart >= n) return -1; + let closerStart = lineStart; + let spaces = 0; + while (spaces < 4 && closerStart < n && content[closerStart] === ' ') { + closerStart++; + spaces++; + } + if (spaces >= 4) { + i = lineStart; + continue; + } + let j = closerStart; + while (j < n && content[j] === fenceChar) j++; + if (j - closerStart >= tickCount) { + let rest = j; + while (rest < n && content[rest] !== '\n') { + if (content[rest] !== ' ') break; + rest++; + } + if (rest >= n || content[rest] === '\n') return closerStart; + } + i = lineStart; + } + return -1; +} + +function fencedCodeRanges(content: string): Range[] { + const ranges: Range[] = []; + const n = content.length; + let i = 0; + while (i < n) { + if (i > 0 && content[i - 1] !== '\n') { + const nx = nextLine(content, i); + if (nx < 0) return ranges; + i = nx; + continue; + } + let fenceLineStart = i; + let spaces = 0; + while (spaces < 4 && fenceLineStart < n && content[fenceLineStart] === ' ') { + fenceLineStart++; + spaces++; + } + if (spaces >= 4) { + const nx = nextLine(content, i); + if (nx < 0) return ranges; + i = nx; + continue; + } + const ch = fenceLineStart < n ? content[fenceLineStart] : ''; + if (ch !== '`' && ch !== '~') { + const nx = nextLine(content, i); + if (nx < 0) return ranges; + i = nx; + continue; + } + const tickStart = fenceLineStart; + let j = fenceLineStart; + while (j < n && content[j] === ch) j++; + const tickCount = j - tickStart; + if (tickCount < 3) { + const nx = nextLine(content, i); + if (nx < 0) return ranges; + i = nx; + continue; + } + if (ch === '`') { + const restEnd = content.indexOf('\n', j); + const restLimit = restEnd < 0 ? n : restEnd; + const tick = content.indexOf('`', j); + if (tick >= 0 && tick < restLimit) { + const nx = nextLine(content, i); + if (nx < 0) return ranges; + i = nx; + continue; + } + } + i = j; + const closer = findFenceCloser(content, i, tickCount, ch); + if (closer < 0) { + ranges.push([tickStart, n]); + return ranges; + } + let k = closer; + while (k < n && content[k] === ch) k++; + ranges.push([tickStart, k]); + i = k; + } + return ranges; +} + +function isBlankLineAt(content: string, pos: number): boolean { + for (let i = pos; i < content.length; i++) { + const c = content[i]; + if (c === '\n') return true; + if (c !== ' ' && c !== '\t') return false; + } + return true; +} + +function inlineCodeRanges(content: string, fenced: Range[]): Range[] { + const ranges: Range[] = []; + const n = content.length; + let i = 0; + let fi = 0; + while (i < n) { + while (fi < fenced.length && fenced[fi][1] <= i) fi++; + if (fi < fenced.length && fenced[fi][0] <= i) { + i = fenced[fi][1]; + fi++; + continue; + } + const b = content.indexOf('`', i); + if (b < 0) return ranges; + const openStart = b; + if (fi < fenced.length && fenced[fi][0] <= openStart && openStart < fenced[fi][1]) { + i = fenced[fi][1]; + fi++; + continue; + } + let j = openStart + 1; + while (j < n && content[j] === '`') j++; + const openLen = j - openStart; + let closerEnd = -1; + let k = j; + while (k < n) { + if (content[k] === '\n') { + if (isBlankLineAt(content, k + 1)) break; + k++; + continue; + } + if (content[k] !== '`') { + k++; + continue; + } + const runStart = k; + while (k < n && content[k] === '`') k++; + if (k - runStart === openLen) { + closerEnd = k; + break; + } + } + if (closerEnd < 0) { + i = openStart + 1; + continue; + } + ranges.push([openStart, closerEnd]); + i = closerEnd; + } + return ranges; +} + +function inRanges(ranges: Range[], pos: number): boolean { + for (const [s, e] of ranges) if (pos >= s && pos < e) return true; + return false; +} + +function unescapeWikiBody(s: string): string { + return s.replace(/\\([\\\]|])/g, '$1'); +} + +function splitOnUnescapedPipe(body: string): { key: string; suffix: string } | null { + let i = 0; + while (i < body.length) { + if (body[i] === '\\' && i + 1 < body.length) { + i += 2; + continue; + } + if (body[i] === '|') return { key: body.slice(0, i), suffix: body.slice(i + 1) }; + i++; + } + return null; +} + +/** links.CanonicalizeRef: upper-case everything before the last hyphen. */ +export function canonicalizeRef(ref: string): string { + const dash = ref.lastIndexOf('-'); + if (dash < 0) return ref.toUpperCase(); + return ref.slice(0, dash).toUpperCase() + ref.slice(dash); +} + +export interface ExtractedRef { + /** The literal `[[...]]` text, for rendering a link that does not resolve. */ + raw: string; + start: number; + end: number; + /** Set only for the `[[workspace::REF]]` form. */ + workspace?: string; + /** Canonical (upper-cased prefix) ref. */ + ref: string; + /** The `|Display` override, when present. */ + display?: string; +} + +/** The ref and workspace-ref links in `content`, outside code, in source order. */ +export function extractRefs(content: string): ExtractedRef[] { + if (!content) return []; + const fenced = fencedCodeRanges(content); + const inline = inlineCodeRanges(content, fenced); + const out: ExtractedRef[] = []; + const re = new RegExp(WIKI_LINK, 'g'); + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const start = m.index; + if (inRanges(fenced, start) || inRanges(inline, start)) continue; + let body = m[1]; + let display: string | undefined; + const split = splitOnUnescapedPipe(body); + if (split) { + display = unescapeWikiBody(split.suffix); + body = split.key; + } + const unescaped = unescapeWikiBody(body); + if (unescaped === '') continue; + const trimmed = trimSpace(unescaped); + const raw = m[0]; + const end = start + raw.length; + const sep = trimmed.indexOf('::'); + if (sep >= 0) { + const ws = trimSpace(trimmed.slice(0, sep)); + const rest = trimSpace(trimmed.slice(sep + 2)); + if (WORKSPACE_SLUG.test(ws) && REF_PATTERN.test(rest)) { + out.push({ raw, start, end, workspace: ws, ref: canonicalizeRef(rest), display }); + continue; + } + } + if (REF_PATTERN.test(trimmed)) { + out.push({ raw, start, end, ref: canonicalizeRef(trimmed), display }); + } + } + return out; +} + +export type RefSegment = + | { type: 'text'; text: string } + | { type: 'link'; text: string; href: string; title: string; workspace: string; ref: string } + | { type: 'restricted'; text: 'restricted item'; workspace: string; ref: string }; + +/** The (workspace, canonical ref) key a verdict is held under (3c D4: exported). */ +export function refKey(workspace: string, ref: string): string { + return `${workspace}\u0000${ref}`; +} + +/** + * Split a message body into text, link and restricted segments. + * + * A ref's workspace is its explicit one, else the message's context workspace + * slug. It is looked up in the page-level `refs` by (workspace, canonical ref): + * `visible` becomes a link to `/-/r/{workspace}/{REF}` (which re-checks access + * on click), `restricted` a title-less placeholder, and anything else (an + * `unresolved` entry, no entry because the server dropped it, or a bare ref + * with no context workspace) stays plain text exactly as written. + */ +export function tokenizeBody( + msg: Pick, + refs: readonly ChatRef[] +): RefSegment[] { + if (msg.deleted || !msg.body) return msg.body ? [{ type: 'text', text: msg.body }] : []; + const byKey = new Map(); + for (const r of refs) byKey.set(refKey(r.workspace, r.ref), r); + const segs: RefSegment[] = []; + let pos = 0; + const pushText = (text: string) => { + if (!text) return; + const last = segs[segs.length - 1]; + if (last && last.type === 'text') last.text += text; + else segs.push({ type: 'text', text }); + }; + for (const e of extractRefs(msg.body)) { + pushText(msg.body.slice(pos, e.start)); + pos = e.end; + const workspace = e.workspace ?? msg.context_workspace?.slug ?? ''; + const hit = workspace ? byKey.get(refKey(workspace, e.ref)) : undefined; + if (hit?.state === 'visible') { + segs.push({ + type: 'link', + text: e.display ?? e.ref, + href: `/-/r/${workspace}/${e.ref}`, + title: hit.title ?? '', + workspace, + ref: e.ref + }); + } else if (hit?.state === 'restricted') { + segs.push({ type: 'restricted', text: 'restricted item', workspace, ref: e.ref }); + } else { + pushText(e.raw); + } + } + pushText(msg.body.slice(pos)); + return segs; +} diff --git a/web/src/lib/chat/sourceGuards.test.ts b/web/src/lib/chat/sourceGuards.test.ts new file mode 100644 index 000000000..fbc9e8a81 --- /dev/null +++ b/web/src/lib/chat/sourceGuards.test.ts @@ -0,0 +1,27 @@ +// TASK-4044 3c: source guards for the chat UI (U30, U32). +import { describe, expect, it } from 'vitest'; + +const sources = { + ...import.meta.glob('../components/chat/*.svelte', { query: '?raw', import: 'default', eager: true }), + ...import.meta.glob('../../routes/messages/**/*.svelte', { query: '?raw', import: 'default', eager: true }) +} as Record; + +describe('chat UI sources', () => { + it('finds the sources it guards', () => { + const names = Object.keys(sources); + expect(names.some((n) => n.endsWith('MessageBubble.svelte'))).toBe(true); + expect(names.some((n) => n.endsWith('[n]/+page.svelte'))).toBe(true); + }); + + // U30: a body is never injected as HTML. A comment may name the tag; a use has an expression after it. + it('uses no {@html} tag', () => { + for (const [name, src] of Object.entries(sources)) expect(src, name).not.toMatch(/\{@html\s+[^}\s]/); + }); + + // U32: no direction toggle and no accept-instructions control in the chat UI (flex-direction is CSS). + it('has no direction or accept-instructions control', () => { + for (const [name, src] of Object.entries(sources)) { + expect(src, name).not.toMatch(/(?), and the lead/trail snippets carry - // the parent's own scope for its icon and chevron. Not the Sidebar footer - // row: it overflows on desktop (TASK-4044, comment 89264cdd, posted 04:36:46Z). + // The entry to /messages (TASK-4044 PR 2a-ii, 3c). Three placements share it: + // the TopBar user menu, the You sheet and the /console nav bar; each passes + // the class of its siblings. A parent's scoped styles do not reach into this + // element, so the parent styles it with :global(.), and the lead/trail + // snippets carry the parent's own scope for its icon and chevron. Not the + // Sidebar footer row: it overflows on desktop (TASK-4044, comment 89264cdd, + // posted 04:36:46Z). let { class: className = '', role, diff --git a/web/src/lib/components/chat/MessageBubble.svelte b/web/src/lib/components/chat/MessageBubble.svelte index 5bec4d20e..1605987c9 100644 --- a/web/src/lib/components/chat/MessageBubble.svelte +++ b/web/src/lib/components/chat/MessageBubble.svelte @@ -1,11 +1,15 @@ @@ -99,7 +107,12 @@ {quote.deleted ? 'Deleted message' : quote.excerpt} {/if} -

{message.body}

+ +

{#each segments as seg, i (i)}{#if seg.type === 'link'}{seg.text}{:else if seg.type === 'restricted'}{seg.text}{:else}{seg.text}{/if}{/each}

{/if} {#if menuOpen && onReply && onDelete} { expect(region!.textContent).toContain('Failed to send'); }); }); + +// TASK-4044 3c (U29, corrected by N9): how a body renders its refs. +describe('MessageBubble refs', () => { + const entry = (r: ChatRef) => [refKey(r.workspace, r.ref), r] as const; + const map = (...rs: ChatRef[]) => new Map(rs.map(entry)); + const withBody = (body: string, over: Partial = {}): ChatMessage => ({ ...base, body, ...over }); + const body = (c: HTMLElement) => c.querySelector('.body') as HTMLElement; + + it('a visible ref is a link to /-/r with the title and a hard reload', () => { + const refs = map({ workspace: 'docapp', ref: 'TASK-1', state: 'visible', title: 'Ship it', collection_slug: 'tasks' }); + const r = render(MessageBubble, { props: { message: withBody('see [[docapp::TASK-1]] now'), refs } }); + const a = body(r.container).querySelector('a')!; + expect(a.getAttribute('href')).toBe('/-/r/docapp/TASK-1'); + expect(a.getAttribute('title')).toBe('Ship it'); + expect(a.hasAttribute('data-sveltekit-reload')).toBe(true); + expect(a.textContent).toBe('TASK-1'); + expect(body(r.container).textContent).toBe('see TASK-1 now'); + }); + + it('a bare visible ref links through the context workspace', () => { + const refs = map({ workspace: 'docapp', ref: 'TASK-1', state: 'visible', title: 'Ship it' }); + const message = withBody('[[task-1]]', { context_workspace: { slug: 'docapp', name: 'DocApp' } }); + const r = render(MessageBubble, { props: { message, refs } }); + expect(body(r.container).querySelector('a')!.getAttribute('href')).toBe('/-/r/docapp/TASK-1'); + }); + + it('a restricted ref is a span with no href and no title attribute', () => { + const refs = map({ workspace: 'other', ref: 'BUG-2', state: 'restricted' }); + const r = render(MessageBubble, { props: { message: withBody('x [[other::BUG-2]] y'), refs } }); + const b = body(r.container); + expect(b.querySelector('a')).toBeNull(); + const span = b.querySelector('span.ref-restricted')!; + expect(span.textContent).toBe('restricted item'); + expect(span.hasAttribute('href')).toBe(false); + expect(span.hasAttribute('title')).toBe(false); + expect(span.getAttributeNames()).toEqual(['class']); + expect(b.textContent).toBe('x restricted item y'); + expect(b.innerHTML).not.toContain('BUG-2'); + }); + + it('unresolved and missing refs stay the raw text, brackets kept, with no link', () => { + const refs = map({ workspace: 'docapp', ref: 'TASK-3', state: 'unresolved' }); + const text = 'a [[docapp::TASK-3]] b [[docapp::TASK-99]] c [[TASK-3]]'; + const r = render(MessageBubble, { props: { message: withBody(text), refs } }); + const b = body(r.container); + expect(b.textContent).toBe(text); + expect(b.querySelector('a, span')).toBeNull(); + }); + + it('with no refs at all the body is the raw text', () => { + const r = render(MessageBubble, { props: { message: withBody('[[docapp::TASK-1]]') } }); + expect(body(r.container).textContent).toBe('[[docapp::TASK-1]]'); + expect(body(r.container).querySelector('a')).toBeNull(); + }); + + it('keeps newlines and spacing as text, and renders markup in the body as text', () => { + const refs = map({ workspace: 'docapp', ref: 'TASK-1', state: 'visible', title: 't' }); + const text = 'line one\n bold [[docapp::TASK-1]]\nend'; + const r = render(MessageBubble, { props: { message: withBody(text), refs } }); + const b = body(r.container); + expect(b.textContent).toBe('line one\n bold TASK-1\nend'); + expect(b.querySelector('b')).toBeNull(); + expect(b.querySelector('a')!.getAttribute('title')).toBe('t'); + }); + + it('a tombstone renders no body and no ref', () => { + const refs = map({ workspace: 'docapp', ref: 'TASK-1', state: 'visible', title: 'Ship it' }); + const message = withBody('', { deleted: true }); + const r = render(MessageBubble, { props: { message, refs } }); + expect(r.container.textContent).toContain('Message deleted'); + expect(r.container.querySelector('a')).toBeNull(); + }); + + it('PendingBubble and the quote chip keep the raw text', () => { + const p = render(PendingBubble, { + props: { pending: { op_key: 'k', body: 'see [[docapp::TASK-1]]', status: 'sending' as const }, onRetry: vi.fn(), onDiscard: vi.fn() } + }); + expect(p.container.querySelector('a')).toBeNull(); + expect(p.container.textContent).toContain('[[docapp::TASK-1]]'); + p.unmount(); + const message = withBody('x', { + reply_to_seq: 2, + reply_to: { seq: 2, author_agent_key: 'codex', author_is_human: false, excerpt: 'see [[docapp::TASK-1]]', deleted: false } + }); + const refs = map({ workspace: 'docapp', ref: 'TASK-1', state: 'visible', title: 'Ship it' }); + const q = render(MessageBubble, { props: { message, refs } }); + expect(q.container.querySelector('.quote a')).toBeNull(); + expect(q.container.querySelector('.quote-text')!.textContent).toBe('see [[docapp::TASK-1]]'); + }); +}); diff --git a/web/src/lib/components/layout/Sidebar.chatEntry.svelte.test.ts b/web/src/lib/components/layout/Sidebar.chatEntry.svelte.test.ts new file mode 100644 index 000000000..7d4db441f --- /dev/null +++ b/web/src/lib/components/layout/Sidebar.chatEntry.svelte.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, cleanup } from '@testing-library/svelte'; +import { tick } from 'svelte'; + +const h = vi.hoisted(() => ({ + ws: { current: { slug: 'ws', owner_username: 'u', is_guest: false } as { slug: string; owner_username: string; is_guest: boolean } }, + auth: { authenticated: true } +})); + +vi.mock('$lib/api/client', () => ({ + api: { + health: vi.fn(async () => ({ version: 'dev', commit: 'abcdef0' })), + collections: { list: vi.fn(async () => []), update: vi.fn() } + }, + isPlanLimitError: () => false, + planLimitMessage: () => '' +})); +vi.mock('$app/navigation', () => ({ goto: vi.fn(), afterNavigate: vi.fn(), beforeNavigate: vi.fn() })); +vi.mock('$lib/stores/workspace.svelte', () => ({ + workspaceStore: { + get current() { + return h.ws.current; + }, + canEditCollection: () => true, + membershipKnown: true + } +})); +vi.mock('$lib/stores/collections.svelte', async () => await import('./sidebarQuickAdd.fixture.svelte')); +vi.mock('$lib/stores/chat.svelte', () => ({ chatStore: { unreadTotal: 4 } })); +vi.mock('$lib/stores/auth.svelte', async (orig) => { + const real = await orig(); + return { + ...real, + authStore: new Proxy(real.authStore, { + get: (t, k) => (k === 'authenticated' ? h.auth.authenticated : Reflect.get(t, k)) + }) + }; +}); + +import Sidebar from './Sidebar.svelte'; + +const entry = () => document.querySelector('nav.collection-nav a.nav-item[href="/messages"]'); + +// TASK-4044 3c (Option C, ruling 687eb810 Q-e): chat is per user, so the entry is +// gated on being signed in, not on the workspace's guest flag. +describe('Sidebar Chat entry (TASK-4044 3c)', () => { + beforeEach(() => cleanup()); + + it('a member sees it, after Tags, with the unread badge and a global href', async () => { + h.ws.current = { slug: 'ws', owner_username: 'u', is_guest: false }; + h.auth.authenticated = true; + render(Sidebar); + await tick(); + const a = entry()!; + expect(a).not.toBeNull(); + expect(a.getAttribute('href')).toBe('/messages'); + expect(a.getAttribute('aria-label')).toBe('Chat, 4 unread'); + expect(a.querySelector('.unread-badge')?.textContent).toBe('4'); + expect(a.previousElementSibling?.getAttribute('href')).toBe('/u/ws/tags'); + }); + + it('a signed-in guest member of the workspace sees it too', async () => { + h.ws.current = { slug: 'ws', owner_username: 'u', is_guest: true }; + h.auth.authenticated = true; + render(Sidebar); + await tick(); + expect(document.querySelector('a[href="/u/ws/tags"]')).toBeNull(); + expect(entry()).not.toBeNull(); + }); + + it('a signed-out visitor does not', async () => { + h.ws.current = { slug: 'ws', owner_username: 'u', is_guest: false }; + h.auth.authenticated = false; + render(Sidebar); + await tick(); + expect(document.querySelector('nav.collection-nav')).not.toBeNull(); + expect(entry()).toBeNull(); + }); +}); diff --git a/web/src/lib/components/layout/Sidebar.svelte b/web/src/lib/components/layout/Sidebar.svelte index 28d09dd45..c14cae17d 100644 --- a/web/src/lib/components/layout/Sidebar.svelte +++ b/web/src/lib/components/layout/Sidebar.svelte @@ -10,6 +10,8 @@ import { uiStore } from '$lib/stores/ui.svelte'; import { GITHUB_REPO_URL } from '$lib/brand/links'; import { authStore } from '$lib/stores/auth.svelte'; + import { chatStore } from '$lib/stores/chat.svelte'; + import UnreadBadge from '$lib/components/chat/UnreadBadge.svelte'; import { goto } from '$app/navigation'; import { api, isPlanLimitError } from '$lib/api/client'; import { showPlanLimitToast } from '$lib/billing/planLimitToast'; @@ -59,6 +61,7 @@ let isActivityPage = $derived(activeKey === 'activity'); let isStarredPage = $derived(activeKey === 'starred'); let isTagsPage = $derived(activeKey === 'tags'); + let isMessagesPage = $derived(page.url.pathname === '/messages' || page.url.pathname.startsWith('/messages/')); let activeCollectionSlug = $derived( activeKey && activeKey.startsWith('collection:') ? activeKey.slice('collection:'.length) : null @@ -636,6 +639,25 @@ Tags {/if} + + {#if authStore.authenticated} + 0 ? `Chat, ${chatStore.unreadTotal} unread` : 'Chat'} + onclick={() => uiStore.onNavigate()} + > + 💬 + Chat + + + {/if} {#if isGuest}
diff --git a/web/src/lib/stores/chatThread.svelte.test.ts b/web/src/lib/stores/chatThread.svelte.test.ts index 5548fd722..9297927a3 100644 --- a/web/src/lib/stores/chatThread.svelte.test.ts +++ b/web/src/lib/stores/chatThread.svelte.test.ts @@ -5,7 +5,8 @@ // store is visible here rather than only in a browser. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { flushSync } from 'svelte'; -import type { ChatMessage } from '$lib/types'; +import type { ChatMessage, ChatRef } from '$lib/types'; +import { refKey } from '$lib/chat/refs'; const h = vi.hoisted(() => ({ getConversation: vi.fn(), @@ -1557,3 +1558,259 @@ describe('S1 serialized window', () => { expect(thread.messages.map((m) => m.body)).toEqual(['new1']); }); }); + +// TASK-4044 3c: the ref verdicts the thread collects (rulings 687eb810, Q-h). +describe('refs (3c)', () => { + const key = (ref: string, workspace = 'w') => refKey(workspace, ref); + const vis = (ref: string, workspace = 'w'): ChatRef => ({ workspace, ref, state: 'visible', title: `t ${ref}` }); + const restricted = (ref: string): ChatRef => ({ workspace: 'w', ref, state: 'restricted' }); + const unresolved = (ref: string): ChatRef => ({ workspace: 'w', ref, state: 'unresolved' }); + + /** Every list read answers one ref of its own: T-. */ + function tagEachCall() { + let i = 0; + h.listMessages.mockClear(); + h.listMessages.mockImplementation(async (_n: number, o: object) => ({ + ...server.list(o), + refs: [vis(`T-${++i}`)] + })); + } + const tagged = () => Array.from({ length: h.listMessages.mock.calls.length }, (_, i) => key(`T-${i + 1}`)); + function expectEveryCallAbsorbed() { + expect(h.listMessages.mock.calls.length).toBeGreaterThan(0); + for (const k of tagged()) expect(thread.refs.has(k), k).toBe(true); + } + + // U33: one row per list site; a site that skips the absorb leaves its call's ref out (M33a-g). + describe('every list site absorbs the page refs (U33)', () => { + it('a: open with unread (after_seq)', async () => { + tagEachCall(); + server.fill(5); + server.readSeq = 2; + await thread.open(1); + expect(h.listMessages.mock.calls[0][1]).toMatchObject({ after_seq: 2 }); + expectEveryCallAbsorbed(); + }); + + it('a: open with nothing unread (newest page)', async () => { + tagEachCall(); + server.fill(3); + server.readSeq = 3; + await thread.open(1); + expect(h.listMessages.mock.calls[0][1]).toMatchObject({ limit: 50 }); + expectEveryCallAbsorbed(); + }); + + it('b: loadOlder', async () => { + tagEachCall(); + server.fill(120); + server.readSeq = 120; + await thread.open(1); + await thread.loadOlder(); + expect(h.listMessages.mock.calls[1][1]).toHaveProperty('before_seq'); + expectEveryCallAbsorbed(); + expect(h.listMessages.mock.calls).toHaveLength(2); + }); + + it('c: loadNewer', async () => { + tagEachCall(); + server.fill(400); + server.readSeq = 0; + await thread.open(1); + expect(thread.hasNewer).toBe(true); + await thread.loadNewer(); + expect(h.listMessages.mock.calls).toHaveLength(2); + expectEveryCallAbsorbed(); + }); + + it('d: refresh, the rows after the newest', async () => { + server.fill(3); + server.readSeq = 3; + await thread.open(1); + tagEachCall(); + server.add(msg(4)); + await bump(); + expect(h.listMessages.mock.calls[0][1]).toMatchObject({ after_seq: 3 }); + expect(thread.refs.has(key('T-1'))).toBe(true); + }); + + it('e: refresh, the tombstone page', async () => { + server.fill(3); + server.readSeq = 3; + await thread.open(1); + tagEachCall(); + await bump(); + expect(h.listMessages.mock.calls).toHaveLength(2); + expect(thread.refs.has(key('T-2'))).toBe(true); + }); + + it('e: refresh, the tombstone page with a gap (after_seq = oldest - 1)', async () => { + server.fill(400); + server.readSeq = 0; + await thread.open(1); + expect(thread.hasNewer).toBe(true); + tagEachCall(); + await bump(); + expect(h.listMessages.mock.calls).toHaveLength(1); + expect(h.listMessages.mock.calls[0][1]).toMatchObject({ after_seq: 0 }); + expectEveryCallAbsorbed(); + }); + + it('f: applyOwn re-anchor at the newest page', async () => { + server.fill(400); + server.readSeq = 0; + await thread.open(1); + wireSend(); + tagEachCall(); + expect(await thread.send('hello')).toBe('sent'); + expect(h.listMessages.mock.calls).toHaveLength(1); + expectEveryCallAbsorbed(); + }); + + it('g: fill of the skipped peer rows', async () => { + server.fill(3); + server.readSeq = 3; + await thread.open(1); + wireSend(); + for (const seq of [4, 5, 6]) server.add(msg(seq)); + tagEachCall(); + expect(await thread.send('hello')).toBe('sent'); + expect(h.listMessages.mock.calls.length).toBeGreaterThan(0); + expectEveryCallAbsorbed(); + }); + }); + + it('the send response refs are absorbed (M33h)', async () => { + await readyThread(); + wireSend(); + const real = h.sendMessage.getMockImplementation()!; + h.sendMessage.mockImplementation(async (n: number, b: never) => ({ + ...(await real(n, b)), + refs: [vis('S-1')] + })); + expect(await thread.send('see [[w::S-1]]')).toBe('sent'); + expect(thread.refs.get(key('S-1'))).toMatchObject({ state: 'visible' }); + }); + + it('a ref first seen on an older page still links after a refresh', async () => { + server.fill(120); + server.readSeq = 120; + await thread.open(1); + h.listMessages.mockImplementation(async (_n: number, o: { before_seq?: number }) => ({ + ...server.list(o), + refs: o.before_seq !== undefined ? [vis('OLD-1')] : [] + })); + await thread.loadOlder(); + expect(thread.refs.has(key('OLD-1'))).toBe(true); + server.add(msg(121)); + await bump(); + expect(thread.refs.get(key('OLD-1'))).toMatchObject({ state: 'visible' }); + }); + + // U34: verdicts are per reader (M32). + describe('identity (U34)', () => { + const change = () => { + h.state.epoch++; + for (const fn of [...h.state.listeners]) fn('previous'); + }; + + it('an identity change clears refs', async () => { + tagEachCall(); + await readyThread(); + expect(thread.refs.size).toBeGreaterThan(0); + change(); + expect(thread.refs.size).toBe(0); + }); + + it('a reopen clears refs', async () => { + tagEachCall(); + await readyThread(); + h.listMessages.mockImplementation(async (_n: number, o: object) => ({ ...server.list(o), refs: [] })); + await thread.open(1); + expect(thread.refs.size).toBe(0); + }); + + it('a page that resolves after the reset absorbs nothing', async () => { + await readyThread(); + let release!: () => void; + h.listMessages.mockImplementationOnce((_n: number, o: object) => { + const page = { ...server.list(o), refs: [vis('LATE-1')] }; + return new Promise((res) => (release = () => res(page))); + }); + server.add(msg(4)); + await bump(); + change(); + release(); + await flush(); + expect(thread.refs.size).toBe(0); + }); + + it('a send answered after the reset absorbs nothing', async () => { + await readyThread(); + wireSend(); + const real = h.sendMessage.getMockImplementation()!; + let release!: () => void; + h.sendMessage.mockImplementationOnce( + (n: number, b: never) => + new Promise((res) => (release = () => res(real(n, b).then((r: object) => ({ ...r, refs: [vis('LATE-2')] }))))) + ); + const done = thread.send('x'); + change(); + release(); + expect(await done).toBe('stale'); + expect(thread.refs.size).toBe(0); + }); + }); + + // U35: the merge rule (Q-h, M34). + describe('merge rule (U35)', () => { + async function serve(refs: ChatRef[]) { + h.listMessages.mockImplementation(async (_n: number, o: object) => ({ ...server.list(o), refs })); + await bump(); + } + const state = (ref: string) => thread.refs.get(key(ref))?.state; + + it('visible then restricted gives restricted', async () => { + await readyThread(); + await serve([vis('A-1')]); + expect(state('A-1')).toBe('visible'); + await serve([restricted('A-1')]); + expect(state('A-1')).toBe('restricted'); + expect(thread.refs.get(key('A-1'))?.title).toBeUndefined(); + }); + + it('restricted then visible gives visible', async () => { + await readyThread(); + await serve([restricted('A-1')]); + await serve([vis('A-1')]); + expect(thread.refs.get(key('A-1'))).toMatchObject({ state: 'visible', title: 't A-1' }); + }); + + it('visible then unresolved stays visible', async () => { + await readyThread(); + await serve([vis('A-1')]); + await serve([unresolved('A-1')]); + expect(state('A-1')).toBe('visible'); + }); + + it('restricted then unresolved stays restricted', async () => { + await readyThread(); + await serve([restricted('A-1')]); + await serve([unresolved('A-1')]); + expect(state('A-1')).toBe('restricted'); + }); + + it('empty then unresolved gives unresolved', async () => { + await readyThread(); + await serve([unresolved('A-1')]); + expect(state('A-1')).toBe('unresolved'); + }); + + it('keys by workspace as well as ref', async () => { + await readyThread(); + await serve([vis('A-1', 'w'), restricted('A-1')].map((r, i) => (i ? { ...r, workspace: 'other' } : r))); + expect(thread.refs.get(key('A-1', 'w'))?.state).toBe('visible'); + expect(thread.refs.get(key('A-1', 'other'))?.state).toBe('restricted'); + }); + }); +}); diff --git a/web/src/lib/stores/chatThread.svelte.ts b/web/src/lib/stores/chatThread.svelte.ts index 6cee36963..34d8fe9c0 100644 --- a/web/src/lib/stores/chatThread.svelte.ts +++ b/web/src/lib/stores/chatThread.svelte.ts @@ -34,6 +34,14 @@ // reopen, not live. Known (N4): a send that times out AFTER the server committed // it shows the row beside the failed bubble after a refresh, until Retry. // +// REFS (TASK-4044 3c, ruling 687eb810). `refs` collects the verdicts every page and +// every send response carried, so the body renderer can link them. Refs past the +// server's 200 cap (refs_truncated) are not surfaced: they stay literal text, +// which fails safe. Verdicts merge in arrival order (a send's refs land outside +// the op chain), so a link stays until a later response re-judges its ref, up to +// a re-fetch of its page; /-/r re-checks access on every click (3c r1 H1/H2, +// ruling 83cb9a50). +// // Identity: every answer is fenced by a seq and authStore.identityFence(). import { untrack } from 'svelte'; import { api, isNotFoundError, isReplyTargetDeletedError, isReplyTargetNotFoundError } from '$lib/api/client'; @@ -41,7 +49,8 @@ import { isRequestTooLargeError, validateDraft, type SendBlock } from '$lib/chat import { ChatWindow, mergeBySeq, unreadDividerIndex, type WindowItem } from '$lib/chat/model'; import { authStore } from '$lib/stores/auth.svelte'; import { chatStore } from '$lib/stores/chat.svelte'; -import type { ChatConversationSummary, ChatMessage } from '$lib/types'; +import { refKey } from '$lib/chat/refs'; +import type { ChatConversationSummary, ChatMessage, ChatMessagesResponse, ChatRef } from '$lib/types'; /** Rows in the first page when nothing is unread, and in a load-earlier page. */ export const THREAD_PAGE = 50; @@ -50,6 +59,8 @@ export const THREAD_MAX_PAGE = 200; /** Pages one refresh will follow from the newest end before it sets hasNewer. */ export const REFRESH_MAX_PAGES = 5; +type ListOpts = Parameters[1]; + export type ThreadStatus = 'loading' | 'ready' | 'notFound' | 'failed'; /** @@ -82,6 +93,11 @@ export class ChatThread { detached = $state.raw([]); /** Set by the page from its bottom sentinel. */ atBottom = $state(false); + /** + * The ref verdicts this reader has been shown, by refKey (workspace, REF). + * Verdicts are per reader, so #reset clears them (TASK-4044 3c M32). + */ + refs = $state.raw>(new Map()); #win = new ChatWindow(); #n: number | null = null; @@ -146,6 +162,7 @@ export class ChatThread { this.#lastSeq = 0; this.#readSeq = 0; this.detached = []; + this.refs = new Map(); this.#ops = Promise.resolve(); this.#refreshQueued = null; this.#refreshDeferred = false; @@ -174,6 +191,35 @@ export class ChatThread { return () => mine === this.#seq && same(); } + /** + * Every list read goes through here. The page's refs are absorbed only while + * `current` holds, so a page that resolves after a reopen, a dispose or an + * identity change leaves no verdict behind (TASK-4044 3c, M32 and M33). + */ + async #list(n: number, opts: ListOpts, current: () => boolean): Promise { + const page = await api.chat.listMessages(n, opts); + if (current()) this.#absorbRefs(page.refs); + return page; + } + + /** + * Merge a response's refs (Q-h, ruling 687eb810). A `visible` or `restricted` + * entry is a verdict and REPLACES the held one, so a later page can upgrade or + * downgrade a link. `unresolved` is the server not looking (its 50-lookup + * cap), not a verdict: it only fills an empty key. + */ + #absorbRefs(incoming: ChatRef[] | undefined) { + if (!incoming?.length) return; + let next: Map | null = null; + for (const r of incoming) { + const key = refKey(r.workspace, r.ref); + const held = (next ?? this.refs).get(key); + if (r.state === 'unresolved' && held) continue; + (next ??= new Map(this.refs)).set(key, r); + } + if (next) this.refs = next; + } + async open(n: number | null): Promise { this.#seq++; this.#reset(); @@ -191,8 +237,8 @@ export class ChatThread { this.conversation = conv; const page = conv.unread > 0 - ? await api.chat.listMessages(n, { after_seq: conv.read_seq, limit: THREAD_MAX_PAGE }) - : await api.chat.listMessages(n, { limit: THREAD_PAGE }); + ? await this.#list(n, { after_seq: conv.read_seq, limit: THREAD_MAX_PAGE }, current) + : await this.#list(n, { limit: THREAD_PAGE }, current); if (!current()) return; this.openReadSeq = page.read_seq; this.#readSeq = page.read_seq; @@ -219,7 +265,7 @@ export class ChatThread { this.olderFailed = false; await this.#enqueue(current, async () => { try { - const page = await api.chat.listMessages(n, { before_seq: this.messages[0].seq, limit: THREAD_PAGE }); + const page = await this.#list(n, { before_seq: this.messages[0].seq, limit: THREAD_PAGE }, current); if (!current()) return; this.#win.merge(page.messages); this.#publish(this.#lastSeq); @@ -241,7 +287,7 @@ export class ChatThread { await this.#enqueue(current, async () => { try { if (!this.hasNewer || this.#win.hasInFlight()) return; - const page = await api.chat.listMessages(n, { after_seq: this.newestSeq, limit: THREAD_MAX_PAGE }); + const page = await this.#list(n, { after_seq: this.newestSeq, limit: THREAD_MAX_PAGE }, current); // A send in flight: its committed row may be in this page beside its bubble. if (!current() || this.#win.hasInFlight()) return; this.#win.merge(page.messages); @@ -278,7 +324,7 @@ export class ChatThread { if (!gap) { let cursor = this.newestSeq; for (let pages = 0; pages < REFRESH_MAX_PAGES; pages++) { - const page = await api.chat.listMessages(n, { after_seq: cursor, limit: THREAD_MAX_PAGE }); + const page = await this.#list(n, { after_seq: cursor, limit: THREAD_MAX_PAGE }, current); if (!current() || this.#held()) return; this.#win.merge(page.messages); this.#publish(page.has_more ? page.last_seq : null); @@ -290,8 +336,8 @@ export class ChatThread { const lo = this.messages[0].seq; const hi = this.newestSeq; const page = gap - ? await api.chat.listMessages(n, { after_seq: lo - 1, limit: THREAD_MAX_PAGE }) - : await api.chat.listMessages(n, { limit: THREAD_MAX_PAGE }); + ? await this.#list(n, { after_seq: lo - 1, limit: THREAD_MAX_PAGE }, current) + : await this.#list(n, { limit: THREAD_MAX_PAGE }, current); if (!current() || this.#held()) return; this.#win.merge(page.messages.filter((m) => m.seq >= lo && m.seq <= hi)); this.#publish(this.#lastSeq); @@ -366,7 +412,7 @@ export class ChatThread { if (!others() && this.hasNewer) { let rows: ChatMessage[] | null = null; try { - rows = (await api.chat.listMessages(n, { limit: THREAD_PAGE })).messages; + rows = (await this.#list(n, { limit: THREAD_PAGE }, current)).messages; } catch {} // a failed fetch keeps the window; the row detaches (ruling ec6bbe31) if (!current()) return; if (rows && !others()) { @@ -388,7 +434,7 @@ export class ChatThread { async #fill(n: number, upTo: number, current: () => boolean, others: () => boolean): Promise { try { for (let pages = 0; pages < REFRESH_MAX_PAGES && this.newestSeq < upTo - 1; pages++) { - const page = await api.chat.listMessages(n, { after_seq: this.newestSeq, limit: THREAD_MAX_PAGE }); + const page = await this.#list(n, { after_seq: this.newestSeq, limit: THREAD_MAX_PAGE }, current); if (!current() || page.messages.length === 0 || others()) return false; // a send began: its row could ride this page this.#win.merge(page.messages.filter((m) => m.seq < upTo)); this.#publish(null); @@ -415,6 +461,7 @@ export class ChatThread { ...(replyToSeq !== undefined ? { reply_to_seq: replyToSeq } : {}) }); if (!current()) return 'stale'; + this.#absorbRefs(res.refs); await this.#enqueue(current, () => this.#applyOwn(n, opKey, res.message, current)); if (!current()) return 'stale'; this.ownSendTick++; diff --git a/web/src/routes/console/+layout.svelte b/web/src/routes/console/+layout.svelte index a5d8f2709..b9b2e78ec 100644 --- a/web/src/routes/console/+layout.svelte +++ b/web/src/routes/console/+layout.svelte @@ -7,6 +7,7 @@ import { onMount } from 'svelte'; import { isBlockedByModal } from '$lib/a11y/viewerBackdrop'; import { clickOutside } from '$lib/utils/clickOutside'; + import ChatLink from '$lib/components/chat/ChatLink.svelte'; let { children } = $props(); let ready = $state(false); @@ -161,6 +162,9 @@ > Needs you + + : scoped .nav-link rules do not reach it (TASK-4044 3c). */ + .nav-links :global(.chat-nav-link) { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius); + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 500; + text-decoration: none; + transition: color 0.15s, background 0.15s; + } + + .nav-links :global(.chat-nav-link:hover) { + color: var(--text-primary); + background: var(--bg-hover); + text-decoration: none; + } + .nav-right { display: flex; align-items: center; @@ -459,6 +483,12 @@ background: var(--bg-hover); } + .nav-links :global(.chat-nav-link) { + width: 100%; + padding: var(--space-3) var(--space-4); + border-radius: 0; + } + .nav-link.active { color: var(--text-primary); background: var(--bg-tertiary); diff --git a/web/src/routes/console/consoleChatEntry.svelte.test.ts b/web/src/routes/console/consoleChatEntry.svelte.test.ts new file mode 100644 index 000000000..f02f1976f --- /dev/null +++ b/web/src/routes/console/consoleChatEntry.svelte.test.ts @@ -0,0 +1,68 @@ +// Runs in the jsdom vitest project (filename ends `.svelte.test.ts`). +// +// TASK-4044 3c (U31): the /console nav bar carries the Chat entry, after +// "Needs you", with the unread badge, and a click closes the mobile menu. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/svelte'; +import { createRawSnippet, tick, flushSync } from 'svelte'; + +vi.mock('$lib/api/client', () => { + const never = () => new Promise(() => {}); + return { + api: new Proxy({}, { get: () => new Proxy({}, { get: () => never }) }) + }; +}); +vi.mock('$lib/stores/auth.svelte', () => ({ + authStore: { + load: async () => ({ authenticated: true }), + user: { name: 'E2E', email: 'e2e@example.com', role: 'admin' }, + cloudMode: false, + authenticated: true, + onIdentityChange: () => () => {} + } +})); +vi.mock('$lib/stores/chat.svelte', () => ({ chatStore: { unreadTotal: 3 } })); + +import ConsoleLayout from './+layout.svelte'; + +const childSnippet = createRawSnippet(() => ({ render: () => `
child
` })); + +beforeEach(async () => { + render(ConsoleLayout, { props: { children: childSnippet } }); + for (let i = 0; i < 50 && !document.querySelector('.nav-links'); i++) { + await new Promise((r) => setTimeout(r, 5)); + flushSync(); + } +}); + +afterEach(() => { + cleanup(); + document.body.innerHTML = ''; +}); + +describe('console nav Chat entry (TASK-4044 3c)', () => { + it('is a nav-link to /messages right after "Needs you", with the unread count', () => { + const links = [...document.querySelectorAll('.nav-links a')]; + const needs = links.findIndex((a) => a.getAttribute('href') === '/console/needs'); + const chat = links.findIndex((a) => a.getAttribute('href') === '/messages'); + expect(needs).toBeGreaterThanOrEqual(0); + expect(chat).toBe(needs + 1); + expect(links[chat].classList.contains('nav-link')).toBe(true); + expect(links[chat].getAttribute('aria-label')).toBe('Chat, 3 unread'); + expect(links[chat].textContent).toContain('Chat'); + }); + + it('closes the mobile menu on click', async () => { + const toggle = document.querySelector('[aria-expanded]')!; + toggle.click(); + await tick(); + flushSync(); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + const a = document.querySelector('.nav-links a[href="/messages"]')!; + a.addEventListener('click', (e) => e.preventDefault()); + a.click(); + await tick(); + flushSync(); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + }); +}); diff --git a/web/src/routes/messages/[n]/+page.svelte b/web/src/routes/messages/[n]/+page.svelte index f5edd1f83..abfa90980 100644 --- a/web/src/routes/messages/[n]/+page.svelte +++ b/web/src/routes/messages/[n]/+page.svelte @@ -213,6 +213,7 @@ {/if}