From fda3a906bf9d2b5c53ec6e7b43928b28f9df4f5a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:34:44 -0700 Subject: [PATCH 1/5] feat(v2): pod chat matches the D215 artboard (TASK-734) Pod header 64px with the display title, board chip and member faces; the panel toggle shows only while the panel is hidden, and the open panel carries Pod | Threads and a Hide panel key. Messages take 36px faces at radius 9 with the runtime as plain mono before the time; the side-row indent follows to 48px. The composer is a floating card with ghost plus and a filled Send. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQmb6BbxDrAtw45bgN --- frontend/src/i18n/locales/en.json | 7 +- frontend/src/i18n/locales/zh-CN.json | 7 +- frontend/src/v2/__tests__/V2Composer.test.tsx | 11 +- .../src/v2/__tests__/V2Inspector.test.tsx | 42 ++- .../src/v2/__tests__/V2ThreadStarter.test.tsx | 8 +- .../src/v2/__tests__/v2-copy-budget.test.tsx | 3 +- .../v2/__tests__/v2-layout-invariants.test.ts | 87 +++--- frontend/src/v2/components/V2Inspector.tsx | 83 +++++- frontend/src/v2/components/V2Thread.tsx | 71 +++-- frontend/src/v2/v2.css | 255 ++++++++++++------ 10 files changed, 404 insertions(+), 170 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f94273ef33..da7a5283af 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 yet." }, "goal": { "title": "Goal", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 82edfeac9a..ac8fa2307a 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 74220a3e28..e5ef06214d 100644 --- a/frontend/src/v2/__tests__/V2Composer.test.tsx +++ b/frontend/src/v2/__tests__/V2Composer.test.tsx @@ -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,9 +97,12 @@ 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(); }); diff --git a/frontend/src/v2/__tests__/V2Inspector.test.tsx b/frontend/src/v2/__tests__/V2Inspector.test.tsx index 8344d42ce4..daae3c311b 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,48 @@ 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('the threads view says so when the pod has none', () => { + renderInspector(); + fireEvent.click(screen.getByRole('radio', { name: 'Threads' })); + expect(screen.getByText('No threads yet.')).toBeInTheDocument(); + }); + + 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__/V2ThreadStarter.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx index adf4e8aaaf..3401eb93b8 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 33d0f12f25..16008f3a9f 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 5214c436c2..4e3a5cfd8f 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -565,11 +565,12 @@ 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'); expect(v2).toContain('.v2-msg--grouped'); }); @@ -971,9 +972,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 +988,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 +1146,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 +1403,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 +1433,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 +1607,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). @@ -1640,15 +1652,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 +2906,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 +2962,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 +5331,6 @@ 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/); }); }); diff --git a/frontend/src/v2/components/V2Inspector.tsx b/frontend/src/v2/components/V2Inspector.tsx index ffce4a61c2..8873162c48 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,37 @@ 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[] => { + const byId = new Map(messages.map((message) => [String(message.id), message])); + const lastAt = new Map(); + messages.forEach((message) => { + if (message.thread_root_id === null || message.thread_root_id === undefined) return; + const rootId = String(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 +160,9 @@ 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'); + const threads = useMemo(() => threadRowsFrom(detail.messages || []), [detail.messages]); const toggleGroup = (group: InspectorGroup) => { setOpenGroups((current) => { const next = new Set(current); @@ -247,8 +282,20 @@ const V2Inspector: React.FC = ({ detail, currentUserId, onPodC return (