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 && (
+
+
+
+ )}