mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-11-02 01:19:57 +00:00
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:
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user