From c3690204b0a9bfb199abcec553cd412c8ce840e7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:28:46 -0800 Subject: [PATCH 1/2] fix(dm-room): the room's head leads with its agent, and the panel starts closed Lifts the DM room onto its d215 frame at 1440: - A room with one agent leads its head with that agent's 28px face, then the name, then its runtime and DM chips beside the name rather than across the header. The panel toggle keeps the right edge. - The pod panel (members, board, files) is a team pod's furniture. A 1:1 room now keeps its own panel state, starting closed, so opening it in a room doesn't open it in every team pod and closing it in a team pod doesn't close it in rooms. The type comes from the sidebar's list, so a room never paints the panel while its detail loads. The artboard used av--28 and av--56 without defining them; both are now declared on the same quarter-radius scale as the other sizes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- docs/design/artboards/d215/index.html | 2 + .../v2/__tests__/V2LayoutSelection.test.tsx | 38 +++++++++++++++ .../src/v2/__tests__/V2ThreadStarter.test.tsx | 16 +++++++ .../v2/__tests__/v2-layout-invariants.test.ts | 11 ++++- frontend/src/v2/chat/chat-pane.css | 7 +++ frontend/src/v2/components/V2Layout.tsx | 47 ++++++++++++------- frontend/src/v2/components/V2Thread.tsx | 15 +++++- 7 files changed, 117 insertions(+), 19 deletions(-) diff --git a/docs/design/artboards/d215/index.html b/docs/design/artboards/d215/index.html index 1bf56d6a55..68db4b515f 100644 --- a/docs/design/artboards/d215/index.html +++ b/docs/design/artboards/d215/index.html @@ -200,8 +200,10 @@ .av svg { width: 100%; height: 100%; display: block; } .av--20 { width: 20px; height: 20px; border-radius: 6px; } .av--24 { width: 24px; height: 24px; border-radius: 6px; } +.av--28 { width: 28px; height: 28px; border-radius: 7px; } .av--36 { width: 36px; height: 36px; border-radius: 9px; } .av--44 { width: 44px; height: 44px; border-radius: 11px; } +.av--56 { width: 56px; height: 56px; border-radius: 14px; } .stack { display: inline-flex; } .stack .av { box-shadow: 0 0 0 2px var(--sf); } .stack .av + .av { margin-left: -6px; } diff --git a/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx b/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx index 2ff5a0e350..c3dcde8c2b 100644 --- a/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx +++ b/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx @@ -297,6 +297,44 @@ describe('V2Layout default pod selection', () => { expect(localStorage.getItem('v2.inspectorCollapsed')).toBe(preference); }); + test('a 1:1 room starts with the pod panel closed and keeps its own choice, apart from team pods', () => { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: jest.fn((query: string) => ({ + matches: query !== '(max-width: 760px)', + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })), + }); + mockPodsState.pods = [workspacePod, { ...workspacePod, _id: 'room', name: 'Scout', type: 'agent-room' } as V2Pod]; + const renderAt = (podId: string) => render( + + + } /> + + , + ); + + const room = renderAt('room'); + expect(screen.queryByTestId('workspace-inspector')).toBeNull(); + // Opening it in the room is remembered for rooms only; the team pod keeps its own default. + fireEvent.click(screen.getByRole('button', { name: 'toggle inspector' })); + expect(screen.getByTestId('workspace-inspector')).toBeInTheDocument(); + expect(localStorage.getItem('v2.inspectorCollapsed.direct')).toBe('0'); + expect(localStorage.getItem('v2.inspectorCollapsed')).toBeNull(); + room.unmount(); + + const team = renderAt('workspace'); + expect(screen.getByTestId('workspace-inspector')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'toggle inspector' })); + expect(localStorage.getItem('v2.inspectorCollapsed')).toBe('1'); + team.unmount(); + + renderAt('room'); + expect(screen.getByTestId('workspace-inspector')).toBeInTheDocument(); + }); + test('lands a new user in their oldest own workspace instead of auto-joined HQ', async () => { renderAutoLayout(); diff --git a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx index e3acda03ea..84d2b0333f 100644 --- a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx +++ b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx @@ -159,6 +159,22 @@ describe('V2Thread teaching empty states', () => { expect([...compact.querySelectorAll('.v2-pod-header__chip')].map((chip) => chip.textContent)).toEqual(['DM']); }); + test("a 1:1 room's head leads with the agent's face, before the name; a team pod's does not", () => { + const { unmount } = renderChat(makeAgentRoom()); + const header = document.querySelector('.v2-pod-header'); + expect(header).toHaveClass('v2-pod-header--direct'); + const face = screen.getByTestId('agent-room-header-avatar'); + // The name follows, so the face is not read out a second time. + expect(face).toHaveAttribute('aria-hidden', 'true'); + expect(face.firstElementChild).toHaveClass('v2-avatar'); + expect(face.nextElementSibling).toHaveClass('v2-thread__title'); + unmount(); + + renderChat(makeDetail()); + expect(screen.queryByTestId('agent-room-header-avatar')).toBeNull(); + expect(document.querySelector('.v2-pod-header')).not.toHaveClass('v2-pod-header--direct'); + }); + test('a 1:1 room whose runtime is unknown shows DM alone', () => { renderChat(makeAgentRoom()); const meta = document.querySelector('.v2-pod-header__meta:not(.v2-pod-header__meta--compact)'); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 490806b8ec..1a69c3d99d 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -320,6 +320,15 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(memory).toContain('background: transparent'); }); + test("a 1:1 room's head: a 28px face, and the chips beside the name with the spacer after them (d215 dm)", () => { + const pane = read('../chat/chat-pane.css'); + const face = ruleBody(pane, '.v2-thread .v2-pod-header__avatar .v2-avatar'); + expect(face).toContain('width: 28px'); + expect(face).toContain('height: 28px'); + expect(ruleBody(pane, '.v2-pod-header--direct .v2-thread__title')).toContain('flex: 0 1 auto'); + expect(ruleBody(pane, '.v2-pod-header--direct .v2-pod-header__meta')).toContain('margin-right: auto'); + }); + test('agent Control reconnect remains an ink action with a focus edge', () => { const action = ruleBody(agentSettings, '.v2-root .v2-agent-settings a.v2-agent-settings__action'); expect(action).toContain('background: var(--v2-ink)'); @@ -1832,7 +1841,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { 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("className={`v2-thread__header v2-pod-header${isDirectPod ? ' v2-pod-header--direct' : ''}`}"); expect(podChat).toContain('podChat.header.boardOpen'); expect(podChat).toContain('v2-thread__inspector-toggle'); expect(podChat).toContain('v2-pod-header__stack'); diff --git a/frontend/src/v2/chat/chat-pane.css b/frontend/src/v2/chat/chat-pane.css index 26b0381cbc..5fd3292353 100644 --- a/frontend/src/v2/chat/chat-pane.css +++ b/frontend/src/v2/chat/chat-pane.css @@ -427,6 +427,13 @@ .v2-thread .v2-pod-header__stack .v2-avatar + .v2-avatar { margin-left: -6px; } +/* A 1:1 room (d215 dm frame): the agent's 28px face, the name, then its chips beside the name rather + than across the header; the spacer falls after the chips, so the panel toggle keeps the right edge. */ +.v2-pod-header__avatar { display: inline-flex; flex: none; align-self: center; } +.v2-thread .v2-pod-header__avatar .v2-avatar { width: 28px; height: 28px; border-radius: var(--v2-r-7, 7px); } +.v2-pod-header--direct .v2-thread__title { flex: 0 1 auto; } +.v2-pod-header--direct .v2-pod-header__meta { margin-right: auto; } + .v2-pod-header__more { color: var(--v2-text-muted); font: 500 11.5px/1 var(--v2-font-mono); } /* D215: the board is a chip, a board glyph and the count. */ diff --git a/frontend/src/v2/components/V2Layout.tsx b/frontend/src/v2/components/V2Layout.tsx index 4bb0d30445..0f91e6b021 100644 --- a/frontend/src/v2/components/V2Layout.tsx +++ b/frontend/src/v2/components/V2Layout.tsx @@ -19,6 +19,9 @@ interface V2LayoutProps { } const INSPECTOR_PREF_KEY = 'v2.inspectorCollapsed'; +// A 1:1 room keeps its own panel state: the d215 dm frame draws the room without the pod panel, +// which carries a team pod's furniture (members, board, files), so the room starts with it closed. +const DIRECT_INSPECTOR_PREF_KEY = 'v2.inspectorCollapsed.direct'; const LAST_POD_KEY = 'v2:lastPodId'; const INVITE_BLOCKED_POD_TYPES = new Set(['agent-room', 'agent-dm']); const PHONE_MEDIA_QUERY = '(max-width: 760px)'; @@ -27,21 +30,22 @@ const isPhoneViewport = (): boolean => ( typeof window !== 'undefined' && !!window.matchMedia?.(PHONE_MEDIA_QUERY).matches ); -const readInspectorCollapsed = (): boolean => { +const readInspectorCollapsed = (key: string = INSPECTOR_PREF_KEY): boolean => { try { - const v = localStorage.getItem(INSPECTOR_PREF_KEY); + const v = localStorage.getItem(key); if (v !== null) return v === '1'; } catch { // Unavailable storage uses the same default as a fresh session. } + if (key === DIRECT_INSPECTOR_PREF_KEY) return true; // The artboard opens the desktop inspector by default; narrower viewports // keep it closed until requested. Explicit preferences take precedence. return !(typeof window !== 'undefined' && window.matchMedia?.('(min-width: 1200px)').matches); }; -const writeInspectorCollapsed = (next: boolean) => { +const writeInspectorCollapsed = (key: string, next: boolean) => { try { - localStorage.setItem(INSPECTOR_PREF_KEY, next ? '1' : '0'); + localStorage.setItem(key, next ? '1' : '0'); } catch { // localStorage unavailable; revert to default on next render. } @@ -90,9 +94,12 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => { // prevents its first rendered frame from flashing open; the media listener // below handles later viewport changes. On phones the sheet opens only // through the header or bottom control. - const [inspectorCollapsed, setInspectorCollapsed] = useState(() => ( + const [podInspectorCollapsed, setPodInspectorCollapsed] = useState(() => ( isPhoneViewport() ? true : readInspectorCollapsed() )); + const [directInspectorCollapsed, setDirectInspectorCollapsed] = useState(() => ( + isPhoneViewport() ? true : readInspectorCollapsed(DIRECT_INSPECTOR_PREF_KEY) + )); // Invite modal lives here (not in V2Inspector) so the chat header // invite icon and the inspector "+ Invite" button can both open it. The // modal itself is V2InviteModal — this component just owns open/close. @@ -108,7 +115,10 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => { const phoneViewport = window.matchMedia(PHONE_MEDIA_QUERY); const onChange = () => { setPhone(phoneViewport.matches); - if (phoneViewport.matches) setInspectorCollapsed(true); + if (phoneViewport.matches) { + setPodInspectorCollapsed(true); + setDirectInspectorCollapsed(true); + } }; onChange(); phoneViewport.addEventListener('change', onChange); @@ -119,17 +129,7 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => { // decides whether it should open; an established/dismissed user flips it off // as soon as the probe resolves. const [firstRunVisible, setFirstRunVisible] = useState(true); - const toggleInspector = useCallback(() => { - setInspectorCollapsed((prev) => { - const next = !prev; - writeInspectorCollapsed(next); - return next; - }); - }, []); - const openInspector = useCallback(() => { - setInspectorCollapsed(false); - writeInspectorCollapsed(false); - }, []); + // File pills stay useful without an artifact sub-page in the compact // inspector: mint the same authorized ObjectStore URL and open it directly. const openFile = useCallback((fileName: string) => { @@ -181,6 +181,19 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => { const selectedPodId = paramPodId || null; const detail = useV2PodDetail(selectedPodId); + // The sidebar's list already knows the type, so a room never paints the pod panel while its detail loads. + const selectedPodType = pods.find((pod) => pod._id === selectedPodId)?.type ?? detail.pod?.type; + const directPod = INVITE_BLOCKED_POD_TYPES.has(String(selectedPodType)); + const inspectorCollapsed = directPod ? directInspectorCollapsed : podInspectorCollapsed; + const toggleInspector = useCallback(() => { + const next = !inspectorCollapsed; + (directPod ? setDirectInspectorCollapsed : setPodInspectorCollapsed)(next); + writeInspectorCollapsed(directPod ? DIRECT_INSPECTOR_PREF_KEY : INSPECTOR_PREF_KEY, next); + }, [directPod, inspectorCollapsed]); + const openInspector = useCallback(() => { + (directPod ? setDirectInspectorCollapsed : setPodInspectorCollapsed)(false); + writeInspectorCollapsed(directPod ? DIRECT_INSPECTOR_PREF_KEY : INSPECTOR_PREF_KEY, false); + }, [directPod]); // Agent-room creation happens outside this pod-list hook. After navigation, // refresh the membership list once if that newly selected room is not in it // yet, so it immediately appears in the sidebar instead of waiting for a diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx index 08f38ac278..3260bbc176 100644 --- a/frontend/src/v2/components/V2Thread.tsx +++ b/frontend/src/v2/components/V2Thread.tsx @@ -1954,9 +1954,22 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in return (
-
+
{mobileNavButton} + {/* A room with one agent leads with that agent's face (d215 dm frame). The name follows, so the + face is decoration to a screen reader. */} + {isAgentRoom && botMembers.length === 1 && ( + + )}
{renaming ? ( From b598bb920c519b0017a267624b43096dde011031 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:32:15 -0800 Subject: [PATCH 2/2] docs(dm-room): say when a room's panel state is known Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- frontend/src/v2/components/V2Layout.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/v2/components/V2Layout.tsx b/frontend/src/v2/components/V2Layout.tsx index 0f91e6b021..786c83c4f9 100644 --- a/frontend/src/v2/components/V2Layout.tsx +++ b/frontend/src/v2/components/V2Layout.tsx @@ -181,7 +181,8 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => { const selectedPodId = paramPodId || null; const detail = useV2PodDetail(selectedPodId); - // The sidebar's list already knows the type, so a room never paints the pod panel while its detail loads. + // The sidebar's list knows the type before the detail loads. A room that isn't in the list yet + // (a cold deep link, a just-opened "Talk to") reads as a team pod until its detail arrives. const selectedPodType = pods.find((pod) => pod._id === selectedPodId)?.type ?? detail.pod?.type; const directPod = INVITE_BLOCKED_POD_TYPES.has(String(selectedPodType)); const inspectorCollapsed = directPod ? directInspectorCollapsed : podInspectorCollapsed;