-
-
- {t('podsSidebar.title')}
-
-
-
- {showCreate && (
-
- )}
-
-
-
-
-
setSearch(e.target.value)}
- />
-
-
-
-
- {FILTERS.map((f) => (
-
- ))}
-
-
- {filter === 'community' && (
-
- {COMMUNITY_VIEWS.map((view) => (
+
+ {loading &&
}
+ {!loading && error &&
{error}
}
+ {!loading && !error && (
+
+
+ {t('podsSidebar.workspace.rooms')}
+ {rooms.map((pod) => renderPodRow(pod, 'room'))}
- ))}
-
- )}
-
-
- {visibleLoading &&
}
- {!visibleLoading && visibleError &&
{visibleError}
}
- {!visibleLoading
- && !visibleError
- && filter === 'community'
- && communityView === 'discover'
- && joinNotice && (
-
{joinNotice}
- )}
- {!visibleLoading && !visibleError && filtered.length === 0 && (
-
- {search
- ? t('podsSidebar.empty.noMatch')
- : (filter === 'community'
- ? t(
- communityView === 'discover'
- ? 'podsSidebar.discover.empty'
- : 'podsSidebar.discover.joinedEmpty',
- )
- : (filter === 'private'
- ? t('podsSidebar.empty.noDirectMessages')
- : t('podsSidebar.empty.noPods')))}
-
- )}
-
- {!visibleLoading
- && !visibleError
- && filter === 'community'
- && communityView === 'discover'
- && filtered.map((pod) => (
-
- {podMarkFor(pod)}
-
- {pod.name}
- {pod.description && (
- {pod.description}
- )}
-
- {t('podsSidebar.meta.memberCount', { count: pod.members?.length || 0 })}
-
-
-
-
- ))}
-
- {!visibleLoading && grouped.map((group) => (
-
-
{group.label}
- {group.items.map((pod) => {
- const memberCount = pod.members?.length || 0;
- const agentCount = agentCountFor(pod);
- const time = formatRelativeTime(pod.lastMessage?.createdAt || pod.updatedAt || pod.createdAt);
- const active = pod._id === selectedPodId;
- const meta = podKind(pod) === 'private'
- ? t('podsSidebar.meta.directMessage')
- : (agentCount > 0
- ? t('podsSidebar.meta.agentCount', { count: agentCount })
- : t('podsSidebar.meta.memberCount', { count: memberCount }));
- const snippet = podSnippetFor(pod, meta);
- const pinnedNow = isPinned(pod._id);
- const typing = typingPods.has(pod._id);
- const unread = isUnread(pod._id, pod.lastMessage?.createdAt);
- return (
-
-
- ))}
-
+
+ )}
+
+
+
+ {t('podsSidebar.workspace.channels')}
+
+ {channels.map((connector) => (
+
+ ))}
+
+
+
- {showCommunityOffer && (
-
-
-
- {t('podsSidebar.community.copy')}
-
-
-
+
+ {t('podsSidebar.workspace.direct')}
+ {direct.map((pod) => renderPodRow(pod, 'direct'))}
+
+
)}
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index d9115f8dac..3d46c17151 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -133,9 +133,9 @@ body.modern-ui.v2-canvas {
--v2-shadow-pending: none; /* the one chrome-card shadow: a PENDING decision or approval only */
/* Layout */
- --v2-rail-w: 76px;
- --v2-pods-w: 272px;
- --v2-inspector-w: 336px;
+ --v2-rail-w: 56px;
+ --v2-pods-w: 232px;
+ --v2-inspector-w: 300px;
/* Keep production's scoped V2 layer in step with design-system/tokens.css.
The typing cadence belongs to the minimal mark; pulse/spin retain their
@@ -9799,3 +9799,205 @@ body.modern-ui.v2-canvas {
@media (max-width: 640px) {
.v2-activity__reply { flex-basis: 100%; }
}
+
+/* ---------- Workspace sidebar (TASK-129 PR 1) ----------
+ The workspace artboard is a ground-colour grid, not three inset cards.
+ These rules intentionally follow the retired sidebar rules above while the
+ chat and inspector receive their own structural passes in PRs 2 and 3. */
+
+.v2-shell {
+ gap: 12px;
+ padding: 14px;
+ box-sizing: border-box;
+}
+
+.v2-shell .v2-pane {
+ height: 100%;
+ min-height: 0;
+}
+
+.v2-shell .v2-pane--main {
+ margin: 0;
+ height: 100%;
+}
+
+.v2-pods {
+ position: relative;
+ height: 100%;
+ padding: 6px 12px;
+ background: var(--v2-shell-bg);
+}
+
+.v2-pods__list {
+ display: flex;
+ min-height: 0;
+ flex: 1;
+ flex-direction: column;
+ gap: 18px;
+ overflow-y: auto;
+}
+
+.v2-pods__group {
+ min-width: 0;
+}
+
+.v2-pods__group-label {
+ margin: 0 0 6px;
+ padding: 0;
+ color: var(--v2-text-muted);
+ font-family: var(--v2-font-mono);
+ font-size: 11px;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+ line-height: 16px;
+ text-transform: lowercase;
+}
+
+.v2-pods__rows {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.v2-root button.v2-pods__row,
+.v2-root button.v2-pods__channel,
+.v2-root button.v2-pods__new-line,
+.v2-root button.v2-pods__connect-line {
+ width: 100%;
+ min-width: 0;
+ border: 0;
+ background: transparent;
+ color: var(--v2-text-primary);
+ font: 500 13px/18px var(--v2-font);
+ text-align: left;
+}
+
+.v2-root button.v2-pods__row {
+ display: flex;
+ min-height: 36px;
+ align-items: center;
+ gap: 7px;
+ padding: 9px 12px;
+ border-radius: 4px;
+}
+
+.v2-root button.v2-pods__row:hover,
+.v2-root button.v2-pods__channel:hover:not(:disabled) {
+ background: var(--v2-surface-hover);
+}
+
+.v2-root button.v2-pods__row--selected,
+.v2-root button.v2-pods__row--selected:hover {
+ background: var(--v2-accent);
+ color: #fff;
+ font-weight: 600;
+}
+
+.v2-pods__row-name {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.v2-pods__attention-count {
+ margin-left: auto;
+ color: #fff;
+ font-family: var(--v2-font-mono);
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 16px;
+}
+
+.v2-pods__direct-avatar.v2-avatar {
+ width: 20px;
+ height: 20px;
+ border-radius: 4px;
+ font-size: 8px;
+}
+
+.v2-root button.v2-pods__channel {
+ display: flex;
+ min-height: 32px;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 12px;
+ border-radius: 4px;
+ color: var(--v2-text-secondary);
+}
+
+.v2-root button.v2-pods__channel:disabled {
+ cursor: default;
+}
+
+.v2-pods__channel-dot {
+ width: 8px;
+ height: 8px;
+ flex: 0 0 8px;
+ border: 1px solid var(--v2-border-strong);
+ background: transparent;
+}
+
+.v2-pods__channel-dot--live {
+ border-color: var(--v2-accent);
+ background: var(--v2-accent);
+}
+
+.v2-root button.v2-pods__new-line,
+.v2-root button.v2-pods__connect-line {
+ min-height: 32px;
+ padding: 7px 12px;
+ color: var(--v2-accent-text);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.v2-root button.v2-pods__new-line { margin-top: 2px; }
+
+.v2-pods__create {
+ margin: 4px 0 0;
+ padding: 8px;
+ border: 1px solid var(--v2-border);
+ border-radius: 4px;
+ background: var(--v2-surface);
+}
+
+.v2-pods__create-input {
+ min-height: 32px;
+ font-size: 12px;
+}
+
+.v2-root button.v2-pods__create-cancel,
+.v2-root button.v2-pods__create-submit {
+ min-height: 30px;
+ padding: 6px 9px;
+}
+
+.v2-pods__empty {
+ padding: 28px 12px;
+ font-size: 12px;
+}
+
+@media (max-width: 760px) {
+ .v2-shell,
+ .v2-shell--no-inspector,
+ .v2-shell--feature {
+ gap: 0;
+ padding: 0;
+ }
+
+ .v2-shell .v2-pane {
+ height: 100vh;
+ }
+
+ .v2-pods-aside {
+ width: min(86vw, 320px);
+ }
+
+ .v2-pods__mobile-close {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ z-index: 1;
+ }
+}
From 33b16b18dbc54071255dea1678b9abf0ae31cb00 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sat, 5 Sep 2026 05:17:58 -0700
Subject: [PATCH 2/6] fix(v2): preserve sidebar row geometry
---
.../V2PodsSidebar.community.test.tsx | 2 ++
.../v2/__tests__/v2-layout-invariants.test.ts | 11 ++++++++++
frontend/src/v2/components/V2PodsSidebar.tsx | 21 ++++++++++++++++---
frontend/src/v2/v2.css | 6 +++---
4 files changed, 34 insertions(+), 6 deletions(-)
diff --git a/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx b/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx
index ce9c8bfa46..614b5b28b5 100644
--- a/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx
+++ b/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx
@@ -80,6 +80,7 @@ describe('V2PodsSidebar workspace groups', () => {
pod('agent-room', 'Build Agent', 'agent-room', [human('me'), agent('builder')]),
pod('human-dm', 'Design Partner', 'chat', [human('me'), human('designer')]),
pod('room-chat', 'Project Chat', 'chat', [human('me'), human('one'), human('two')]),
+ pod('future-type', 'Unreviewed Type', 'future-type', [human('me'), human('other')]),
]);
const rooms = screen.getByRole('heading', { name: 'rooms' }).closest('section');
@@ -90,6 +91,7 @@ describe('V2PodsSidebar workspace groups', () => {
expect(within(rooms).getByRole('button', { name: 'Project Chat' })).toBeInTheDocument();
expect(within(direct).getByRole('button', { name: 'Build Agent' })).toBeInTheDocument();
expect(within(direct).getByRole('button', { name: 'Design Partner' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Unreviewed Type' })).not.toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Telegram · Sharpen' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '+ Connect Slack' })).toBeInTheDocument();
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index d6ac24350f..d69aee6b50 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -132,6 +132,10 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(selected).toContain('background: var(--v2-accent)');
expect(selected).toContain('color: #fff');
expect(ruleBody(v2, '.v2-root button.v2-pods__row')).toContain('border-radius: 4px');
+ // The selection is the full 232px sidebar block; 12px is inside its row,
+ // not a list inset that would shrink the cobalt slab.
+ expect(lastRuleBody(v2, '.v2-pods')).toContain('padding: 6px 0');
+ expect(labels).toContain('padding: 0 12px');
expect(channelDot).toContain('width: 8px');
expect(liveChannelDot).toContain('background: var(--v2-accent)');
expect(directAvatar).toContain('width: 20px');
@@ -160,6 +164,13 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(podsSidebar).not.toContain('useV2Unread');
});
+ test('room types are an explicit reviewed mapping, not a complement of direct types', () => {
+ expect(podsSidebar).toContain('const ROOM_POD_TYPES');
+ expect(podsSidebar).toContain('export const isRoomPod');
+ expect(podsSidebar).toContain('pods.filter(isRoomPod)');
+ expect(podsSidebar).not.toContain('pods.filter((pod) => !isDirectPod(pod))');
+ });
+
test('channels read the existing connector binding endpoint and retain their room target', () => {
expect(podsSidebar).toContain("'/api/integrations/user/all'");
expect(podsSidebar).toContain('connector.podId');
diff --git a/frontend/src/v2/components/V2PodsSidebar.tsx b/frontend/src/v2/components/V2PodsSidebar.tsx
index e95f6638e6..37c1760aea 100644
--- a/frontend/src/v2/components/V2PodsSidebar.tsx
+++ b/frontend/src/v2/components/V2PodsSidebar.tsx
@@ -27,6 +27,17 @@ interface V2PodsSidebarProps {
}
const DM_POD_TYPES = new Set(['agent-room', 'agent-dm']);
+// A new stored type must be deliberately placed in the workspace grammar.
+// Do not turn this into "not direct": that would silently surface any future
+// type as a room before product has decided how it belongs in the sidebar.
+const ROOM_POD_TYPES = new Set([
+ 'chat',
+ 'study',
+ 'games',
+ 'agent-ensemble',
+ 'agent-admin',
+ 'team',
+]);
const connectorLabel = (type: string): string => ({
telegram: 'Telegram',
@@ -57,12 +68,16 @@ const isHumanPair = (pod: V2Pod): boolean => {
};
// The stored types stay unchanged. The workspace grammar puts agent DMs and
-// human two-person chat under direct; every other member pod remains a room.
-// Keeping this predicate named makes a new Pod type an explicit review point.
+// human two-person chat under direct. See ROOM_POD_TYPES for the explicitly
+// reviewed room types; unknown types do not silently join a list.
export const isDirectPod = (pod: V2Pod): boolean => (
DM_POD_TYPES.has(String(pod.type || '')) || isHumanPair(pod)
);
+export const isRoomPod = (pod: V2Pod): boolean => (
+ ROOM_POD_TYPES.has(String(pod.type || '')) && !isHumanPair(pod)
+);
+
const directMemberFor = (pod: V2Pod, currentUserId?: string): V2PodMember | undefined => (
(pod.members || []).find((member): member is V2PodMember => (
typeof member === 'object' && member._id !== currentUserId
@@ -116,7 +131,7 @@ const V2PodsSidebar: React.FC
= ({
}, [api]);
const { rooms, direct } = useMemo(() => {
- const roomPods = pods.filter((pod) => !isDirectPod(pod));
+ const roomPods = pods.filter(isRoomPod);
const directPods = pods.filter(isDirectPod);
return { rooms: sortPods(roomPods), direct: sortPods(directPods) };
}, [pods]);
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index 3d46c17151..d62898445e 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -9824,7 +9824,7 @@ body.modern-ui.v2-canvas {
.v2-pods {
position: relative;
height: 100%;
- padding: 6px 12px;
+ padding: 6px 0;
background: var(--v2-shell-bg);
}
@@ -9843,7 +9843,7 @@ body.modern-ui.v2-canvas {
.v2-pods__group-label {
margin: 0 0 6px;
- padding: 0;
+ padding: 0 12px;
color: var(--v2-text-muted);
font-family: var(--v2-font-mono);
font-size: 11px;
@@ -9955,7 +9955,7 @@ body.modern-ui.v2-canvas {
.v2-root button.v2-pods__new-line { margin-top: 2px; }
.v2-pods__create {
- margin: 4px 0 0;
+ margin: 4px 12px 0;
padding: 8px;
border: 1px solid var(--v2-border);
border-radius: 4px;
From 50d953fdc67b8b31ec5dda1fa97daaddd7ef9482 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sat, 5 Sep 2026 05:23:28 -0700
Subject: [PATCH 3/6] test(v2): cover sidebar pod type mapping
---
.../v2/__tests__/v2-layout-invariants.test.ts | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index d69aee6b50..af09341173 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -94,6 +94,7 @@ describe('v2 layout invariants (CSS rule presence)', () => {
const avatar = read('../components/V2Avatar.tsx');
const billingPanel = read('../components/V2BillingPanel.tsx');
const appsManagement = read('../../components/AppsManagement.tsx');
+ const podModel = read('../../../../backend/models/Pod.ts');
test('Your Team card name owns its line so the category chip cannot crush it', () => {
const rule = ruleBody(v2, '.v2-team-card__name');
@@ -171,6 +172,24 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(podsSidebar).not.toContain('pods.filter((pod) => !isDirectPod(pod))');
});
+ test('every stored pod type has exactly one reviewed sidebar group', () => {
+ const entriesFor = (source: string, setName: string): string[] => {
+ const match = source.match(new RegExp(`const ${setName} = new Set\\(\\[([\\s\\S]*?)\\]\\);`));
+ return Array.from(match?.[1].matchAll(/'([^']+)'/g) || [], ([, entry]) => entry);
+ };
+ const schemaTypes = Array.from(
+ podModel.match(/type:\s*\{[\s\S]*?enum:\s*\[([^\]]+)\],[\s\S]*?default:\s*'chat'/)?.[1].matchAll(/'([^']+)'/g) || [],
+ ([, entry]) => entry,
+ );
+ const sidebarTypes = [
+ ...entriesFor(podsSidebar, 'ROOM_POD_TYPES'),
+ ...entriesFor(podsSidebar, 'DM_POD_TYPES'),
+ ];
+
+ expect(new Set(sidebarTypes).size).toBe(sidebarTypes.length);
+ expect(sidebarTypes.sort()).toEqual(schemaTypes.sort());
+ });
+
test('channels read the existing connector binding endpoint and retain their room target', () => {
expect(podsSidebar).toContain("'/api/integrations/user/all'");
expect(podsSidebar).toContain('connector.podId');
From 82fa5ef888aa7d8a0bdd0c4dc115848e44abf757 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sat, 5 Sep 2026 07:09:13 -0700
Subject: [PATCH 4/6] fix(v2): size workspace rail to artboard
---
.../v2/__tests__/v2-layout-invariants.test.ts | 14 ++++++++++
frontend/src/v2/v2.css | 27 ++++++++++---------
2 files changed, 29 insertions(+), 12 deletions(-)
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index af09341173..2122d3689e 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -121,6 +121,20 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(lastRuleBody(v2, '.v2-shell')).toContain('padding: 14px');
});
+ test('the compact rail uses the artboard’s 32px square marks inside the 56px column', () => {
+ const rail = ruleBody(v2, '.v2-rail');
+ const brand = ruleBody(v2, '.v2-rail__brand-icon');
+ const item = ruleBody(v2, '.v2-root button.v2-rail__item');
+ const active = ruleBody(v2, '.v2-root button.v2-rail__item--active');
+ expect(rail).toContain('padding: 6px 10px');
+ expect(brand).toContain('width: 32px');
+ expect(brand).toContain('height: 32px');
+ expect(item).toContain('width: 32px');
+ expect(item).toContain('height: 32px');
+ expect(item).toContain('border-radius: var(--v2-radius)');
+ expect(active).toContain('background: var(--v2-ink)');
+ });
+
test('sidebar is the artboard’s three-list grammar, not the retired search/filter surface', () => {
const labels = lastRuleBody(v2, '.v2-pods__group-label');
const selected = selectorRuleBody(v2, '.v2-root button.v2-pods__row--selected');
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index d62898445e..290903a011 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -575,7 +575,7 @@ body.modern-ui.v2-canvas {
.v2-rail {
height: 100%;
- padding: 18px 10px;
+ padding: 6px 10px;
display: flex;
flex-direction: column;
gap: 8px;
@@ -640,7 +640,7 @@ body.modern-ui.v2-canvas {
font-weight: 700;
font-size: 17px;
padding: 0;
- margin-bottom: 22px;
+ margin-bottom: 16px;
color: var(--v2-text-primary);
letter-spacing: -0.02em;
height: 32px;
@@ -651,8 +651,8 @@ body.modern-ui.v2-canvas {
}
.v2-rail__brand-icon {
- width: 28px;
- height: 28px;
+ width: 32px;
+ height: 32px;
border-radius: var(--v2-radius-sm);
background: var(--v2-accent);
color: #fff;
@@ -669,22 +669,25 @@ body.modern-ui.v2-canvas {
gap: 4px;
min-height: 0;
overflow-y: auto;
- padding-right: 2px;
+ padding-right: 0;
}
-.v2-rail__item {
+.v2-root button.v2-rail__item {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 0;
- min-height: 44px;
+ width: 32px;
+ min-height: 32px;
+ height: 32px;
+ align-self: center;
padding: 0;
border-radius: var(--v2-radius);
font-size: 14px;
font-weight: 600;
color: #374151;
- width: 100%;
+ background: var(--v2-surface-hover);
text-align: left;
transition: background 80ms ease;
}
@@ -707,14 +710,14 @@ body.modern-ui.v2-canvas {
white-space: nowrap;
}
-.v2-rail__item:hover {
+.v2-root button.v2-rail__item:hover {
background: var(--v2-surface-hover);
color: var(--v2-text-primary);
}
-.v2-rail__item--active {
- background: var(--v2-accent-soft);
- color: var(--v2-accent-text);
+.v2-root button.v2-rail__item--active {
+ background: var(--v2-ink);
+ color: var(--v2-on-ink);
font-weight: 600;
}
From 4fbe997f26704eb74eac05a4b93b0d835a07c34f Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sat, 5 Sep 2026 18:34:58 -0700
Subject: [PATCH 5/6] feat(v2): group workspace pods in sidebar
---
frontend/src/i18n/locales/en.json | 10 ++-
frontend/src/i18n/locales/zh-CN.json | 10 ++-
.../V2PodsSidebar.community.test.tsx | 64 ++++++++++++++++---
.../v2/__tests__/v2-layout-invariants.test.ts | 22 +++++--
frontend/src/v2/components/V2PodsSidebar.tsx | 62 ++++++++++++++++--
frontend/src/v2/hooks/useV2Pods.ts | 3 +
frontend/src/v2/v2.css | 24 ++++++-
7 files changed, 173 insertions(+), 22 deletions(-)
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index dad1e2bf2a..bc820c3cef 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1499,7 +1499,15 @@
"button": "Join HQ"
},
"workspace": {
- "rooms": "rooms",
+ "pods": "pods",
+ "pinned": "pinned",
+ "community": "community",
+ "team": "team",
+ "chat": "chat",
+ "study": "study",
+ "games": "games",
+ "ensemble": "ensemble",
+ "admin": "admin",
"channels": "channels",
"direct": "direct",
"new": "new",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index de222b0319..766aa6f081 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -1493,7 +1493,15 @@
"button": "加入 HQ"
},
"workspace": {
- "rooms": "房间",
+ "pods": "Pods",
+ "pinned": "置顶",
+ "community": "社区",
+ "team": "团队",
+ "chat": "聊天",
+ "study": "学习",
+ "games": "游戏",
+ "ensemble": "协作组",
+ "admin": "管理",
"channels": "频道",
"direct": "私信",
"new": "新建",
diff --git a/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx b/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx
index 614b5b28b5..30545bcdfa 100644
--- a/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx
+++ b/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx
@@ -5,10 +5,11 @@ import {
} from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import i18n, { i18nReady } from '../../i18n';
-import V2PodsSidebar from '../components/V2PodsSidebar';
+import V2PodsSidebar, { groupWorkspacePods } from '../components/V2PodsSidebar';
const mockApiGet = jest.fn();
const mockCreatePod = jest.fn();
+const mockPinned = new Set();
jest.mock('../hooks/useV2Pods', () => ({
useV2Pods: () => ({
@@ -20,18 +21,23 @@ jest.mock('../hooks/useV2Api', () => ({
useV2Api: () => ({ get: mockApiGet, post: jest.fn(), patch: jest.fn(), del: jest.fn() }),
}));
+jest.mock('../hooks/useV2Pinned', () => ({
+ useV2Pinned: () => ({ pinned: mockPinned, toggle: jest.fn(), isPinned: (id: string) => mockPinned.has(id) }),
+}));
+
jest.mock('../../context/AuthContext', () => ({
useAuth: () => ({ currentUser: { _id: 'me', username: 'me' } }),
}));
const CurrentPath = () => {useLocation().pathname}
;
-const pod = (id, name, type, members) => ({
+const pod = (id, name, type, members, extra = {}) => ({
_id: id,
name,
type,
members,
updatedAt: '2026-09-05T00:00:00.000Z',
+ ...extra,
});
const human = (id) => ({ _id: id, username: id, isBot: false });
@@ -58,6 +64,7 @@ describe('V2PodsSidebar workspace groups', () => {
beforeEach(async () => {
jest.clearAllMocks();
+ mockPinned.clear();
mockApiGet.mockImplementation((url) => {
if (url === '/api/integrations/user/all') {
return Promise.resolve([{
@@ -72,7 +79,7 @@ describe('V2PodsSidebar workspace groups', () => {
await act(async () => { await i18n.changeLanguage('en'); });
});
- test('renders existing member pods as rooms, connectors as channels, and DMs as direct', async () => {
+ test('renders existing member pods under their reviewed pod labels, connectors as channels, and DMs as direct', async () => {
renderSidebar([
pod('sharpen', 'Sharpen', 'team', [human('me'), human('other')]),
pod('admin', 'Agent Admin', 'agent-admin', [human('me'), agent('ops')]),
@@ -83,12 +90,12 @@ describe('V2PodsSidebar workspace groups', () => {
pod('future-type', 'Unreviewed Type', 'future-type', [human('me'), human('other')]),
]);
- const rooms = screen.getByRole('heading', { name: 'rooms' }).closest('section');
+ const pods = screen.getByRole('heading', { name: 'pods' }).closest('section');
const direct = screen.getByRole('heading', { name: 'direct' }).closest('section');
- expect(within(rooms).getByRole('button', { name: 'Sharpen' })).toBeInTheDocument();
- expect(within(rooms).getByRole('button', { name: 'Agent Admin' })).toBeInTheDocument();
- expect(within(rooms).getByRole('button', { name: 'Study Group' })).toBeInTheDocument();
- expect(within(rooms).getByRole('button', { name: 'Project Chat' })).toBeInTheDocument();
+ expect(within(pods).getByRole('button', { name: 'Sharpen' })).toBeInTheDocument();
+ expect(within(pods).getByRole('button', { name: 'Agent Admin' })).toBeInTheDocument();
+ expect(within(pods).getByRole('button', { name: 'Study Group' })).toBeInTheDocument();
+ expect(within(pods).getByRole('button', { name: 'Project Chat' })).toBeInTheDocument();
expect(within(direct).getByRole('button', { name: 'Build Agent' })).toBeInTheDocument();
expect(within(direct).getByRole('button', { name: 'Design Partner' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Unreviewed Type' })).not.toBeInTheDocument();
@@ -98,6 +105,47 @@ describe('V2PodsSidebar workspace groups', () => {
expect(screen.getByRole('button', { name: 'Telegram · Sharpen' }).querySelector('.v2-pods__channel-dot--live')).toBeTruthy();
});
+ test('groups each workspace pod exactly once with pinned and community ahead of its type', async () => {
+ mockPinned.add('pinned-team');
+ renderSidebar([
+ pod('pinned-team', 'Pinned team', 'team', [human('me'), human('one')]),
+ pod('community-team', 'Commonly HQ', 'team', [human('me'), human('two')], { communityListed: true }),
+ pod('team', 'Team', 'team', [human('me'), human('three')]),
+ pod('chat', 'Chat', 'chat', [human('me'), human('four'), human('five')]),
+ pod('study', 'Study', 'study', [human('me'), human('six')]),
+ pod('games', 'Games', 'games', [human('me'), human('seven')]),
+ pod('ensemble', 'Ensemble', 'agent-ensemble', [human('me'), agent('eight')]),
+ pod('admin', 'Admin', 'agent-admin', [human('me'), agent('nine')]),
+ ], null);
+
+ expect(screen.getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent)).toEqual([
+ 'pinned', 'community', 'team', 'chat', 'study', 'games', 'ensemble', 'admin',
+ ]);
+ expect(within(screen.getByRole('heading', { name: 'pinned' }).closest('section')).getByRole('button', { name: 'Pinned team' })).toBeInTheDocument();
+ expect(within(screen.getByRole('heading', { name: 'community' }).closest('section')).getByRole('button', { name: 'Commonly HQ' })).toBeInTheDocument();
+
+ ['Pinned team', 'Commonly HQ', 'Team', 'Chat', 'Study', 'Games', 'Ensemble', 'Admin'].forEach((name) => {
+ expect(screen.getAllByRole('button', { name })).toHaveLength(1);
+ });
+ });
+
+ test('maps every non-DM stored type to one category label', () => {
+ const workspacePods = [
+ pod('team', 'Team', 'team', []),
+ pod('chat', 'Chat', 'chat', [human('me'), human('one'), human('two')]),
+ pod('study', 'Study', 'study', []),
+ pod('games', 'Games', 'games', []),
+ pod('ensemble', 'Ensemble', 'agent-ensemble', []),
+ pod('admin', 'Admin', 'agent-admin', []),
+ ];
+
+ const groups = groupWorkspacePods(workspacePods, new Set());
+
+ expect(groups.map((group) => group.label)).toEqual(['team', 'chat', 'study', 'games', 'ensemble', 'admin']);
+ expect(groups.flatMap((group) => group.pods.map((item) => item._id)).sort())
+ .toEqual(workspacePods.map((item) => item._id).sort());
+ });
+
test('uses the decision queue count only on the selected room and retains no legacy controls', async () => {
renderSidebar([pod('sharpen', 'Sharpen', 'team', [human('me'), human('other')])]);
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index 2122d3689e..2a12832749 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -137,6 +137,7 @@ describe('v2 layout invariants (CSS rule presence)', () => {
test('sidebar is the artboard’s three-list grammar, not the retired search/filter surface', () => {
const labels = lastRuleBody(v2, '.v2-pods__group-label');
+ const sublabels = ruleBody(v2, '.v2-pods__subgroup-label');
const selected = selectorRuleBody(v2, '.v2-root button.v2-pods__row--selected');
const channelDot = ruleBody(v2, '.v2-pods__channel-dot');
const liveChannelDot = ruleBody(v2, '.v2-pods__channel-dot--live');
@@ -144,6 +145,10 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(labels).toContain('font-family: var(--v2-font-mono)');
expect(labels).toContain('font-size: 11px');
+ expect(sublabels).toContain('padding: 0 24px');
+ expect(sublabels).toContain('margin: 0 0 6px');
+ expect(sublabels).toContain('color: var(--v2-text-placeholder)');
+ expect(sublabels).toContain('font-family: var(--v2-font-mono)');
expect(selected).toContain('background: var(--v2-accent)');
expect(selected).toContain('color: #fff');
expect(ruleBody(v2, '.v2-root button.v2-pods__row')).toContain('border-radius: 4px');
@@ -151,11 +156,12 @@ describe('v2 layout invariants (CSS rule presence)', () => {
// not a list inset that would shrink the cobalt slab.
expect(lastRuleBody(v2, '.v2-pods')).toContain('padding: 6px 0');
expect(labels).toContain('padding: 0 12px');
+ expect(lastRuleBody(v2, '.v2-pods__rows')).toContain('gap: 4px');
expect(channelDot).toContain('width: 8px');
expect(liveChannelDot).toContain('background: var(--v2-accent)');
expect(directAvatar).toContain('width: 20px');
expect(directAvatar).toContain('height: 20px');
- expect(podsSidebar).toContain("'podsSidebar.workspace.rooms'");
+ expect(podsSidebar).toContain("'podsSidebar.workspace.pods'");
expect(podsSidebar).toContain("'podsSidebar.workspace.channels'");
expect(podsSidebar).toContain("'podsSidebar.workspace.direct'");
expect(podsSidebar).not.toContain('v2-pods__search');
@@ -181,6 +187,10 @@ describe('v2 layout invariants (CSS rule presence)', () => {
test('room types are an explicit reviewed mapping, not a complement of direct types', () => {
expect(podsSidebar).toContain('const ROOM_POD_TYPES');
+ expect(podsSidebar).toContain('const ROOM_POD_TYPE_LABELS');
+ expect(podsSidebar).toContain('communityListed === true');
+ expect(podsSidebar).toContain("? 'pinned'");
+ expect(podsSidebar).toContain("? 'community'");
expect(podsSidebar).toContain('export const isRoomPod');
expect(podsSidebar).toContain('pods.filter(isRoomPod)');
expect(podsSidebar).not.toContain('pods.filter((pod) => !isDirectPod(pod))');
@@ -195,13 +205,15 @@ describe('v2 layout invariants (CSS rule presence)', () => {
podModel.match(/type:\s*\{[\s\S]*?enum:\s*\[([^\]]+)\],[\s\S]*?default:\s*'chat'/)?.[1].matchAll(/'([^']+)'/g) || [],
([, entry]) => entry,
);
- const sidebarTypes = [
- ...entriesFor(podsSidebar, 'ROOM_POD_TYPES'),
- ...entriesFor(podsSidebar, 'DM_POD_TYPES'),
- ];
+ const roomTypes = entriesFor(podsSidebar, 'ROOM_POD_TYPES');
+ const directTypes = entriesFor(podsSidebar, 'DM_POD_TYPES');
+ const typeLabelBody = podsSidebar.match(/const ROOM_POD_TYPE_LABELS:[\s\S]*?= \{([\s\S]*?)\};/)?.[1] || '';
+ const mappedRoomTypes = Array.from(typeLabelBody.matchAll(/(?:'([^']+)'|(\w+)):\s*'[^']+'/g), ([, quoted, bare]) => quoted || bare);
+ const sidebarTypes = [...roomTypes, ...directTypes];
expect(new Set(sidebarTypes).size).toBe(sidebarTypes.length);
expect(sidebarTypes.sort()).toEqual(schemaTypes.sort());
+ expect(mappedRoomTypes.sort()).toEqual(roomTypes.sort());
});
test('channels read the existing connector binding endpoint and retain their room target', () => {
diff --git a/frontend/src/v2/components/V2PodsSidebar.tsx b/frontend/src/v2/components/V2PodsSidebar.tsx
index 37c1760aea..f73ee28e66 100644
--- a/frontend/src/v2/components/V2PodsSidebar.tsx
+++ b/frontend/src/v2/components/V2PodsSidebar.tsx
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
import V2Avatar from './V2Avatar';
import { UseV2PodsResult, V2Pod, V2PodMember, useV2Pods } from '../hooks/useV2Pods';
import { useV2Api } from '../hooks/useV2Api';
+import { useV2Pinned } from '../hooks/useV2Pinned';
import { useAuth } from '../../context/AuthContext';
interface Connector {
@@ -39,6 +40,24 @@ const ROOM_POD_TYPES = new Set([
'team',
]);
+type PodListGroup = 'pinned' | 'community' | 'team' | 'chat' | 'study' | 'games' | 'ensemble' | 'admin';
+
+const POD_LIST_GROUP_ORDER: PodListGroup[] = [
+ 'pinned', 'community', 'team', 'chat', 'study', 'games', 'ensemble', 'admin',
+];
+
+// Every non-DM Pod.type has a deliberate, human-readable sidebar label. The
+// community label is not a type: it is an admin-curated placement that wins
+// over kind but never over a person's persisted pin.
+export const ROOM_POD_TYPE_LABELS: Record> = {
+ team: 'team',
+ chat: 'chat',
+ study: 'study',
+ games: 'games',
+ 'agent-ensemble': 'ensemble',
+ 'agent-admin': 'admin',
+};
+
const connectorLabel = (type: string): string => ({
telegram: 'Telegram',
discord: 'Discord',
@@ -78,6 +97,27 @@ export const isRoomPod = (pod: V2Pod): boolean => (
ROOM_POD_TYPES.has(String(pod.type || '')) && !isHumanPair(pod)
);
+export const groupWorkspacePods = (
+ pods: V2Pod[],
+ pinnedPodIds: Set,
+): Array<{ label: PodListGroup; pods: V2Pod[] }> => {
+ const groups = new Map();
+ POD_LIST_GROUP_ORDER.forEach((label) => groups.set(label, []));
+
+ pods.forEach((pod) => {
+ const label = pinnedPodIds.has(pod._id)
+ ? 'pinned'
+ : pod.communityListed === true
+ ? 'community'
+ : ROOM_POD_TYPE_LABELS[String(pod.type || '')];
+ if (label) groups.get(label)?.push(pod);
+ });
+
+ return POD_LIST_GROUP_ORDER
+ .map((label) => ({ label, pods: sortPods(groups.get(label) || []) }))
+ .filter((group) => group.pods.length > 0);
+};
+
const directMemberFor = (pod: V2Pod, currentUserId?: string): V2PodMember | undefined => (
(pod.members || []).find((member): member is V2PodMember => (
typeof member === 'object' && member._id !== currentUserId
@@ -91,6 +131,7 @@ const V2PodsSidebar: React.FC = ({
const navigate = useNavigate();
const api = useV2Api();
const { currentUser } = useAuth();
+ const { pinned } = useV2Pinned();
const ownPodsState = useV2Pods();
const {
pods, loading, error, createPod,
@@ -130,11 +171,11 @@ const V2PodsSidebar: React.FC = ({
return () => { active = false; };
}, [api]);
- const { rooms, direct } = useMemo(() => {
+ const { podGroups, direct } = useMemo(() => {
const roomPods = pods.filter(isRoomPod);
const directPods = pods.filter(isDirectPod);
- return { rooms: sortPods(roomPods), direct: sortPods(directPods) };
- }, [pods]);
+ return { podGroups: groupWorkspacePods(roomPods, pinned), direct: sortPods(directPods) };
+ }, [pods, pinned]);
const attentionCountByPod = useMemo(() => attentionItems.reduce>((counts, item) => {
if (!item.podId) return counts;
@@ -237,9 +278,18 @@ const V2PodsSidebar: React.FC = ({
{!loading && error && {error}
}
{!loading && !error && (
-
- {t('podsSidebar.workspace.rooms')}
- {rooms.map((pod) => renderPodRow(pod, 'room'))}
+
+ {t('podsSidebar.workspace.pods')}
+
+ {podGroups.map((group) => (
+
+
+ {t(`podsSidebar.workspace.${group.label}`)}
+
+ {group.pods.map((pod) => renderPodRow(pod, 'room'))}
+
+ ))}
+