fix(web-shell): render a single archive action in the session toolbar (#9066)

* fix(web-shell): render a single archive action in the session toolbar

Read-only session rows render a dedicated archive control, but the shared action block kept contributing its own archive action whenever the row also showed pin (restricted secondary workspaces with the qualified REST core and session organization enabled). Both blocks render as stacked overlays, so the row ended up with two visually identical archive buttons after the pin action.

Make the read-only block the single owner of the archive action for those rows: suppress the archive entry (inline button and dropdown item) in the shared action block whenever the read-only block renders it. Pin and all other actions are unaffected.

* fix(web-shell): centralize archive action ownership

* fix(web-shell): keep archive actions pointer-accessible
This commit is contained in:
易良 2026-08-13 15:11:34 +00:00 committed by GitHub
parent 299d29c479
commit 71b45e3259
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 130 additions and 4 deletions

View file

@ -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 ? (
<span className={styles.sessionTime}>{time}</span>
) : null}
{readOnly && showArchive && (
{showReadOnlyArchive && (
<div
className={styles.sessionActions}
onClick={(event) => 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')}
</DropdownMenuItem>
)}
{showArchive &&
{showSharedArchive &&
!inlineActionItems.has('archive') && (
<DropdownMenuItem
disabled={busy || isCurrent}

View file

@ -4382,3 +4382,125 @@ describe('WebShellSidebar archived session export', () => {
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<DaemonSessionSummary[]> {
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<HTMLElement>('[class*="sessionRow"]'),
).find((candidate) => candidate.textContent?.includes(label));
expect(row).toBeDefined();
return row!;
}
function archiveButtonsInRow(label: string): HTMLButtonElement[] {
return Array.from(
sessionRow(label).querySelectorAll<HTMLButtonElement>(
'button[aria-label="Archive"]',
),
);
}
async function settle(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
async function countArchiveMenuItemsInRow(label: string): Promise<number> {
const trigger = sessionAction(label);
expect(trigger).toBeDefined();
await act(async () => {
click(trigger!);
await Promise.resolve();
});
return Array.from(
document.body.querySelectorAll<HTMLElement>('[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);
});
});