diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.session-pinning.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.session-pinning.test.tsx index f29f1c88e20..df11ebd0656 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.session-pinning.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.session-pinning.test.tsx @@ -918,3 +918,672 @@ describe('WebShellSidebar session pinning (issue #9465)', () => { expect(pinnedListTitles()).toEqual(['Only pinned']); }); }); + +describe('WebShellSidebar pinned group members (issue #10391)', () => { + const defaultGroupsCatalog = { + groups: [], + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }; + + it('keeps pinned sessions inside their group section instead of rendering the group empty', async () => { + workspaceActions.listSessionGroups.mockResolvedValue({ + groups: [ + { + id: 'design-group', + name: 'Design', + color: 'blue', + order: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }); + const member = makeSession('pinned-member', { + displayName: 'Pinned member', + groupId: 'design-group', + isPinned: true, + pinnedAt: '2026-01-02T00:00:00.000Z', + }); + active.sessions = [ + member, + makeSession('plain', { displayName: 'Plain session' }), + ]; + active.data = active.sessions; + pinned.sessions = [member]; + pinned.data = pinned.sessions; + + renderSidebar(); + await flushSidebar(); + + const group = container.querySelector( + 'section[aria-label="Design"]', + ); + expect(group).not.toBeNull(); + // Reported symptom: every member pinned -> group rendered `· 0`. + expect(group?.textContent).toContain('· 1'); + expect(group?.textContent).toContain('Pinned member'); + + // The pinned row also stays in the dedicated Pinned section. + expect(pinnedListTitles()).toContain('Pinned member'); + + // ...and it does not fall into Ungrouped. + const ungrouped = container.querySelector( + 'section[aria-label="Ungrouped"]', + ); + expect(ungrouped?.textContent ?? '').not.toContain('Pinned member'); + expect(ungrouped?.textContent).toContain('Plain session'); + + workspaceActions.listSessionGroups.mockResolvedValue(defaultGroupsCatalog); + }); + + function mockDesignGroupCatalog(): void { + workspaceActions.listSessionGroups.mockResolvedValue({ + groups: [ + { + id: 'design-group', + name: 'Design', + color: 'blue', + order: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }); + } + + it('keeps the group section visible when search only matches pinned members', async () => { + mockDesignGroupCatalog(); + const member = makeSession('pinned-member', { + displayName: 'Pinned member', + groupId: 'design-group', + isPinned: true, + pinnedAt: '2026-01-02T00:00:00.000Z', + }); + active.sessions = [ + member, + makeSession('plain', { displayName: 'Plain session' }), + ]; + active.data = active.sessions; + pinned.sessions = [member]; + pinned.data = pinned.sessions; + + renderSidebar(); + await flushSidebar(); + + const searchButton = container.querySelector( + 'button[aria-label="Search sessions"]', + ); + expect(searchButton).not.toBeNull(); + act(() => { + searchButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushSidebar(); + const searchInput = container.querySelector( + 'input[aria-label="Search sessions"]', + ); + expect(searchInput).not.toBeNull(); + act(() => { + const setValue = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value', + )!.set!; + setValue.call(searchInput, 'Pinned member'); + searchInput!.dispatchEvent(new Event('input', { bubbles: true })); + }); + await flushSidebar(); + + // The only match is pinned, so the pinned-filtered flat list is empty; + // the body must still render the group section holding that member + // instead of the empty-state notice. + const group = container.querySelector( + 'section[aria-label="Design"]', + ); + expect(group).not.toBeNull(); + expect(group?.textContent).toContain('Pinned member'); + expect(group?.textContent).toContain('\u00b7 1'); + expect(container.textContent ?? '').not.toContain('No sessions'); + + workspaceActions.listSessionGroups.mockResolvedValue(defaultGroupsCatalog); + }); + + it('mounts a single rename form for a pinned member rendered in two rows', async () => { + mockDesignGroupCatalog(); + const member = makeSession('pinned-member', { + displayName: 'Pinned member', + groupId: 'design-group', + isPinned: true, + pinnedAt: '2026-01-02T00:00:00.000Z', + }); + active.sessions = [member]; + active.data = active.sessions; + pinned.sessions = [member]; + pinned.data = pinned.sessions; + connection.sessionId = 'pinned-member'; + + renderSidebar(); + await flushSidebar(); + + // The member renders twice: in the Pinned section and in its group + // section. Starting a rename from the group row must mount one form. + const group = container.querySelector( + 'section[aria-label="Design"]', + ); + expect(group).not.toBeNull(); + const groupRow = Array.from( + group!.querySelectorAll('[role="button"]'), + ).find((element) => element.textContent?.includes('Pinned member')); + expect(groupRow).not.toBeUndefined(); + act(() => { + groupRow!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + }); + await flushSidebar(); + + expect( + container.querySelectorAll('input[aria-label="Rename: Pinned member"]') + .length, + ).toBe(1); + + workspaceActions.listSessionGroups.mockResolvedValue(defaultGroupsCatalog); + }); + + it('renders each member once when a group mixes pinned and unpinned sessions', async () => { + mockDesignGroupCatalog(); + const pinnedMember = makeSession('pinned-member', { + displayName: 'Pinned member', + groupId: 'design-group', + isPinned: true, + pinnedAt: '2026-01-02T00:00:00.000Z', + }); + const activeMember = makeSession('active-member', { + displayName: 'Active member', + groupId: 'design-group', + }); + active.sessions = [pinnedMember, activeMember]; + active.data = active.sessions; + pinned.sessions = [pinnedMember]; + pinned.data = pinned.sessions; + + renderSidebar(); + await flushSidebar(); + + const group = container.querySelector( + 'section[aria-label="Design"]', + ); + expect(group).not.toBeNull(); + expect(group?.textContent).toContain('\u00b7 2'); + const titles = Array.from( + group!.querySelectorAll('[data-web-shell-session-title]'), + ).map((node) => node.textContent ?? ''); + expect(titles.filter((title) => title === 'Pinned member')).toHaveLength(1); + expect(titles.filter((title) => title === 'Active member')).toHaveLength(1); + // The pinned member still renders in the Pinned section too. + expect(pinnedListTitles()).toContain('Pinned member'); + + workspaceActions.listSessionGroups.mockResolvedValue(defaultGroupsCatalog); + }); + + it('keeps a pinned color-tagged session in its color section', async () => { + mockDesignGroupCatalog(); + const colorMember = makeSession('pinned-color', { + displayName: 'Pinned color', + color: 'red', + groupId: null, + isPinned: true, + pinnedAt: '2026-01-02T00:00:00.000Z', + }); + active.sessions = [ + colorMember, + makeSession('plain', { displayName: 'Plain session' }), + ]; + active.data = active.sessions; + pinned.sessions = [colorMember]; + pinned.data = pinned.sessions; + + renderSidebar(); + await flushSidebar(); + + // Color sections render only when non-empty: a pinned row that lost the + // color classification would make an all-pinned color section disappear + // entirely — the #10391 membership-loss symptom one path over. + const colorSection = container.querySelector( + 'section[aria-label="Red"]', + ); + expect(colorSection).not.toBeNull(); + expect(colorSection?.textContent).toContain('\u00b7 1'); + expect(colorSection?.textContent).toContain('Pinned color'); + // The row stays in the Pinned section and never spills into Ungrouped. + expect(pinnedListTitles()).toContain('Pinned color'); + const ungrouped = container.querySelector( + 'section[aria-label="Ungrouped"]', + ); + expect(ungrouped?.textContent ?? '').not.toContain('Pinned color'); + expect(ungrouped?.textContent).toContain('Plain session'); + + workspaceActions.listSessionGroups.mockResolvedValue(defaultGroupsCatalog); + }); + + it('keeps a pinned member visible in its group preview ahead of unpinned members', async () => { + mockDesignGroupCatalog(); + const pinnedMember = makeSession('pinned-member', { + displayName: 'Pinned member', + groupId: 'design-group', + isPinned: true, + pinnedAt: '2026-01-02T00:00:00.000Z', + }); + // One pinned member plus five unpinned: the bucket exceeds the preview + // limit (5), so the pinned row must keep its catalog position (pinned + // sorts first) instead of being appended after every unpinned member, + // which would hide it behind "Show all". + const unpinnedMembers = [1, 2, 3, 4, 5].map((index) => + makeSession(`unpinned-${index}`, { + displayName: `Unpinned ${index}`, + groupId: 'design-group', + }), + ); + active.sessions = [pinnedMember, ...unpinnedMembers]; + active.data = active.sessions; + pinned.sessions = [pinnedMember]; + pinned.data = pinned.sessions; + + renderSidebar(); + await flushSidebar(); + + const group = container.querySelector( + 'section[aria-label="Design"]', + ); + expect(group).not.toBeNull(); + expect(group?.textContent).toContain('\u00b7 6'); + const rows = Array.from( + group!.querySelectorAll('[data-web-shell-session-title]'), + ).map((node) => node.textContent ?? ''); + expect(rows).toHaveLength(5); + expect(rows[0]).toBe('Pinned member'); + expect(group?.textContent).toContain('Show all'); + + workspaceActions.listSessionGroups.mockResolvedValue(defaultGroupsCatalog); + }); + + it('drops a pinned member from its group when search only matches unpinned members', async () => { + mockDesignGroupCatalog(); + const pinnedMember = makeSession('pinned-member', { + displayName: 'Pinned member', + groupId: 'design-group', + isPinned: true, + pinnedAt: '2026-01-02T00:00:00.000Z', + }); + active.sessions = [ + pinnedMember, + makeSession('active-member', { + displayName: 'Active member', + groupId: 'design-group', + }), + ]; + active.data = active.sessions; + pinned.sessions = [pinnedMember]; + pinned.data = pinned.sessions; + + renderSidebar(); + await flushSidebar(); + + const searchButton = container.querySelector( + 'button[aria-label="Search sessions"]', + ); + expect(searchButton).not.toBeNull(); + act(() => { + searchButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushSidebar(); + const searchInput = container.querySelector( + 'input[aria-label="Search sessions"]', + ); + expect(searchInput).not.toBeNull(); + act(() => { + const setValue = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value', + )!.set!; + setValue.call(searchInput, 'Active member'); + searchInput!.dispatchEvent(new Event('input', { bubbles: true })); + }); + await flushSidebar(); + + // The query matches only the unpinned member: the grouped bucket must + // read the search-filtered list, so the pinned member leaves the group + // instead of lingering as a stale row with a stale count. + const group = container.querySelector( + 'section[aria-label="Design"]', + ); + expect(group).not.toBeNull(); + expect(group?.textContent).toContain('\u00b7 1'); + expect(group?.textContent).toContain('Active member'); + expect(group?.textContent ?? '').not.toContain('Pinned member'); + + workspaceActions.listSessionGroups.mockResolvedValue(defaultGroupsCatalog); + }); + + it('mounts a single rename form from the group row when the Pinned section is collapsed', async () => { + mockDesignGroupCatalog(); + const member = makeSession('pinned-member', { + displayName: 'Pinned member', + groupId: 'design-group', + isPinned: true, + pinnedAt: '2026-01-02T00:00:00.000Z', + }); + active.sessions = [member]; + active.data = active.sessions; + pinned.sessions = [member]; + pinned.data = pinned.sessions; + connection.sessionId = 'pinned-member'; + + renderSidebar(); + await flushSidebar(); + + // Collapse the Pinned section: its rows unmount, so the duplicate group + // row becomes the rename host. + const pinnedToggle = Array.from( + container.querySelectorAll('button[aria-expanded]'), + ).find((button) => button.textContent?.includes('Pinned')); + expect(pinnedToggle).not.toBeUndefined(); + act(() => { + pinnedToggle!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushSidebar(); + expect( + Array.from( + container.querySelectorAll('button[aria-expanded]'), + ) + .find((button) => button.textContent?.includes('Pinned')) + ?.getAttribute('aria-expanded'), + ).toBe('false'); + + const group = container.querySelector( + 'section[aria-label="Design"]', + ); + expect(group).not.toBeNull(); + const groupRow = Array.from( + group!.querySelectorAll('[role="button"]'), + ).find((element) => element.textContent?.includes('Pinned member')); + expect(groupRow).not.toBeUndefined(); + act(() => { + groupRow!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + }); + await flushSidebar(); + + expect( + container.querySelectorAll('input[aria-label="Rename: Pinned member"]') + .length, + ).toBe(1); + + workspaceActions.listSessionGroups.mockResolvedValue(defaultGroupsCatalog); + }); + + it('keeps a group-less pinned session out of the Ungrouped section', async () => { + mockDesignGroupCatalog(); + const pinnedFree = makeSession('pinned-free', { + displayName: 'Pinned free', + isPinned: true, + pinnedAt: '2026-01-02T00:00:00.000Z', + // groupId and color stay null: the row has no color/group bucket. + }); + active.sessions = [ + pinnedFree, + makeSession('plain', { displayName: 'Plain session' }), + ]; + active.data = active.sessions; + pinned.sessions = [pinnedFree]; + pinned.data = pinned.sessions; + + renderSidebar(); + await flushSidebar(); + + // The pinned row stays in the dedicated Pinned section... + expect(pinnedListTitles()).toContain('Pinned free'); + + // ...and never spills into Ungrouped, which keeps only the plain row. + const ungrouped = container.querySelector( + 'section[aria-label="Ungrouped"]', + ); + expect(ungrouped).not.toBeNull(); + expect(ungrouped?.textContent).toContain('Plain session'); + expect(ungrouped?.textContent).toContain('\u00b7 1'); + expect(ungrouped?.textContent ?? '').not.toContain('Pinned free'); + + workspaceActions.listSessionGroups.mockResolvedValue(defaultGroupsCatalog); + }); + + it('mounts a single rename form for a secondary-workspace pinned member rendered in two rows', async () => { + // The sidebar-level Pinned section lifts pinned rows out of every + // workspace; a secondary workspace's own group section keeps the member + // too (#10391), so the member renders twice and only one row may host + // the rename form. + connection.capabilities = { + ...organizationCapabilities, + // Qualified-rest rename for non-current secondary sessions. + features: [ + ...organizationCapabilities.features, + 'workspace_session_metadata', + 'workspace_qualified_rest_core', + ], + }; + workspace.capabilities = { + ...organizationCapabilities, + workspaces: [ + { id: 'primary', cwd: '/tmp/project', primary: true, trusted: true }, + { + id: 'secondary', + cwd: '/tmp/other', + primary: false, + trusted: true, + }, + ], + }; + const designGroupCatalog = { + groups: [ + { + id: 'design-group', + name: 'Design', + color: 'blue', + order: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }; + workspaceActions.listSessionGroups.mockResolvedValue({ + groups: [], + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }); + const member = makeSession('secondary-member', { + displayName: 'Secondary member', + workspaceCwd: '/tmp/other', + groupId: 'design-group', + isPinned: true, + pinnedAt: '2026-01-02T00:00:00.000Z', + }); + // The sidebar's secondary pinned page carries the grouped member. + useSessionCatalogQueries.mockImplementation( + ( + _client: unknown, + queries: Array<{ + workspaceCwd: string; + options?: Record; + }>, + ) => + queries.map((query) => { + if ( + query.workspaceCwd === '/tmp/other' && + query.options?.group === 'pinned' + ) { + return { + page: { sessions: [member] }, + loading: false, + stale: false, + }; + } + return {}; + }), + ); + // The secondary workspace section loads its own session and group pages. + workspace.client.workspaceByCwd.mockImplementation((cwd: string) => { + if (cwd === '/tmp/other') { + return { + listWorkspaceSessions: vi.fn().mockResolvedValue([ + member, + makeSession('secondary-plain', { + displayName: 'Secondary plain', + workspaceCwd: '/tmp/other', + }), + ]), + listSessionGroups: vi.fn().mockResolvedValue(designGroupCatalog), + }; + } + return { + listWorkspaceSessions: vi.fn().mockResolvedValue([]), + listSessionGroups: vi.fn().mockResolvedValue({ + groups: [], + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }), + }; + }); + pinned.sessions = []; + pinned.data = pinned.sessions; + active.sessions = []; + active.data = active.sessions; + connection.sessionId = 'secondary-member'; + + renderSidebar(); + await flushSidebar(); + await flushSidebar(); + + // The member renders twice: in the Pinned section and in the secondary + // workspace's group section. + expect(sessionTitleCount('Secondary member')).toBe(2); + const group = Array.from( + container.querySelectorAll('section[aria-label="Design"]'), + ).find((section) => section.textContent?.includes('Secondary member')); + expect(group).not.toBeUndefined(); + const groupRow = Array.from( + group!.querySelectorAll('[role="button"]'), + ).find((element) => element.textContent?.includes('Secondary member')); + expect(groupRow).not.toBeUndefined(); + act(() => { + groupRow!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + }); + await flushSidebar(); + + expect( + container.querySelectorAll('input[aria-label="Rename: Secondary member"]') + .length, + ).toBe(1); + }); + + it('mounts the rename form on the workspace row while the pinned page is absent', async () => { + // Cold-load race: the secondary workspace's own session page settles + // before the sidebar's secondary pinned catalog page (or the pinned + // query errors and retries only after 30s). `pinnedSessions` then lacks + // the member, so the Pinned section renders no host row for it — the + // workspace group row must host the rename form itself instead of being + // suppressed, otherwise double-click rename is a silent no-op. + connection.capabilities = { + ...organizationCapabilities, + features: [ + ...organizationCapabilities.features, + 'workspace_session_metadata', + 'workspace_qualified_rest_core', + ], + }; + workspace.capabilities = { + ...organizationCapabilities, + workspaces: [ + { id: 'primary', cwd: '/tmp/project', primary: true, trusted: true }, + { + id: 'secondary', + cwd: '/tmp/other', + primary: false, + trusted: true, + }, + ], + }; + const designGroupCatalog = { + groups: [ + { + id: 'design-group', + name: 'Design', + color: 'blue', + order: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }; + workspaceActions.listSessionGroups.mockResolvedValue({ + groups: [], + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }); + const member = makeSession('secondary-member', { + displayName: 'Secondary member', + workspaceCwd: '/tmp/other', + groupId: 'design-group', + isPinned: true, + pinnedAt: '2026-01-02T00:00:00.000Z', + }); + // The secondary pinned catalog page never settles (absent query result), + // so `pinnedSessions` stays empty and the Pinned section never renders. + useSessionCatalogQueries.mockImplementation( + (_client: unknown, queries: Array<{ workspaceCwd: string }>) => + queries.map(() => ({})), + ); + // The secondary workspace section loads its own session page carrying + // the pinned member. + workspace.client.workspaceByCwd.mockImplementation((cwd: string) => { + if (cwd === '/tmp/other') { + return { + listWorkspaceSessions: vi.fn().mockResolvedValue([member]), + listSessionGroups: vi.fn().mockResolvedValue(designGroupCatalog), + }; + } + return { + listWorkspaceSessions: vi.fn().mockResolvedValue([]), + listSessionGroups: vi.fn().mockResolvedValue({ + groups: [], + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }), + }; + }); + pinned.sessions = []; + pinned.data = pinned.sessions; + active.sessions = []; + active.data = active.sessions; + connection.sessionId = 'secondary-member'; + + renderSidebar(); + await flushSidebar(); + await flushSidebar(); + + // No host row exists in the Pinned section... + expect(pinnedListTitles()).not.toContain('Secondary member'); + // ...so the only rendered copy is the workspace group row. + expect(sessionTitleCount('Secondary member')).toBe(1); + const group = container.querySelector( + 'section[aria-label="Design"]', + ); + expect(group).not.toBeNull(); + const groupRow = Array.from( + group!.querySelectorAll('[role="button"]'), + ).find((element) => element.textContent?.includes('Secondary member')); + expect(groupRow).not.toBeUndefined(); + act(() => { + groupRow!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + }); + await flushSidebar(); + + expect( + container.querySelectorAll('input[aria-label="Rename: Secondary member"]') + .length, + ).toBe(1); + }); +}); diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index aae715e3ba8..edc8a3c0abb 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -3491,19 +3491,15 @@ export function WebShellSidebar({ ], ); - const filteredSessions = useMemo(() => { + const searchedSessions = useMemo(() => { const query = searchQuery.trim().toLowerCase(); const sourceScopedSessions = sessions .map(applyOptimisticPin) .filter((session) => matchesSessionSource(session, selectedSessionSource), ); - const unpinnedSessions = - selectedSessionSource === 'channel' - ? sourceScopedSessions - : sourceScopedSessions.filter((session) => !session.isPinned); - const nextSessions = query - ? unpinnedSessions.filter((session) => { + return query + ? sourceScopedSessions.filter((session) => { const label = getSessionLabel(session).toLowerCase(); return ( label.includes(query) || @@ -3511,7 +3507,14 @@ export function WebShellSidebar({ sessionMatchesGitQuery(session, query) ); }) - : unpinnedSessions.slice(); + : sourceScopedSessions; + }, [applyOptimisticPin, searchQuery, selectedSessionSource, sessions]); + const filteredSessions = useMemo(() => { + const unpinnedSessions = + selectedSessionSource === 'channel' + ? searchedSessions + : searchedSessions.filter((session) => !session.isPinned); + const nextSessions = unpinnedSessions.slice(); if (organizationEnabled) { return nextSessions; } @@ -3526,13 +3529,7 @@ export function WebShellSidebar({ (createdTimeById.get(b.sessionId) ?? 0) - (createdTimeById.get(a.sessionId) ?? 0), ); - }, [ - applyOptimisticPin, - organizationEnabled, - searchQuery, - selectedSessionSource, - sessions, - ]); + }, [organizationEnabled, searchedSessions, selectedSessionSource]); const channelCatalogLoaded = channelCatalogData !== undefined; const channelSessionSections = useMemo( @@ -3568,7 +3565,15 @@ export function WebShellSidebar({ sessionsByGroupId.set(group.id, []); } const recentSessions: DaemonSessionSummary[] = []; - for (const session of filteredSessions) { + // Bucket the search-filtered catalog in one pass, pinned rows included: + // they are lifted into the Pinned section, but their group/color section + // must keep them, otherwise a section whose members are all pinned + // rendered `· 0`, indistinguishable from lost memberships (#10391). + // One pass (instead of bucketing the unpinned rows first and appending + // the pinned ones) preserves the daemon catalog order — pinned rows sort + // first there — so a pinned member is never pushed behind its section's + // preview limit by rows it sorts ahead of. + for (const session of searchedSessions) { // Color takes precedence: the picker keeps color and group mutually // exclusive, but stay defensive if a store somehow carries both. if (session.color && SESSION_GROUP_COLORS.includes(session.color)) { @@ -3583,9 +3588,14 @@ export function WebShellSidebar({ : undefined; if (groupSessions) { groupSessions.push(session); - } else { - recentSessions.push(session); + continue; } + // On sources with a Pinned section, a pinned session without a + // (renderable) group stays Pinned-section-only; it never spills into + // Ungrouped. The channel source has no Pinned section, so its pinned + // rows keep the normal Ungrouped bucket. + if (session.isPinned && selectedSessionSource !== 'channel') continue; + recentSessions.push(session); } const sections: SessionSection[] = []; // Color buckets first, in palette order; only render non-empty ones so the @@ -3626,7 +3636,14 @@ export function WebShellSidebar({ }); } return sections; - }, [filteredSessions, groups, organizationEnabled, searchQuery, t]); + }, [ + groups, + organizationEnabled, + searchedSessions, + searchQuery, + selectedSessionSource, + t, + ]); useEffect(() => { const activeSections = channelSessionSections ?? sessionSections; @@ -3814,9 +3831,10 @@ export function WebShellSidebar({ session: DaemonSessionSummary, options: { isArchived?: boolean; + renameFormDisabled?: boolean; } = {}, ) => { - const { isArchived = false } = options; + const { isArchived = false, renameFormDisabled = false } = options; const sessionIdentity = getIdentityForSession(session); const label = getSessionLabel(session); const stamp = session.updatedAt || session.createdAt; @@ -3825,7 +3843,12 @@ export function WebShellSidebar({ const exporting = exportingSessionIds.has(sessionIdentity); const completedUnread = !isCurrentSession(session) && completedUnreadIds.has(sessionIdentity); - const isEditing = editingSessionIdentity === sessionIdentity; + // Pinned group members also render in the Pinned section; callers + // suppress the rename form on the duplicate row so only one input can + // mount — a rival input's autofocus would blur this one and its blur + // handler would cancel the rename. + const isEditing = + !renameFormDisabled && editingSessionIdentity === sessionIdentity; const gitIcon = session.worktree ? ( ) : session.branch ? ( @@ -4424,7 +4447,6 @@ export function WebShellSidebar({ filteredSessions.length === 0 && (selectedSessionSource === 'channel' || channelSessionSections !== null || - searchQuery.trim() || !organizationEnabled || sessionSections.length === 0) ) { @@ -4482,7 +4504,14 @@ export function WebShellSidebar({ deleteLabel={t('sidebar.groupDelete')} actionsDisabled={groupBusy} > - {section.sessions.map((session) => renderSessionRow(session))} + {section.sessions.map((session) => + renderSessionRow(session, { + // Pinned members also render in the Pinned section; while that + // section is expanded its row hosts the rename form, so this + // duplicate row must not mount a second autofocused input. + renameFormDisabled: Boolean(session.isPinned) && pinnedExpanded, + }), + )} ); }); @@ -4498,6 +4527,7 @@ export function WebShellSidebar({ handleRenameGroup, loading, organizationEnabled, + pinnedExpanded, reload, renderSessionRow, searchQuery, @@ -5370,10 +5400,39 @@ export function WebShellSidebar({ } renderSessions={!ws.primary} renderSession={(session) => - renderSessionRow({ - ...session, - workspaceCwd: ws.cwd, - }) + renderSessionRow( + { + ...session, + workspaceCwd: ws.cwd, + }, + { + // Pinned members also render in the + // sidebar-level Pinned section; while that + // section is expanded its row hosts the + // rename form, so this duplicate row must + // not mount a second autofocused input. + // Channel mode has no Pinned section, so + // the workspace row is the only copy and + // must stay editable. The suppression also + // requires the Pinned section to actually + // carry the member: `pinnedSessions` merges + // only the pinned catalog pages and the + // primary sessions page, never this + // workspace's own page, so before the + // pinned page settles (or while it errors) + // this row is the only copy and must host + // the form itself. + renameFormDisabled: + selectedSessionSource !== 'channel' && + Boolean(session.isPinned) && + pinnedExpanded && + pinnedSessions.some( + (candidate) => + getIdentityForSession(candidate) === + getIdentityForSession(session), + ), + }, + ) } showSessionDetails={sessionActionItems.has('details')} headerActions={(visible) => { diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx index b8251587eb3..bcccca4f17b 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx @@ -133,6 +133,8 @@ function renderSection( sessionCatalogRequestsEnabled: boolean; sessionGroupCatalog: DaemonSessionGroupCatalog; sessionLiveStateEnabled: boolean; + excludePinned: boolean; + searchQuery: string; }> = {}, ): void { act(() => { @@ -154,6 +156,8 @@ function renderSection( } sessionGroupCatalog={overrides.sessionGroupCatalog} sessionLiveStateEnabled={overrides.sessionLiveStateEnabled} + excludePinned={overrides.excludePinned} + searchQuery={overrides.searchQuery} sourceType={overrides.sourceType} channelGroupingEnabled={overrides.channelGroupingEnabled} ungroupedLabel="Ungrouped" @@ -1151,3 +1155,198 @@ describe('isAbsolutePath', () => { expect(isAbsolutePath('name')).toBe(false); }); }); + +describe('WorkspaceSection pinned group members (issue #10391)', () => { + function makeOrganizationClient( + sessions: Array>, + ): DaemonClient { + return { + workspaceByCwd: vi.fn(() => ({ + workspaceGit, + listWorkspaceSessionsPage: vi.fn().mockResolvedValue({ sessions }), + listSessionGroups: vi.fn().mockResolvedValue({ + groups: [ + { + id: 'design-group', + name: 'Design', + color: 'blue', + order: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + }), + })), + } as unknown as DaemonClient; + } + + it('keeps pinned members in their group section and count', async () => { + renderSection({ + client: makeOrganizationClient([ + { + sessionId: 'pinned-member', + displayName: 'Pinned member', + groupId: 'design-group', + isPinned: true, + pinnedAt: '2026-01-02T00:00:00.000Z', + }, + { + sessionId: 'plain-session', + displayName: 'Plain session', + groupId: null, + }, + ] as Array>), + expanded: true, + organizationEnabled: true, + excludePinned: true, + }); + await flush(); + + const groupSection = container.querySelector( + 'section[aria-label="Design"]', + ); + expect(groupSection).not.toBeNull(); + // The reported symptom: a group whose members are all pinned rendered + // `· 0`, visually identical to lost memberships. + expect(groupSection?.textContent).toContain('· 1'); + expect(groupSection?.textContent).toContain('Pinned member'); + + // Pinned members keep their group and must not fall into Ungrouped. + const ungrouped = container.querySelector( + 'section[aria-label="Ungrouped"]', + ); + expect(ungrouped?.textContent).toContain('Plain session'); + expect(ungrouped?.textContent ?? '').not.toContain('Pinned member'); + }); + + it('still renders unpinned members when pinned rows are lifted into the group', async () => { + renderSection({ + client: makeOrganizationClient([ + { + sessionId: 'pinned-member', + displayName: 'Pinned member', + groupId: 'design-group', + isPinned: true, + pinnedAt: '2026-01-02T00:00:00.000Z', + }, + { + sessionId: 'active-member', + displayName: 'Active member', + groupId: 'design-group', + }, + ] as Array>), + expanded: true, + organizationEnabled: true, + excludePinned: true, + }); + await flush(); + + const groupSection = container.querySelector( + 'section[aria-label="Design"]', + ); + expect(groupSection?.textContent).toContain('· 2'); + expect(groupSection?.textContent).toContain('Active member'); + expect(groupSection?.textContent).toContain('Pinned member'); + // Every session belongs to the group, so no Ungrouped bucket renders. + expect( + container.querySelector('section[aria-label="Ungrouped"]'), + ).toBeNull(); + }); + + it('renders group sections instead of the empty label when every session is pinned', async () => { + renderSection({ + client: makeOrganizationClient([ + { + sessionId: 'pinned-member', + displayName: 'Pinned member', + groupId: 'design-group', + isPinned: true, + pinnedAt: '2026-01-02T00:00:00.000Z', + }, + ] as Array>), + expanded: true, + organizationEnabled: true, + excludePinned: true, + }); + await flush(); + + // Every session is a pinned group member, so the pinned-filtered list is + // empty; the grouped view must still render the member instead of the + // empty label. + const groupSection = container.querySelector( + 'section[aria-label="Design"]', + ); + expect(groupSection).not.toBeNull(); + expect(groupSection?.textContent).toContain('\u00b7 1'); + expect(groupSection?.textContent).toContain('Pinned member'); + expect(container.textContent ?? '').not.toContain('No sessions'); + }); + + it('keeps a pinned member in its group while searching matches only it', async () => { + renderSection({ + client: makeOrganizationClient([ + { + sessionId: 'pinned-member', + displayName: 'Pinned member', + groupId: 'design-group', + isPinned: true, + pinnedAt: '2026-01-02T00:00:00.000Z', + }, + { + sessionId: 'active-member', + displayName: 'Active member', + groupId: 'design-group', + }, + ] as Array>), + expanded: true, + organizationEnabled: true, + excludePinned: true, + searchQuery: 'pinned', + }); + await flush(); + + // The query matches only the pinned member, so group items must derive + // from the search-filtered list: it stays in its group while the + // non-matching member disappears. + const groupSection = container.querySelector( + 'section[aria-label="Design"]', + ); + expect(groupSection).not.toBeNull(); + expect(groupSection?.textContent).toContain('\u00b7 1'); + expect(groupSection?.textContent).toContain('Pinned member'); + expect(groupSection?.textContent ?? '').not.toContain('Active member'); + }); + + it('keeps a group-less pinned session out of the Ungrouped section', async () => { + renderSection({ + client: makeOrganizationClient([ + { + sessionId: 'pinned-free', + displayName: 'Pinned free', + groupId: null, + isPinned: true, + pinnedAt: '2026-01-02T00:00:00.000Z', + }, + { + sessionId: 'plain-session', + displayName: 'Plain session', + groupId: null, + }, + ] as Array>), + expanded: true, + organizationEnabled: true, + excludePinned: true, + }); + await flush(); + + // `ungrouped` derives from the pinned-filtered list, so the group-less + // pinned session never duplicates the Pinned section inside Ungrouped. + const ungrouped = container.querySelector( + 'section[aria-label="Ungrouped"]', + ); + expect(ungrouped).not.toBeNull(); + expect(ungrouped?.textContent).toContain('\u00b7 1'); + expect(ungrouped?.textContent).toContain('Plain session'); + expect(ungrouped?.textContent ?? '').not.toContain('Pinned free'); + }); +}); diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx index a6744960688..95868d87731 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx @@ -429,12 +429,11 @@ export function WorkspaceSection({ workspace.trusted, ]); - const visibleSessions = useMemo(() => { + const searchedSessions = useMemo(() => { const query = searchQuery.trim().toLowerCase(); return sessions .map((session) => mapSession?.(session) ?? session) .filter((session) => { - if (excludePinned && session.isPinned) return false; if (!query) return true; const label = (session.displayName || '').toLowerCase(); return ( @@ -443,7 +442,14 @@ export function WorkspaceSection({ sessionMatchesGitQuery(session, query) ); }); - }, [excludePinned, mapSession, searchQuery, sessions]); + }, [mapSession, searchQuery, sessions]); + const visibleSessions = useMemo( + () => + excludePinned + ? searchedSessions.filter((session) => !session.isPinned) + : searchedSessions, + [excludePinned, searchedSessions], + ); const directSessions = searchActive || showAllSessions || !limitSessions ? visibleSessions @@ -454,7 +460,12 @@ export function WorkspaceSection({ return null; const assigned = new Set(); const sections = groups.map((group) => { - const items = visibleSessions.filter( + // Group sections derive from the search-filtered list, not the + // pinned-filtered one: pinned members are lifted into the Pinned + // section, but dropping them here rendered a group whose members are + // all pinned as `· 0`, indistinguishable from lost memberships + // (#10391). + const items = searchedSessions.filter( (session) => session.groupId === group.id, ); items.forEach((session) => assigned.add(session.sessionId)); @@ -462,11 +473,19 @@ export function WorkspaceSection({ }); return { sections, + // Pinned sessions without a group stay Pinned-section-only; they never + // spill into Ungrouped. ungrouped: visibleSessions.filter( (session) => !assigned.has(session.sessionId), ), }; - }, [channelGroupingEnabled, groups, organizationEnabled, visibleSessions]); + }, [ + channelGroupingEnabled, + groups, + organizationEnabled, + searchedSessions, + visibleSessions, + ]); const channelSessionGroups = useMemo( () => @@ -569,7 +588,16 @@ export function WorkspaceSection({
{loadErrorLabel}
- ) : visibleSessions.length === 0 ? ( + ) : visibleSessions.length === 0 && + // Group sections keep pinned members even when the + // pinned-filtered list is empty, so only show the empty label + // when the grouped view has nothing to render either. + !( + groupedSessions && + groupedSessions.sections.some( + (section) => section.sessions.length > 0, + ) + ) ? ( // A source switch swaps the query key; until the new source's // page settles there is no data yet, so the "no sessions" notice // would flash for a whole fetch round-trip. diff --git a/packages/web-shell/client/e2e/utils/mockDaemon.ts b/packages/web-shell/client/e2e/utils/mockDaemon.ts index b72dbbcd24b..5bdc98782d3 100644 --- a/packages/web-shell/client/e2e/utils/mockDaemon.ts +++ b/packages/web-shell/client/e2e/utils/mockDaemon.ts @@ -628,7 +628,8 @@ function readRequestBody(raw: string | null): unknown { // Mirror production query modes: `group=pinned` is the pinned bucket; // `group=all` (and missing group) returns the full active list. The UI -// excludes pinned rows from organized sections via `excludePinned`. +// renders pinned rows in the Pinned section and keeps them inside their +// named groups; unassigned pinned rows stay out of Ungrouped. function filterScenarioSessions( scenario: WebShellDaemonScenario, searchParams: URLSearchParams,