diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 78c006c374..d0a1f50dac 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -3115,6 +3115,8 @@ export function WebShellSidebar({ const showPin = canOrganizeSession(session, 'pin'); const showArchive = sessionActionItems.has('archive') && canMutateSessionArchive(session); + const showReadOnlyArchive = readOnly && showArchive && !showPin; + const showSharedArchive = showArchive && !showReadOnlyArchive; const showRename = sessionActionItems.has('rename') && mutableScope; const activeExportScope = getActiveExportScope(session); const showExport = @@ -3219,7 +3221,7 @@ export function WebShellSidebar({ ) : !attentionLabel ? ( {time} ) : null} - {readOnly && showArchive && ( + {showReadOnlyArchive && (
event.stopPropagation()} @@ -3322,7 +3324,8 @@ export function WebShellSidebar({ ? t('sidebar.archiveCurrentDisabled') : t('sidebar.archive'), visible: - showArchive && inlineActionItems.has('archive'), + showSharedArchive && + inlineActionItems.has('archive'), onClick: () => handleArchive(session), }, { @@ -3394,7 +3397,8 @@ export function WebShellSidebar({ )); })()} {(showPin && !inlineActionItems.has('pin')) || - (showArchive && !inlineActionItems.has('archive')) || + (showSharedArchive && + !inlineActionItems.has('archive')) || sessionActionItems.has('details') || (showRename && !inlineActionItems.has('rename')) || canOrganizeSession(session, 'group') || @@ -3436,7 +3440,7 @@ export function WebShellSidebar({ : t('sidebar.pin')} )} - {showArchive && + {showSharedArchive && !inlineActionItems.has('archive') && ( { expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2); }); }); + +describe('WebShellSidebar session toolbar archive action dedupe', () => { + function enableOrganization(): void { + connection.capabilities = { + ...capabilities, + features: [...capabilities.features, 'session_organization'], + }; + workspace.capabilities = connection.capabilities; + } + + // Sessions from a trusted secondary workspace resolve to the + // "restricted" scope: read-only rows that may still show pin and + // archive when the qualified REST core is enabled — the exact state + // that used to render two archive actions. + async function pinnedSecondaryCatalog( + cwd: string, + options?: { group?: string }, + ): Promise { + return cwd === '/tmp/other' && options?.group === 'pinned' + ? [ + { + sessionId: 'pinned-secondary', + workspaceCwd: cwd, + displayName: 'Pinned secondary', + isPinned: true, + }, + ] + : []; + } + + function sessionRow(label: string): HTMLElement { + const row = Array.from( + container.querySelectorAll('[class*="sessionRow"]'), + ).find((candidate) => candidate.textContent?.includes(label)); + expect(row).toBeDefined(); + return row!; + } + + function archiveButtonsInRow(label: string): HTMLButtonElement[] { + return Array.from( + sessionRow(label).querySelectorAll( + 'button[aria-label="Archive"]', + ), + ); + } + + async function settle(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + } + + async function countArchiveMenuItemsInRow(label: string): Promise { + const trigger = sessionAction(label); + expect(trigger).toBeDefined(); + await act(async () => { + click(trigger!); + await Promise.resolve(); + }); + return Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + ).filter((item) => item.textContent?.includes('Archive')).length; + } + + it('keeps one inline archive action when ownership changes', async () => { + enableOrganization(); + useWorkspaceSessionCatalog(pinnedSecondaryCatalog); + + renderSidebar(); + await settle(); + expect(inlineSessionAction('Pinned secondary', 'Unpin')).toBeDefined(); + expect(archiveButtonsInRow('Pinned secondary')).toHaveLength(1); + expect( + sessionRow('Pinned secondary').querySelectorAll( + '[class*="sessionActions"]', + ), + ).toHaveLength(1); + }); + + it('keeps archive when the read-only cluster is the sole owner', async () => { + useWorkspaceSessionCatalog(async (cwd, options) => + cwd === '/tmp/other' && options?.archiveState === 'active' + ? [ + { + sessionId: 'secondary-only', + workspaceCwd: cwd, + displayName: 'Secondary only', + }, + ] + : [], + ); + renderSidebar({ + sessionActions: { items: ['archive'], inlineItems: ['archive'] }, + }); + await expandWorkspace('other'); + expect(archiveButtonsInRow('Secondary only')).toHaveLength(1); + expect( + sessionRow('Secondary only').querySelectorAll( + '[class*="sessionActions"]', + ), + ).toHaveLength(1); + }); + + it('keeps exactly one archive menu item when archive is dropdown-only', async () => { + enableOrganization(); + useWorkspaceSessionCatalog(pinnedSecondaryCatalog); + + renderSidebar({ + sessionActions: { inlineItems: ['pin'] }, + }); + await settle(); + + expect(archiveButtonsInRow('Pinned secondary')).toHaveLength(0); + expect( + sessionRow('Pinned secondary').querySelectorAll( + 'button[aria-label="More actions"]', + ), + ).toHaveLength(1); + expect(await countArchiveMenuItemsInRow('Pinned secondary')).toBe(1); + }); +});