Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/design/artboards/d215/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/v2/__tests__/V2LayoutSelection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MemoryRouter initialEntries={[`/v2/pods/${podId}`]}>
<Routes>
<Route path="/v2/pods/:podId" element={<V2Layout selectionMode="param" />} />
</Routes>
</MemoryRouter>,
);

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();

Expand Down
16 changes: 16 additions & 0 deletions frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)');
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)');
Expand Down Expand Up @@ -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');
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/v2/chat/chat-pane.css
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
48 changes: 31 additions & 17 deletions frontend/src/v2/components/V2Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)';
Expand All @@ -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.
}
Expand Down Expand Up @@ -90,9 +94,12 @@ const V2Layout: React.FC<V2LayoutProps> = ({ 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<boolean>(() => (
const [podInspectorCollapsed, setPodInspectorCollapsed] = useState<boolean>(() => (
isPhoneViewport() ? true : readInspectorCollapsed()
));
const [directInspectorCollapsed, setDirectInspectorCollapsed] = useState<boolean>(() => (
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.
Expand All @@ -108,7 +115,10 @@ const V2Layout: React.FC<V2LayoutProps> = ({ 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);
Expand All @@ -119,17 +129,7 @@ const V2Layout: React.FC<V2LayoutProps> = ({ 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) => {
Expand Down Expand Up @@ -181,6 +181,20 @@ const V2Layout: React.FC<V2LayoutProps> = ({ 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
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/v2/components/V2Thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1954,9 +1954,22 @@ const V2Thread: React.FC<V2ThreadProps> = ({ detail, firstRunVisible = false, in
return (
<main className="v2-pane v2-pane--main">
<div className="v2-chat v2-thread">
<header className="v2-thread__header v2-pod-header">
<header className={`v2-thread__header v2-pod-header${isDirectPod ? ' v2-pod-header--direct' : ''}`}>
<div className="v2-thread__header-row">
{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 && (
<span className="v2-pod-header__avatar" aria-hidden="true" data-testid="agent-room-header-avatar">
<V2Avatar
name={botMembers[0]?.botMetadata?.displayName || botMembers[0]?.username || ''}
src={botMembers[0]?.profilePicture || undefined}
tone="flat"
kind="agent"
seed={botMembers[0]?._id || botMembers[0]?.username || undefined}
/>
</span>
)}
<div className="v2-thread__title">
<div className="v2-thread__title-line">
{renaming ? (
Expand Down
Loading