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
This commit is contained in:
xarmian
2026-10-29 02:59:03 -04:00
committed by GitHub
parent e9cd8bcdc7
commit 7e5384ca07
15 changed files with 1436 additions and 23 deletions
+130
View File
@@ -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<void> {
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<number> {
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');
});
+196
View File
@@ -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<number> {
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<Row> {
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<Page2> {
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);
});
});
+120
View File
@@ -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([]);
});
});
+323
View File
@@ -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<ChatMessage, 'body' | 'context_workspace' | 'deleted'>,
refs: readonly ChatRef[]
): RefSegment[] {
if (msg.deleted || !msg.body) return msg.body ? [{ type: 'text', text: msg.body }] : [];
const byKey = new Map<string, ChatRef>();
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;
}
+27
View File
@@ -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<string, string>;
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(/(?<!flex-)direction|accept[-_]instructions/i);
}
});
});
+7 -6
View File
@@ -3,12 +3,13 @@
import UnreadBadge from '$lib/components/chat/UnreadBadge.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
// The one entry to /messages (TASK-4044 PR 2a-ii). Two placements share it,
// the TopBar user menu and the You sheet; 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(.<class>), 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(.<class>), 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,
@@ -1,11 +1,15 @@
<script lang="ts">
import { longPress } from '$lib/chat/longPress';
import MessageMenu from '$lib/components/chat/MessageMenu.svelte';
import type { ChatMessage } from '$lib/types';
import { tokenizeBody } from '$lib/chat/refs';
import type { ChatMessage, ChatRef } from '$lib/types';
// One confirmed message (TASK-4044 PR 3a). The body is Svelte text
// interpolation, never {@html}: bodies are plain text (IDEA-4083 tracks
// markdown). A tombstone shows "Message deleted" and nothing of the body.
// One confirmed message (TASK-4044 PR 3a). The body is rendered from
// tokenizeBody segments through Svelte text interpolation, never {@html}:
// bodies are plain text (IDEA-4083 tracks markdown). Only a ref the server
// called `visible` becomes a link; `restricted` is a placeholder with no href
// and no title; anything else stays the literal text, `[[...]]` kept (TASK-4044
// 3c, ruling 687eb810). A tombstone shows "Message deleted" and nothing of the body.
//
// PR 3b: with `onReply` and `onDelete` a live message has an action menu. It
// opens on hover or keyboard focus (:focus-visible; a tap focuses the bubble
@@ -13,11 +17,14 @@
// never has one.
let {
message,
refs,
onJumpToQuote,
onReply,
onDelete
}: {
message: ChatMessage;
/** The thread's ref verdicts; absent means every ref stays literal. */
refs?: ReadonlyMap<string, ChatRef>;
onJumpToQuote?: (seq: number) => void;
onReply?: (m: ChatMessage) => void;
onDelete?: (m: ChatMessage) => void;
@@ -48,6 +55,7 @@
new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
);
let quote = $derived(message.reply_to);
let segments = $derived(tokenizeBody(message, refs ? [...refs.values()] : []));
</script>
<!-- The article is the hover/focus/long-press target for the menu; the menu's own buttons are the interactive controls. -->
@@ -99,7 +107,12 @@
<span class="quote-text">{quote.deleted ? 'Deleted message' : quote.excerpt}</span>
</button>
{/if}
<p class="body">{message.body}</p>
<!--
data-sveltekit-reload: /-/r/{ws}/{ref} is a SERVER redirect route with no
client route behind it, so the SvelteKit router must not take the click
(see DecisionChips.svelte). The segments sit on one line: .body is pre-wrap.
-->
<p class="body">{#each segments as seg, i (i)}{#if seg.type === 'link'}<a data-sveltekit-reload href={seg.href} title={seg.title}>{seg.text}</a>{:else if seg.type === 'restricted'}<span class="ref-restricted">{seg.text}</span>{:else}{seg.text}{/if}{/each}</p>
{/if}
{#if menuOpen && onReply && onDelete}
<MessageMenu
@@ -153,6 +166,13 @@
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.body a {
color: var(--accent-blue);
}
.ref-restricted {
font-style: italic;
color: var(--text-secondary);
}
.tombstone {
font-style: italic;
color: var(--text-secondary);
@@ -6,7 +6,8 @@ import { tick } from 'svelte';
import MessageBubble from './MessageBubble.svelte';
import PendingBubble from './PendingBubble.svelte';
import UnreadDivider from './UnreadDivider.svelte';
import type { ChatMessage } from '$lib/types';
import { refKey } from '$lib/chat/refs';
import type { ChatMessage, ChatRef } from '$lib/types';
const base: ChatMessage = {
id: 'm5',
@@ -325,3 +326,93 @@ describe('PendingBubble', () => {
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> = {}): 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: '<b>t</b>' });
const text = 'line one\n <b>bold</b> [[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 <b>bold</b> TASK-1\nend');
expect(b.querySelector('b')).toBeNull();
expect(b.querySelector('a')!.getAttribute('title')).toBe('<b>t</b>');
});
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]]');
});
});
@@ -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<typeof import('$lib/stores/auth.svelte')>();
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<HTMLAnchorElement>('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();
});
});
@@ -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 @@
<span class="nav-label">Tags</span>
</a>
{/if}
<!--
TASK-4044 3c (Option C, ruling 687eb810 Q-e): the Chat entry. Chat is
per user, not per workspace, so it is gated on being signed in and
sits OUTSIDE the `!isGuest` block: a guest member of this workspace
still has their own chat. href is global, not wsPrefix.
-->
{#if authStore.authenticated}
<a
href="/messages"
class="nav-item"
class:active={isMessagesPage}
aria-label={chatStore.unreadTotal > 0 ? `Chat, ${chatStore.unreadTotal} unread` : 'Chat'}
onclick={() => uiStore.onNavigate()}
>
<span class="nav-icon">💬</span>
<span class="nav-label">Chat</span>
<UnreadBadge count={chatStore.unreadTotal} />
</a>
{/if}
{#if isGuest}
<div class="section-header">
+258 -1
View File
@@ -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-<call index>. */
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');
});
});
});
+57 -10
View File
@@ -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<typeof api.chat.listMessages>[1];
export type ThreadStatus = 'loading' | 'ready' | 'notFound' | 'failed';
/**
@@ -82,6 +93,11 @@ export class ChatThread {
detached = $state.raw<ChatMessage[]>([]);
/** 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<ReadonlyMap<string, ChatRef>>(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<ChatMessagesResponse> {
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<string, ChatRef> | 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<void> {
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<boolean> {
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++;
+30
View File
@@ -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
</a>
<!-- TASK-4044 3c: the Chat entry. ChatLink owns its element, so the
sibling look comes from the :global(.chat-nav-link) rules below. -->
<ChatLink class="nav-link chat-nav-link" onclick={closeMobileMenu} />
<a
href="/console/settings"
class="nav-link"
@@ -341,6 +345,26 @@
background: var(--bg-tertiary);
}
/* ChatLink's own <a>: 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);
@@ -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: () => `<div>child</div>` }));
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<HTMLAnchorElement>('.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<HTMLElement>('[aria-expanded]')!;
toggle.click();
await tick();
flushSync();
expect(toggle.getAttribute('aria-expanded')).toBe('true');
const a = document.querySelector<HTMLAnchorElement>('.nav-links a[href="/messages"]')!;
a.addEventListener('click', (e) => e.preventDefault());
a.click();
await tick();
flushSync();
expect(toggle.getAttribute('aria-expanded')).toBe('false');
});
});
+2
View File
@@ -213,6 +213,7 @@
{/if}
<MessageBubble
message={item.message}
refs={thread.refs}
onJumpToQuote={jumpToQuote}
onReply={startReply}
onDelete={deleteMessage}
@@ -257,6 +258,7 @@
{#each thread.detached as m (m.seq)}
<MessageBubble
message={m}
refs={thread.refs}
onJumpToQuote={jumpToQuote}
onReply={startReply}
onDelete={deleteMessage}