diff --git a/docs/design/artboards/d215/index.html b/docs/design/artboards/d215/index.html index 1bf56d6a5..68db4b515 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 2ff5a0e35..c3dcde8c2 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 e3acda03e..84d2b0333 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 490806b8e..1a69c3d99 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 26b0381cb..5fd329235 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 4bb0d3044..786c83c4f 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,20 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => { const selectedPodId = paramPodId || null; const detail = useV2PodDetail(selectedPodId); + // 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; + 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 08f38ac27..3260bbc17 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 ? (