diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f94273ef3..6eee7fe34 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1367,7 +1367,12 @@ "allFiles": "All {{count}} files", "agents": "Agents", "files": "Files", - "joinOpen": "Open" + "joinOpen": "Open", + "viewLabel": "Panel view", + "viewPod": "Pod", + "viewThreads": "Threads", + "hidePanel": "Hide panel", + "noThreads": "No threads in the loaded messages." }, "goal": { "title": "Goal", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 82edfeac9..1cb859634 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1362,7 +1362,12 @@ "allFiles": "全部{{count}}个文件", "agents": "智能体", "files": "文件", - "joinOpen": "开放" + "joinOpen": "开放", + "viewLabel": "面板视图", + "viewPod": "Pod", + "viewThreads": "话题", + "hidePanel": "收起面板", + "noThreads": "已加载的消息里没有话题。" }, "goal": { "title": "目标", diff --git a/frontend/src/v2/__tests__/V2Composer.test.tsx b/frontend/src/v2/__tests__/V2Composer.test.tsx index 74220a3e2..3c033609a 100644 --- a/frontend/src/v2/__tests__/V2Composer.test.tsx +++ b/frontend/src/v2/__tests__/V2Composer.test.tsx @@ -16,7 +16,7 @@ jest.mock('../../context/SocketContext', () => ({ // Composer behavior does not depend on avatar rendering. Keep this test // isolated from the external DiceBear package graph used by V2Avatar. jest.mock('../components/V2Avatar', () => { - const MockAvatar = () => ; + const MockAvatar = ({ name }: { name?: string }) => ; MockAvatar.displayName = 'MockAvatar'; return MockAvatar; }); @@ -85,7 +85,7 @@ const renderChat = (detail) => render( ); describe('V2Composer send button', () => { - test('the pod header carries the direction-C meta (members · agents), not a working count', () => { + test('the pod header carries member faces labelled with the counts (D215), not a working count', () => { const view = renderChat(makeDetail({ pod: { _id: 'p1', name: 'My Workspace', type: 'chat', description: 'Team discussion' }, members: [{ _id: 'u1', username: 'alice', isBot: false }, { _id: 'u2', username: 'bob', isBot: false }], @@ -97,12 +97,36 @@ describe('V2Composer send button', () => { expect(header.querySelector('h1')).toHaveTextContent('My Workspace'); expect(header.querySelector('.v2-hint__content')).toHaveTextContent('Team discussion'); const meta = header.querySelector('.v2-pod-header__meta:not(.v2-pod-header__meta--compact)'); - expect(meta).toHaveTextContent('2 members'); - expect(meta).toHaveTextContent('1 agent'); - expect(header.querySelector('.v2-pod-header__meta--compact')).toHaveTextContent('2 members'); + const faces = meta.querySelector('.v2-pod-header__faces'); + expect(faces).toHaveAttribute('aria-label', '2 members, 1 agent'); + expect(faces.querySelector('.v2-pod-header__stack').children).toHaveLength(2); + expect(meta.querySelector('.v2-pod-header__more')).toBeNull(); + // With no board count there is nothing for the phone head to carry. + expect(header.querySelector('.v2-pod-header__meta--compact')).toBeNull(); expect(view.container.querySelector('.v2-thread__working')).toBeNull(); }); + test('the header shows three faces, people before agents, and counts the rest as +N', () => { + const view = renderChat(makeDetail({ + members: [ + { _id: 'b1', username: 'bot-one', isBot: true }, + { _id: 'b2', username: 'bot-two', isBot: true }, + { _id: 'h1', username: 'ana', isBot: false }, + { _id: 'h2', username: 'ben', isBot: false }, + { _id: 'h3', username: 'cai', isBot: false }, + { _id: 'h4', username: 'dee', isBot: false }, + ], + agents: [{ agentName: 'scout', instanceId: 'default', displayName: 'Scout' }], + })); + + const meta = view.container.querySelector('.v2-pod-header__meta:not(.v2-pod-header__meta--compact)'); + const faces = meta.querySelector('.v2-pod-header__faces'); + expect(faces).toHaveAttribute('aria-label', '4 members, 1 agent'); + const titles = Array.from(faces.querySelector('.v2-pod-header__stack').children).map((face: Element) => face.getAttribute('data-name')); + expect(titles).toEqual(['ana', 'ben', 'cai']); + expect(meta.querySelector('.v2-pod-header__more')).toHaveTextContent('+3'); + }); + test('clicking the send button sends the drafted text', async () => { const detail = makeDetail(); renderChat(detail); diff --git a/frontend/src/v2/__tests__/V2Inspector.test.tsx b/frontend/src/v2/__tests__/V2Inspector.test.tsx index 8344d42ce..9a9840d16 100644 --- a/frontend/src/v2/__tests__/V2Inspector.test.tsx +++ b/frontend/src/v2/__tests__/V2Inspector.test.tsx @@ -1,10 +1,10 @@ /* eslint-disable react/display-name */ import React from 'react'; import { - fireEvent, render, screen, waitFor, + fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -import V2Inspector, { INSPECTOR_OPEN_KEY } from '../components/V2Inspector'; +import V2Inspector, { INSPECTOR_OPEN_KEY, threadRowsFrom } from '../components/V2Inspector'; import V2Avatar from '../components/V2Avatar'; const mockNavigate = jest.fn(); @@ -148,12 +148,75 @@ describe('V2Inspector', () => { currentUserId: 'owner-1', detail: { ...detail, pod: { ...detail.pod, createdBy: { _id: 'owner-1' }, joinPolicy: 'open' } } as any, }); - expect(screen.getAllByRole('radio').map((radio) => radio.getAttribute('aria-label') || radio.textContent)).toEqual([ + const join = screen.getByRole('radiogroup', { name: 'Who can join' }); + expect(within(join).getAllByRole('radio').map((radio) => radio.getAttribute('aria-label') || radio.textContent)).toEqual([ 'Open to join', 'Invite-only', ]); }); + test('the head switches between the pod and its threads, newest activity first (D215, TASK-734)', () => { + const messages = [ + { id: '10', content: 'Older root', created_at: '2026-10-31T01:00:00Z', user: { username: 'ada' }, thread_root_id: null }, + { id: '11', content: 'reply', created_at: '2026-10-31T01:05:00Z', user: { username: 'bo' }, thread_root_id: 10 }, + { id: '20', content: 'Newer root\nsecond line', created_at: '2026-10-31T02:00:00Z', user: { username: 'cy' }, thread_root_id: null }, + { id: '21', content: 'reply', created_at: '2026-10-31T02:10:00Z', user: { username: 'ada' }, thread_root_id: '20' }, + // A reply whose root is outside the loaded window has no text to show. + { id: '31', content: 'orphan', created_at: '2026-10-31T03:00:00Z', user: { username: 'ada' }, thread_root_id: 30 }, + ]; + expect(threadRowsFrom(messages as any).map((row) => [row.rootId, row.author, row.snippet])).toEqual([ + ['20', 'cy', 'Newer root second line'], + ['10', 'ada', 'Older root'], + ]); + + renderInspector({ detail: { ...detail, messages } as any }); + expect(screen.getByRole('heading', { name: /Needs you/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('radio', { name: 'Threads' })); + expect(screen.queryByRole('heading', { name: /Needs you/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /^cy/ })); + expect(mockNavigate).toHaveBeenCalledWith('/v2/pods/pod-1#message-20'); + }); + + test('a reconciled root is found by its Postgres id and listed by the id its anchor uses', () => { + const messages = [ + { id: '6a1f00000000000000000001', pgId: '40', content: 'Migrated root', created_at: '2026-10-31T01:00:00Z', user: { username: 'ada' }, thread_root_id: null }, + { id: '41', pgId: '41', content: 'reply', created_at: '2026-10-31T01:05:00Z', user: { username: 'bo' }, thread_root_id: 40 }, + ]; + expect(threadRowsFrom(messages as any).map((row) => [row.rootId, row.snippet])).toEqual([ + ['6a1f00000000000000000001', 'Migrated root'], + ]); + }); + + test('the threads view says so when the pod has none', () => { + renderInspector(); + fireEvent.click(screen.getByRole('radio', { name: 'Threads' })); + expect(screen.getByText('No threads in the loaded messages.')).toBeInTheDocument(); + }); + + test('opening another pod puts the panel back on the pod view', () => { + const view = renderInspector(); + fireEvent.click(screen.getByRole('radio', { name: 'Threads' })); + expect(screen.getByRole('radio', { name: 'Threads' })).toBeChecked(); + view.rerender( + + + , + ); + expect(screen.getByRole('radio', { name: 'Pod' })).toBeChecked(); + }); + + test('the open panel carries its own Hide panel key', () => { + const onClose = jest.fn(); + renderInspector({ onClose }); + fireEvent.click(screen.getByRole('button', { name: 'Hide panel' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + test('non-owner cannot see the join-policy controls', () => { renderInspector({ currentUserId: 'another-user', diff --git a/frontend/src/v2/__tests__/V2MessageRow.test.tsx b/frontend/src/v2/__tests__/V2MessageRow.test.tsx index a6d859620..9c84f9ac0 100644 --- a/frontend/src/v2/__tests__/V2MessageRow.test.tsx +++ b/frontend/src/v2/__tests__/V2MessageRow.test.tsx @@ -352,6 +352,8 @@ it('the approval-card branch links to its server-bound original pod', () => { }, }} />); expect(screen.getByTestId('approval-card')).toBeInTheDocument(); + // A Threads row lands on this card by its standard anchor. + expect(screen.getByTestId('approval-card').closest('#message-approval-origin-run')).toHaveAttribute('tabindex', '-1'); expect(screen.getByRole('link', { name: 'View run trace' })).toHaveAttribute('href', `/v2/pods/${runPodId}/runs/${runId}`); }); diff --git a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx index adf4e8aaa..3401eb93b 100644 --- a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx +++ b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx @@ -117,16 +117,14 @@ describe('V2Thread teaching empty states', () => { expect(document.querySelector('.v2-teammate')).toBeNull(); }); - test('compact header shows the member count; the agent count stays in the desktop meta', () => { + test('the desktop meta labels the member faces with both counts; the phone head carries no counts', () => { const detail = makeDetail({ agents: [{ agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria' }], }); renderChat(detail); - const compact = document.querySelector('.v2-pod-header__meta--compact'); - expect(compact).toHaveTextContent('1 member'); - expect(compact).not.toHaveTextContent('agent'); - expect(document.querySelector('.v2-pod-header__meta:not(.v2-pod-header__meta--compact)')).toHaveTextContent('1 agent'); + expect(document.querySelector('.v2-pod-header__meta--compact')).toBeNull(); + expect(document.querySelector('.v2-pod-header__faces')).toHaveAttribute('aria-label', '1 member, 1 agent'); }); test('multi-member regular pods use the same teaching state', () => { diff --git a/frontend/src/v2/__tests__/v2-copy-budget.test.tsx b/frontend/src/v2/__tests__/v2-copy-budget.test.tsx index 33d0f12f2..16008f3a9 100644 --- a/frontend/src/v2/__tests__/v2-copy-budget.test.tsx +++ b/frontend/src/v2/__tests__/v2-copy-budget.test.tsx @@ -49,7 +49,8 @@ describe('page head copy budget', () => { expect(css).toContain('.v2-page-head__identity .v2-hint--open,\n .v2-thread__title-line .v2-hint--open { display: contents; }'); expect(css).toContain('.v2-thread__title-line { display: flex; flex-wrap: wrap; align-items: center; }'); expect(css).toContain('.v2-thread__title-line .v2-hint__content { width: 100%; flex-basis: 100%; order: 1; }'); - expect(css).toMatch(/\.v2-pod-header \.v2-thread__header-row \{[^}]*padding: 11px 0;[^}]*align-items: flex-start;/); + expect(css).toMatch(/\.v2-pod-header \.v2-thread__header-row \{[^}]*padding: 10px 0;[^}]*align-items: flex-start;/); + expect(css).toContain('.v2-pod-header .v2-thread__title { display: flex; min-height: 32px; flex-direction: column; justify-content: center; }'); expect(css).toContain('.v2-pod-header__meta.v2-pod-header__meta--compact { display: flex; min-height: 28px; flex: 0 0 auto; white-space: nowrap; }'); expect(css).toContain('box-shadow: var(--v2-focus-ring);'); }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 5214c436c..889ee0514 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -565,11 +565,18 @@ describe('v2 layout invariants (CSS rule presence)', () => { test('grouped messages keep the avatar column so text never shifts (craft audit rule 3)', () => { // Consecutive same-author messages within the grouping window drop the - // header row; the ghost cell must match the artboard's 28px avatar + // header row; the ghost cell must match the D215 artboard's 36px avatar // column or grouped text mis-aligns with headed text by exactly the // avatar width — a defect jsdom cannot see. - expect(ruleBody(v2, '.v2-thread .v2-msg')).toContain('grid-template-columns: 28px'); - expect(ruleBody(v2, '.v2-thread .v2-msg__avatar-ghost')).toContain('width: 28px'); + expect(ruleBody(v2, '.v2-thread .v2-msg')).toContain('grid-template-columns: 36px'); + expect(ruleBody(v2, '.v2-thread .v2-msg__avatar-ghost')).toContain('width: 36px'); + expect(ruleBody(v2, '.v2-msg__gutter-time')).toContain('width: 36px'); + // The typing line is not a message row; its faces stay small. + expect(ruleBody(v2, '.v2-thread .v2-chat__typing-avatars .v2-avatar')).toContain('width: 20px'); + // The phone hides the panel's Hide key. It also carries .v2-icon-button, + // whose later display rule ties a one-class selector, so the hide rule + // must name both classes or it never applies. + expect(mediaBlockContaining(v2, '.v2-workspace-inspector__hide')).toMatch(/\.v2-root button\.v2-icon-button\.v2-workspace-inspector__hide \{ display: none; \}/); expect(v2).toContain('.v2-msg--grouped'); }); @@ -971,9 +978,11 @@ describe('v2 layout invariants (CSS rule presence)', () => { // of rows and recreates the v1 island. expect(ruleBody(v2, '.v2-chat__messages > *')).toContain('width: 100%'); expect(ruleBody(v2, '.v2-chat__messages > *')).toContain('margin-inline: 0'); - expect(ruleBody(v2, '.v2-composer')).toContain('border: 1px solid var(--v2-border)'); - // Direction C: the identity line is gone; the plus and Send sit inside the one bordered row. - expect(ruleBody(v2, '.v2-root button.v2-composer__plus')).toContain('border: 1px solid var(--v2-border)'); + // D215 (TASK-734): the composer is a floating card on the pop shadow, so + // no border draws its edge; the plus is a ghost key inside it. + expect(ruleBody(v2, '.v2-composer')).toContain('border: 0'); + expect(ruleBody(v2, '.v2-composer')).toContain('box-shadow: var(--v2-shadow-pop'); + expect(ruleBody(v2, '.v2-root button.v2-composer__plus')).toContain('border: 1px solid transparent'); // Nothing in the column may escape the shared edge (2026-08-23: // mentions and threads sat off-grid while messages aligned). // Mentions: the wash bleed must equal the padding (the old -12px against @@ -985,11 +994,11 @@ describe('v2 layout invariants (CSS rule presence)', () => { .toContain('width: calc(100% + 20px)'); // Threads (ux-lead 64476 (2)): the ROOT now renders inside the block, so // the block itself is a full-width child like any message and the INDENT - // moved to the inner wrap (chip + rail at the text column, 28px avatar + - // 8px gap). + // moved to the inner wrap (chip + rail at the text column, 36px avatar + + // 12px gap, D215). const block = ruleBody(v2, '.v2-thread-block'); expect(block).toContain('width: 100%'); - expect(ruleBody(v2, '.v2-thread .v2-thread-block__inner')).toContain('margin-left: 36px'); + expect(ruleBody(v2, '.v2-thread .v2-thread-block__inner')).toContain('margin-left: 48px'); // Indent + width must still sum to 100%: an indented child that ALSO // carried width:100% pushed every thread past the pane's right edge and // made the transcript horizontally swipeable (2026-08-24; 350 vs @@ -1143,8 +1152,8 @@ describe('v2 layout invariants (CSS rule presence)', () => { const rule = ruleBody(v2, '.v2-system-action-line'); expect(rule).toContain('display: grid'); expect(rule).toContain('grid-template-columns: 16px minmax(0, max-content) auto minmax(0, 1fr) auto'); - expect(rule).toContain('margin-left: 36px'); - expect(rule).toContain('width: calc(100% - 36px)'); + expect(rule).toContain('margin-left: 48px'); + expect(rule).toContain('width: calc(100% - 48px)'); expect(ruleBody(v2, '.v2-thread .v2-system-action-line .v2-avatar')).toContain('width: 16px'); expect(ruleBody(v2, '.v2-system-action-line__action')).toContain('grid-column: 3;'); expect(ruleBody(v2, '.v2-system-action-line time')).toContain('grid-column: 5'); @@ -1400,14 +1409,17 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(podBoard).not.toContain('+ {t(\'board.newTask\')}'); }); - test('the composer is one line with a 24px plus menu and a 28px ink Send that exists only with text (direction C PR 2a)', () => { - expect(lastRuleBody(v2, '.v2-composer__row')).toContain('min-height: 42px'); - expect(ruleBody(v2, '.v2-root button.v2-composer__plus')).toContain('width: 24px'); - expect(ruleBody(v2, '.v2-root button.v2-composer__plus')).toContain('border-radius: 4px'); + test('the composer is one line with a 32px plus menu and a 32px ink Send that exists only with text (D215, TASK-734)', () => { + expect(lastRuleBody(v2, '.v2-composer__row')).toContain('min-height: 52px'); + expect(ruleBody(v2, '.v2-composer')).toContain('border-radius: var(--v2-r-14, 14px)'); + expect(ruleBody(v2, '.v2-root button.v2-composer__plus')).toContain('width: 32px'); + expect(ruleBody(v2, '.v2-root button.v2-composer__plus')).toContain('border-radius: var(--v2-r-8, 8px)'); + // The menu opens above the 32px plus, not over it. + expect(ruleBody(v2, '.v2-composer__menu')).toContain('bottom: 40px'); expect(lastRuleBody(v2, '.v2-root .v2-composer__field > textarea')).toContain('max-height: 112px'); expect(lastRuleBody(v2, '.v2-root .v2-composer__field > textarea')).toContain('resize: none'); - expect(lastRuleBody(v2, '.v2-root button.v2-composer__send')).toContain('width: 28px'); - expect(lastRuleBody(v2, '.v2-root button.v2-composer__send')).toContain('background: var(--v2-ink)'); + expect(lastRuleBody(v2, '.v2-root button.v2-composer__send')).toContain('width: 32px'); + expect(lastRuleBody(v2, '.v2-root button.v2-composer__send')).toContain('background: linear-gradient(180deg, var(--v2-fill-a, var(--v2-ink)), var(--v2-fill-b, var(--v2-ink)))'); expect(composer).toContain('{hasText && ('); expect(composer).toContain("role=\"menu\""); expect(composer).not.toContain('postsAs'); @@ -1427,7 +1439,10 @@ describe('v2 layout invariants (CSS rule presence)', () => { }); test('threading restyle (PR 2b): runtime tag, 20px reaction chips, two-line quote, thread chip, band, strip, white jump pill', () => { - expect(ruleBody(v2, '.v2-msg__tag')).toContain('font: 500 11px/16px var(--v2-font-mono)'); + // D215: the runtime is plain mono joined to the time, "claude · 1:16 AM". + expect(ruleBody(v2, '.v2-msg__tag')).toContain('font: 500 11.5px/16px var(--v2-font-mono)'); + expect(ruleBody(v2, '.v2-msg__tag')).not.toContain('background'); + expect(ruleBody(v2, '.v2-msg__tag + .v2-msg__time::before')).toContain("content: '· '"); expect(ruleBody(v2, '.v2-thread .v2-msg__reaction')).toContain('height: 20px'); expect(ruleBody(v2, '.v2-thread .v2-msg__quote')).toContain('border-left: 2px solid var(--v2-border)'); expect(ruleBody(v2, '.v2-thread .v2-msg__quote-text')).toContain('-webkit-line-clamp: 2'); @@ -1598,16 +1613,19 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(status).not.toContain('box-shadow'); }); - test('the pod header is 50px: sans 15/600 name, inline description, mono meta — the working count moved to the inspector', () => { - // Walk-1 ruling (b) reverses the Main artboard's Bricolage 22px: a pod is - // a conversation, not a page. The display face stays on page titles. + test('the pod header is 64px: display 18/700 name, inline description, board chip and member faces (D215, TASK-734)', () => { + // D215 brings the display face back to the pod title (walk-1 ruling (b) + // had moved it to sans 15/600); the working count stays in the inspector. expect(podChat).toContain('className="v2-thread__header v2-pod-header"'); expect(podChat).toContain('podChat.header.boardOpen'); expect(podChat).toContain('v2-thread__inspector-toggle'); + expect(podChat).toContain('v2-pod-header__stack'); expect(podChat).not.toContain('podChat.header.agentsWorking'); - expect(ruleBody(v2, '.v2-thread__header')).toContain('min-height: 50px'); + expect(ruleBody(v2, '.v2-thread__header')).toContain('min-height: 64px'); expect(ruleBody(v2, '.v2-thread__header')).toContain('border-bottom: 1px solid var(--v2-border-soft)'); - expect(ruleBody(v2, '.v2-thread__title h1')).toContain('font: 600 15px/20px var(--v2-font)'); + expect(ruleBody(v2, '.v2-thread__title h1')).toContain('font: 700 18px/24px var(--v2-font-display)'); + // Faces overlap by 6px on a 2px surface ring, the artboard's stack. + expect(ruleBody(v2, '.v2-thread .v2-pod-header__stack .v2-avatar + .v2-avatar')).toContain('margin-left: -6px'); expect(ruleBody(v2, '.v2-pod-header__meta')).toContain('font: 400 11px/16px var(--v2-font-mono)'); // Both metas carry the base class; the compact one must out-rank the base // `display: flex` or 1440 shows two metas side by side (ux-lead, 2026-09-06). @@ -1619,7 +1637,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(compactMeta).toBeGreaterThan(baseMeta); // TASK-647 item 3: the phone line keeps the board link, with a 44px hit // area drawn as a pseudo-element so the 28px meta row does not grow. - expect(ruleBody(v2, 'button.v2-pod-header__board--compact::before')).toContain('inset: -14px -10px'); + expect(ruleBody(v2, 'button.v2-pod-header__board--compact::before')).toContain('inset: -14px -6px'); expect(ruleBody(v2, '.v2-thread__title-line > h1')).toContain('max-width: calc(100% - 36px)'); expect(v2).not.toContain('.v2-thread__working'); }); @@ -1640,15 +1658,16 @@ describe('v2 layout invariants (CSS rule presence)', () => { const author = selectorRuleBody(v2, '.v2-thread .v2-msg__author'); const time = ruleBody(v2, '.v2-thread .v2-msg__time'); const actions = ruleBody(v2, '.v2-thread .v2-msg__actions'); - expect(title).toContain('var(--v2-font)'); - expect(title).not.toContain('var(--v2-font-display)'); - expect(avatarRule).toContain('width: 28px'); - expect(avatarRule).toContain('border-radius: 4px'); + // D215 (TASK-734): display title, 36px faces at radius 9 on a 1px ring, + // and the time in the 11.5 mono the runtime label shares. + expect(title).toContain('var(--v2-font-display)'); + expect(avatarRule).toContain('width: 36px'); + expect(avatarRule).toContain('border-radius: var(--v2-r-9, 9px)'); expect(avatarRule).toContain('border: 0'); - expect(avatarRule).toContain('box-shadow: none'); + expect(avatarRule).toContain('box-shadow: 0 0 0 1px var(--v2-av-ring'); expect(author).toContain('font-size: 14px'); expect(author).toContain('font-weight: 600'); - expect(time).toContain('font: 500 12px/16px var(--v2-font-mono)'); + expect(time).toContain('font: 500 11.5px/16px var(--v2-font-mono)'); expect(actions).toContain('border-radius: 4px'); expect(actions).toContain('box-shadow: none'); // These scoped rules only take effect when the chat mounts the thread @@ -2893,14 +2912,14 @@ describe('v2 layout invariants (CSS rule presence)', () => { v2.indexOf('.v2-chat__delivery-hint,\n.v2-chat__no-agents,\n.v2-chat__starts {'), v2.indexOf('}', v2.indexOf('.v2-chat__delivery-hint,\n.v2-chat__no-agents,\n.v2-chat__starts {')), ); - expect(slot).toContain('margin: -4px 0 8px 36px'); - expect(slot).toContain('width: calc(100% - 36px)'); + expect(slot).toContain('margin: -4px 0 8px 48px'); + expect(slot).toContain('width: calc(100% - 48px)'); // Their box is declared exactly once in the sheet — the shared slot — so // there is no second copy left for the cascade to pick between. The - // margin pair is unique to this slot; `width: calc(100% - 36px)` is not + // margin pair is unique to this slot; `width: calc(100% - 48px)` is not // asserted globally because `.v2-thread-block` carries a complement of // its own one indent over. - expect(v2.match(/margin: -4px 0 8px 36px;/g) || []).toHaveLength(1); + expect(v2.match(/margin: -4px 0 8px 48px;/g) || []).toHaveLength(1); // And neither row's own block restates a box. `ruleBody` cannot answer // this: it takes the FIRST line-start match, and the shared rule's second // selector line is itself a line-start `.v2-chat__no-agents {`, so it @@ -2949,10 +2968,10 @@ describe('v2 layout invariants (CSS rule presence)', () => { const threadGrid = ruleBody(v2, '.v2-thread .v2-msg'); const lead = /grid-template-columns:\s*(\d+)px/.exec(threadGrid); const gap = /(?:^|\s)gap:\s*(\d+)px/.exec(threadGrid); - expect(lead?.[1]).toBe('28'); - expect(gap?.[1]).toBe('8'); + expect(lead?.[1]).toBe('36'); + expect(gap?.[1]).toBe('12'); const indent = `${Number(lead?.[1]) + Number(gap?.[1])}px`; - expect(indent).toBe('36px'); + expect(indent).toBe('48px'); // The base grid is deliberately NOT the target. Without this line a // future reader finds 38 + 12 = 50 unreferenced and "corrects" the slot // back to it, which is exactly how this shipped wrong the first time. @@ -5318,6 +5337,21 @@ describe('pod board column scroll (TASK-698)', () => { test('the header Invite hides its label on a phone, keeping it for screen readers', () => { const block = mediaBlockContaining(read('../v2.css'), '.v2-pod-header__invite span'); expect(block).toMatch(/\.v2-pod-header__invite span\s*\{[^}]*clip-path:\s*inset\(50%\)/); - expect(block).toMatch(/\.v2-pod-header__invite\s*\{[^}]*width:\s*28px/); + expect(block).toMatch(/\.v2-pod-header__invite\s*\{[^}]*width:\s*32px/); + // 32px keys take touches at 44px. The inset runs from the padding box, + // inside a 1px border, so it is -7px: 30 + 2 x 7 = 44 (-6px measured 42 in a browser). + const phoneHead = mediaBlockContaining(read('../v2.css'), '.v2-root button.v2-thread__back::after'); + for (const key of ['v2-thread__back', 'v2-pod-header__invite', 'v2-thread__inspector-toggle', 'v2-composer__plus', 'v2-composer__send']) { + expect(phoneHead).toContain(`.v2-root button.${key}::after`); + } + expect(phoneHead).toMatch(/::after \{\s*content: '';\s*position: absolute;[^}]*inset: -7px;\s*\}/); + // z-index keeps every key's hit area above later positioned siblings. + const raised = phoneHead.match(/([^{}]*)\{ position: relative; z-index: 1; \}/); + expect(raised).not.toBeNull(); + for (const key of ['v2-thread__back', 'v2-pod-header__invite', 'v2-thread__inspector-toggle', 'v2-composer__plus', 'v2-composer__send']) { + expect(raised![1]).toContain(`.v2-root button.${key}`); + } + // The 12px gap is what keeps neighbouring 44px areas from overlapping. + expect(phoneHead).toMatch(/\.v2-pod-header \.v2-thread__header-row \{[^}]*gap: 12px;/); }); }); diff --git a/frontend/src/v2/components/V2Inspector.tsx b/frontend/src/v2/components/V2Inspector.tsx index ffce4a61c..c006504cd 100644 --- a/frontend/src/v2/components/V2Inspector.tsx +++ b/frontend/src/v2/components/V2Inspector.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import CloseIcon from '@mui/icons-material/Close'; +import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import V2Avatar from './V2Avatar'; @@ -37,6 +38,45 @@ const whenLabel = (value: string | null | undefined): string => { return days < 30 ? `${days}d` : `${Math.floor(days / 30)}mo`; }; +type InspectorView = 'pod' | 'threads'; + +interface ThreadRow { rootId: string; author: string; snippet: string; lastAt: string | null } + +/** + * The pod's threads, newest activity first, from the messages already loaded. + * A root outside the loaded window has no text to show, so it waits until + * history reaches it rather than rendering as a blank row. + */ +export const threadRowsFrom = (messages: UseV2PodDetailResult['messages']): ThreadRow[] => { + // A reconciled root's `id` is its old Mongo id, while replies carry the + // root's Postgres id in thread_root_id, so the root answers to both. Rows + // key on the exposed id, which is what the #message- anchor uses. + const byId = new Map(); + messages.forEach((message) => { + byId.set(String(message.id), message); + const { pgId } = message as { pgId?: string | number | null }; + if (pgId !== null && pgId !== undefined && pgId !== '') byId.set(String(pgId), message); + }); + const lastAt = new Map(); + messages.forEach((message) => { + if (message.thread_root_id === null || message.thread_root_id === undefined) return; + const rootId = String(byId.get(String(message.thread_root_id))?.id ?? message.thread_root_id); + const at = message.created_at || message.createdAt || ''; + if (!lastAt.has(rootId) || at > (lastAt.get(rootId) || '')) lastAt.set(rootId, at); + }); + const rows: ThreadRow[] = []; + lastAt.forEach((at, rootId) => { + const root = byId.get(rootId); + if (!root) return; + const author = root.user?.username + || (typeof root.userId === 'object' ? root.userId?.username : undefined) + || ''; + const snippet = String(root.content || '').replace(/\s+/g, ' ').trim().slice(0, 120); + rows.push({ rootId, author, snippet, lastAt: at || null }); + }); + return rows.sort((a, b) => (b.lastAt || '').localeCompare(a.lastAt || '')); +}; + interface TaskItem { taskId: string; title: string; @@ -128,6 +168,10 @@ const V2Inspector: React.FC = ({ detail, currentUserId, onPodC const [policyBusy, setPolicyBusy] = useState(false); const [policyError, setPolicyError] = useState(null); const [openGroups, setOpenGroups] = useState>(() => readOpenGroups()); + // D215 (TASK-734): the panel head switches between the pod and its threads. + const [view, setView] = useState('pod'); + useEffect(() => { setView('pod'); }, [pod?._id]); + const threads = useMemo(() => threadRowsFrom(detail.messages || []), [detail.messages]); const toggleGroup = (group: InspectorGroup) => { setOpenGroups((current) => { const next = new Set(current); @@ -247,8 +291,20 @@ const V2Inspector: React.FC = ({ detail, currentUserId, onPodC return (