feat(web): "+" finds a workspace outside your tabs, creates one, or restores one (TASK-3276) (#1639)

PLAN-3002 U4, Dave's Q4 ruling. Since U3 the bar shows only the open set,
so the TopBar "+" becomes the way to reach any other workspace:

- WorkspaceDiscovery: a combobox over a listbox of every visible workspace
  NOT open as a tab, filtered by name or slug, then "New workspace", then
  recently deleted. Arrows (wrapping, aria-activedescendant), Enter, Escape
  (returns focus to "+"), and outside press. Choosing a workspace opens it
  as a KEPT tab and lands on its last route; a failed open still navigates.
- RecentlyDeletedWorkspaces: the deleted-list piece moved out of
  WorkspaceSwitcher, which now renders it too (U6 will build on the same).
- The "+" title is now "Find or create a workspace", since it no longer
  opens the create modal directly; two e2e specs follow the new path.
- Pending invitations are split out to U4b (TASK-3277); "+" keeps a slot.

Also: the U3 drag e2e leg released before svelte-dnd-action sampled the
final pointer position (1 in 10); it now waits for the drag to engage and
for the shadow slot to move before releasing.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
This commit is contained in:
xarmian
2026-09-28 11:37:37 -04:00
committed by GitHub
parent ab4cd6926e
commit 2174993f68
8 changed files with 821 additions and 186 deletions
+5 -2
View File
@@ -168,7 +168,9 @@ test('workspace export → import round-trip via web UI', async ({ page, fixture
await writeFile(bundlePath, bundleBytes);
// 3. Open the Create Workspace modal and switch to Import.
// On desktop the TopBar exposes a "+" button (title="New workspace");
// On desktop the TopBar exposes a "+" button (title="Find or create a
// workspace", PLAN-3002 U4) whose discovery surface has a "New workspace…"
// option;
// on mobile that button is hidden behind the layout's collapse.
// Try the TopBar trigger first and fall back to opening the
// WorkspaceSwitcher dropdown/sheet and clicking its
@@ -188,9 +190,10 @@ test('workspace export → import round-trip via web UI', async ({ page, fixture
// rendered by the dashboard route the moment hydration completes.
await expect(page.getByRole('heading', { name: /E2E Workspace/i })).toBeVisible();
const topbarTrigger = page.getByTitle('New workspace');
const topbarTrigger = page.getByTitle('Find or create a workspace');
if (await topbarTrigger.first().isVisible().catch(() => false)) {
await topbarTrigger.first().click();
await page.getByRole('option', { name: /new workspace/i }).click();
} else {
// Mobile path: tap the workspace switcher's current-workspace
// button to open the picker, then click "+ New Workspace".
+123 -6
View File
@@ -1,5 +1,5 @@
import { test, expect, type SuiteFixture } from './fixtures';
import { request, type APIRequestContext, type BrowserContext, type Page } from '@playwright/test';
import { request, type APIRequestContext, type BrowserContext, type Locator, type Page } from '@playwright/test';
import { quietCrossActorToasts } from './fixtures';
/**
@@ -103,6 +103,18 @@ async function barOrder(page: Page): Promise<string[]> {
return tabs(page).evaluateAll((els) => els.map((el) => (el as HTMLElement).dataset.wsSlug ?? ''));
}
/** A locator's box once two reads 100 ms apart agree (an animation has settled). */
async function stableBox(locator: Locator) {
let prev = await locator.boundingBox();
for (let i = 0; i < 30; i++) {
await locator.page().waitForTimeout(100);
const next = await locator.boundingBox();
if (prev && next && prev.x === next.x && prev.y === next.y && prev.width === next.width) return next;
prev = next;
}
throw new Error('box never settled');
}
async function closeTab(page: Page, slug: string) {
await tab(page, slug).hover();
await tab(page, slug).locator('.workspace-tab-close').click();
@@ -152,15 +164,25 @@ test.describe('workspace tab bar (TASK-3274)', () => {
await expect(tabs(page)).toHaveCount(2);
await expect(page).toHaveURL(new RegExp(`/${a}$`));
// Drag B in front of A, with a real mouse.
const from = await tab(page, b).locator('a').boundingBox();
const to = await tab(page, a).locator('a').boundingBox();
if (!from || !to) throw new Error('tab has no box');
// Drag B in front of A, with a real mouse. The close just reflowed
// the zone with a 150 ms FLIP animation, so the boxes are read only
// once they hold still: a box read mid-animation aims the drag at
// where a tab was, and the drop lands nowhere.
const from = await stableBox(tab(page, b).locator('a'));
const to = await stableBox(tab(page, a).locator('a'));
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
await page.mouse.down();
await page.mouse.move(from.x + from.width / 2 - 10, from.y + from.height / 2, { steps: 4 });
// The drag has engaged once svelte-dnd-action mounts its clone.
await expect(page.locator('#dnd-action-dragged-el')).toHaveCount(1);
await page.mouse.move(to.x + 4, to.y + to.height / 2, { steps: 10 });
// svelte-dnd-action samples the pointer on an interval, so a
// release straight after the last move can drop before it has seen
// where the pointer is. Hold over the target until the shadow slot
// has moved in front of A, then release.
await expect.poll(() => barOrder(page)).toEqual([b, a]);
await page.mouse.up();
await expect(page.locator('#dnd-action-dragged-el')).toHaveCount(0);
await expect.poll(() => barOrder(page)).toEqual([b, a]);
await expect.poll(() => serverOrder(account)).toEqual([b, a]);
// The drop's synthetic click must not have navigated to B.
@@ -278,7 +300,8 @@ test.describe('workspace tab bar (TASK-3274)', () => {
await page.goto(`/${account.username}/${home}`);
await expect(tabs(page)).toHaveCount(1);
await page.getByTitle('New workspace').click();
await page.getByTitle('Find or create a workspace').click();
await page.getByRole('option', { name: /new workspace/i }).click();
const dialog = page.getByRole('dialog', { name: /new workspace/i });
await expect(dialog).toBeVisible();
const name = `Made here ${Date.now()}`;
@@ -299,3 +322,97 @@ test.describe('workspace tab bar (TASK-3274)', () => {
}
});
});
/**
* TASK-3276 (PLAN-3002 U4): the "+" discovery surface. Since U3 the bar shows
* only the open set, so "+" is how a workspace outside it is reached. The
* create path is covered above ('a workspace created with "+" …').
*/
test.describe('"+" discovery surface (TASK-3276)', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'the tab bar is desktop-only (PLAN-3002 Q11)');
await page.setViewportSize(DESKTOP);
});
test('a user with seven workspaces and six tabs reaches the seventh from "+", as a kept tab', async ({
page,
context,
fixture
}) => {
let world: World | undefined;
try {
world = await seed(fixture, ['One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seventh']);
const { account, slugs } = world;
const seventh = slugs[6];
await setOpenSet(account, slugs.slice(0, 6));
await actAs(context, account);
await page.goto(`/${account.username}/${slugs[0]}`);
await expect(tabs(page)).toHaveCount(6);
await expect(tab(page, seventh)).toHaveCount(0);
await page.getByTitle('Find or create a workspace').click();
const search = page.getByRole('combobox', { name: 'Find a workspace' });
await expect(search).toBeFocused();
// The six open workspaces are not offered; the seventh is.
await expect(page.locator('.discovery-option[data-ws-slug]')).toHaveCount(1);
await search.fill('Seventh');
await page.locator(`.discovery-option[data-ws-slug="${seventh}"]`).click();
await expect(page).toHaveURL(new RegExp(`/${account.username}/${seventh}$`));
await expect(tabs(page)).toHaveCount(7);
await expect(tab(page, seventh)).not.toHaveClass(/ephemeral/);
await expect.poll(() => serverOrder(account)).toContain(seventh);
await page.reload();
await expect(tab(page, seventh)).toHaveCount(1);
await expect(tab(page, seventh)).not.toHaveClass(/ephemeral/);
} finally {
await teardown(world);
}
});
test('keyboard only: open "+", search, arrow, Enter; and Escape returns focus to "+"', async ({
page,
context,
fixture
}) => {
let world: World | undefined;
try {
world = await seed(fixture, ['Home', 'Kb Alpha', 'Kb Beta']);
const { account, slugs } = world;
const [home, , beta] = slugs;
await setOpenSet(account, [home]);
await actAs(context, account);
await page.goto(`/${account.username}/${home}`);
await expect(tabs(page)).toHaveCount(1);
const plus = page.getByTitle('Find or create a workspace');
const search = page.getByRole('combobox', { name: 'Find a workspace' });
// Escape closes and hands focus back to "+".
await plus.focus();
await page.keyboard.press('Enter');
await expect(search).toBeFocused();
await page.keyboard.press('Escape');
await expect(search).toHaveCount(0);
await expect(plus).toBeFocused();
// Open again from the keyboard, narrow to the two "Kb" workspaces,
// step to the second, and take it.
await page.keyboard.press('Enter');
await expect(search).toBeFocused();
await page.keyboard.type('Kb');
await expect(page.locator('.discovery-option[data-ws-slug]')).toHaveCount(2);
await page.keyboard.press('ArrowDown');
await expect(page.locator('.discovery-option[aria-selected="true"]')).toHaveAttribute('data-ws-slug', beta);
await page.keyboard.press('Enter');
await expect(page).toHaveURL(new RegExp(`/${account.username}/${beta}$`));
await expect(tab(page, beta)).toHaveCount(1);
await expect(tab(page, beta)).not.toHaveClass(/ephemeral/);
} finally {
await teardown(world);
}
});
});
@@ -0,0 +1,173 @@
<script lang="ts">
// Soft-deleted workspaces still inside the restore window (TASK-1974),
// with a Restore action per row. Moved out of WorkspaceSwitcher so the
// TopBar "+" discovery surface (PLAN-3002 U4 / TASK-3276) and the mobile
// switcher render the same piece instead of two copies.
//
// Renders NOTHING when the list is empty or the fetch failed, so it never
// adds noise to a surface with nothing to restore.
import { api } from '$lib/api/client';
import { workspaceStore } from '$lib/stores/workspace.svelte';
import { toastStore } from '$lib/stores/toast.svelte';
import type { DeletedWorkspace } from '$lib/types';
interface Props {
/** Re-fetched each time this turns true (the host surface opened). */
active: boolean;
/** Roomier rows for a mobile sheet. */
roomy?: boolean;
}
let { active, roomy = false }: Props = $props();
let deleted = $state<DeletedWorkspace[]>([]);
// Collapsed by default; kept while the host stays open.
let expanded = $state(false);
// Slug being restored: per-row pending state, so a double-click cannot
// fire two restores.
let restoringSlug = $state<string | null>(null);
// Monotonic token guarding listDeleted() responses: an older fetch cannot
// clobber the fresher post-restore refresh and re-surface a restored
// workspace.
let deletedSeq = 0;
async function loadDeleted() {
const seq = ++deletedSeq;
try {
const list = await api.workspaces.listDeleted();
if (seq === deletedSeq) deleted = list;
} catch {
// The restore affordance is a bonus, never a blocker. Clear so a
// stale list does not linger and the section stays hidden.
if (seq === deletedSeq) deleted = [];
}
}
$effect(() => {
if (active) loadDeleted();
});
async function restore(ws: DeletedWorkspace) {
if (restoringSlug) return;
restoringSlug = ws.slug;
try {
try {
await api.workspaces.restore(ws.slug);
} catch {
// Only the restore call itself failing is a restore failure.
toastStore.show(`Couldn't restore "${ws.name}"`, 'error');
return;
}
// Confirmed before refreshing, so a failing reload cannot pass for
// a restore failure.
toastStore.show(`Restored "${ws.name}"`, 'success');
try {
await Promise.all([loadDeleted(), workspaceStore.loadAll()]);
} catch {
// Reload failure is non-fatal; the restore went through.
}
} finally {
restoringSlug = null;
}
}
</script>
{#if deleted.length > 0}
<div class="deleted-section" class:roomy>
<button
type="button"
class="deleted-header"
onclick={() => (expanded = !expanded)}
aria-expanded={expanded}
>
<span class="deleted-caret" aria-hidden="true">{expanded ? '▾' : '▸'}</span>
<span class="deleted-title">Recently deleted</span>
<span class="deleted-count">{deleted.length}</span>
</button>
{#if expanded}
<div class="deleted-list">
{#each deleted as ws (ws.slug)}
<div class="deleted-item">
<span class="deleted-name" title={ws.name}>{ws.name}</span>
<span class="deleted-days">
{ws.days_left} {ws.days_left === 1 ? 'day' : 'days'} left
</span>
<button
type="button"
class="restore-btn"
onclick={() => restore(ws)}
disabled={restoringSlug === ws.slug}
>
{restoringSlug === ws.slug ? 'Restoring…' : 'Restore'}
</button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<style>
.deleted-section { border-top: 1px solid var(--border); }
.deleted-header {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
text-align: left;
padding: var(--space-2) var(--space-4);
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.deleted-header:hover { background: var(--bg-hover); }
.deleted-caret { font-size: 0.9em; flex-shrink: 0; }
.deleted-title { flex: 1; min-width: 0; }
.deleted-count {
flex-shrink: 0;
padding: 0 var(--space-2);
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
font-size: 0.9em;
}
.deleted-list { display: flex; flex-direction: column; }
.deleted-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
font-size: 0.9em;
}
.roomy .deleted-header { padding: var(--space-3); font-size: 0.85em; }
.roomy .deleted-item { padding: var(--space-2) var(--space-3); }
.deleted-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-secondary);
}
.deleted-days {
flex-shrink: 0;
color: var(--text-muted);
font-size: 0.85em;
white-space: nowrap;
}
.restore-btn {
flex-shrink: 0;
padding: var(--space-1) var(--space-2);
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--accent-blue);
cursor: pointer;
font-size: 0.85em;
}
.restore-btn:hover:not(:disabled) { background: var(--bg-hover); }
.restore-btn:disabled { opacity: 0.6; cursor: default; }
</style>
+23 -7
View File
@@ -13,6 +13,7 @@
import WorkspaceSwitcher from '$lib/components/layout/WorkspaceSwitcher.svelte';
import UserMenuResources from '$lib/components/layout/UserMenuResources.svelte';
import ConnectWorkspaceModal from '$lib/components/ConnectWorkspaceModal.svelte';
import WorkspaceDiscovery from '$lib/components/layout/WorkspaceDiscovery.svelte';
import Menu from '$lib/components/common/Menu.svelte';
import MenuItem from '$lib/components/common/MenuItem.svelte';
import { workspaceRestoreTarget } from '$lib/utils/workspace-route';
@@ -23,6 +24,10 @@
let userTriggerEl: HTMLButtonElement | undefined = $state(undefined);
let currentTheme = $state<'dark' | 'light'>('dark');
let connectOpen = $state(false);
// The "+" discovery surface (PLAN-3002 U4): find a workspace that is not
// open as a tab, create one, or restore a deleted one.
let discoveryOpen = $state(false);
let addEl: HTMLButtonElement | undefined = $state(undefined);
let currentSlug = $derived(workspaceStore.current?.slug ?? '');
@@ -407,13 +412,20 @@
{/each}
</div>
<button
class="workspace-add"
onclick={() => uiStore.openCreateWorkspace()}
title="New workspace"
>
<span class="add-icon">+</span>
</button>
<div class="workspace-add-anchor">
<button
class="workspace-add"
bind:this={addEl}
onclick={() => (discoveryOpen = !discoveryOpen)}
title="Find or create a workspace"
aria-label="Find or create a workspace"
aria-haspopup="listbox"
aria-expanded={discoveryOpen}
>
<span class="add-icon">+</span>
</button>
<WorkspaceDiscovery open={discoveryOpen} onclose={() => (discoveryOpen = false)} trigger={addEl} />
</div>
</div>
<div class="topbar-right">
@@ -676,6 +688,10 @@
text-overflow: ellipsis;
}
.workspace-add-anchor {
position: relative;
flex-shrink: 0;
}
.workspace-add {
display: flex;
align-items: center;
@@ -32,7 +32,7 @@ vi.mock('$app/navigation', () => ({ goto: mocks.goto }));
vi.mock('$lib/api/client', () => ({
PadApiError: class extends Error {},
api: { workspaces: { tabs: mocks.tabs } },
api: { workspaces: { tabs: mocks.tabs, listDeleted: vi.fn(async () => []) } },
}));
vi.mock('$lib/stores/workspace.svelte', () => ({
@@ -239,8 +239,11 @@ describe('TopBar: the Q2 highlight', () => {
it('opening the create flow from "+" clears it', async () => {
await mountWith([ALPHA]);
uiStore.highlightAddWorkspace();
const add = document.querySelector<HTMLButtonElement>('.workspace-add')!;
add.click();
document.querySelector<HTMLButtonElement>('.workspace-add')!.click();
await tick();
flushSync();
// "+" opens the discovery surface (U4); its last option is create.
document.querySelector<HTMLElement>('.discovery-option.create')!.click();
flushSync();
expect(uiStore.addWorkspaceHighlighted).toBe(false);
uiStore.closeCreateWorkspace();
@@ -0,0 +1,268 @@
<script lang="ts">
// The TopBar "+" discovery surface (PLAN-3002 U4 / TASK-3276, Dave's Q4
// ruling): search the workspaces that are NOT open as tabs, create one,
// and restore a recently deleted one. Since U3 the bar shows only the open
// set, so this is how a workspace outside it is reached without going
// through /console.
//
// A combobox over a listbox, not a Menu: focus stays in the search input
// and the arrow keys move the active option (aria-activedescendant), which
// the Menu primitive cannot do because it hands focus to its first row.
import { tick } from 'svelte';
import { goto } from '$app/navigation';
import { workspaceStore } from '$lib/stores/workspace.svelte';
import { tabsStore } from '$lib/stores/tabs.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { uiStore } from '$lib/stores/ui.svelte';
import { pushEscapeHandler, ESCAPE_PRIORITY } from '$lib/stores/escapeStack';
import { clickOutside } from '$lib/utils/clickOutside';
import { workspaceRestoreTarget } from '$lib/utils/workspace-route';
import RecentlyDeletedWorkspaces from '$lib/components/layout/RecentlyDeletedWorkspaces.svelte';
import type { Workspace } from '$lib/types';
interface Props {
open: boolean;
onclose: () => void;
/** The "+" button: exempt from outside-press, and focus returns to it. */
trigger?: HTMLElement;
}
let { open, onclose, trigger }: Props = $props();
const LISTBOX_ID = 'workspace-discovery-options';
let query = $state('');
let activeIndex = $state(0);
let inputEl: HTMLInputElement | undefined = $state(undefined);
let panelEl: HTMLElement | undefined = $state(undefined);
type Option = { kind: 'workspace'; ws: Workspace } | { kind: 'create' };
// Every visible workspace that is not an open tab, filtered by name or
// slug. The list is the workspace store's; the open set is the tabs
// store's, so a workspace opened elsewhere drops out as its commit lands.
let matches = $derived.by(() => {
const openSlugs = new Set(tabsStore.tabs.map((t) => t.slug));
const q = query.trim().toLowerCase();
return workspaceStore.workspaces.filter(
(ws) =>
!openSlugs.has(ws.slug) &&
(q === '' || ws.name.toLowerCase().includes(q) || ws.slug.toLowerCase().includes(q))
);
});
let options = $derived<Option[]>([
...matches.map((ws) => ({ kind: 'workspace' as const, ws })),
{ kind: 'create' as const },
]);
// Keep the active option in range as the list narrows.
$effect(() => {
if (activeIndex >= options.length) activeIndex = Math.max(0, options.length - 1);
});
// Reset and focus the search each time the surface opens.
$effect(() => {
if (!open) return;
query = '';
activeIndex = 0;
tick().then(() => inputEl?.focus());
});
$effect(() => {
if (!open) return;
return pushEscapeHandler(() => {
close();
return true;
}, ESCAPE_PRIORITY.menu);
});
function close() {
onclose();
trigger?.focus();
}
function optionId(i: number): string {
return `${LISTBOX_ID}-${i}`;
}
// Opening from search is an explicit choice, so the tab is KEPT (TASK-3276),
// then you land on that workspace's last route. A failed open still
// navigates: reaching the workspace is the point, the tab a convenience.
async function openWorkspace(ws: Workspace) {
onclose();
const isSameIdentity = authStore.identityFence();
await tabsStore.open(ws.slug).catch(() => {});
if (!isSameIdentity()) return;
goto(workspaceRestoreTarget(ws));
}
function createWorkspace() {
onclose();
uiStore.openCreateWorkspace();
}
function activate(option: Option | undefined) {
if (!option) return;
if (option.kind === 'workspace') void openWorkspace(option.ws);
else createWorkspace();
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = (activeIndex + 1) % options.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = (activeIndex - 1 + options.length) % options.length;
} else if (e.key === 'Enter') {
e.preventDefault();
activate(options[activeIndex]);
} else if (e.key === 'Escape') {
// Handled here as well as on the escape stack, so a press in the
// input closes this surface and nothing under it (Menu's pattern).
e.preventDefault();
e.stopPropagation();
close();
}
}
function onQueryInput() {
activeIndex = 0;
}
</script>
{#if open}
<div
class="discovery"
bind:this={panelEl}
use:clickOutside={{
enabled: open,
onOutside: onclose,
extra: () => [trigger],
}}
>
<input
bind:this={inputEl}
bind:value={query}
oninput={onQueryInput}
onkeydown={onInputKeydown}
class="discovery-search"
type="text"
role="combobox"
aria-label="Find a workspace"
aria-expanded="true"
aria-controls={LISTBOX_ID}
aria-autocomplete="list"
aria-activedescendant={options.length ? optionId(activeIndex) : undefined}
placeholder="Find a workspace…"
autocomplete="off"
spellcheck="false"
/>
<ul id={LISTBOX_ID} class="discovery-options" role="listbox" aria-label="Workspaces not open">
{#each options as option, i (option.kind === 'workspace' ? option.ws.slug : '__create')}
<!-- Options are activated through the combobox's keys; a pointer
click is the mouse path to the same action. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<li
id={optionId(i)}
role="option"
aria-selected={i === activeIndex}
class="discovery-option"
class:active={i === activeIndex}
class:create={option.kind === 'create'}
data-ws-slug={option.kind === 'workspace' ? option.ws.slug : undefined}
onmousedown={(e) => e.preventDefault()}
onmousemove={() => (activeIndex = i)}
onclick={() => activate(option)}
>
{#if option.kind === 'workspace'}
<span class="discovery-name">{option.ws.name}</span>
{#if option.ws.is_guest}<span class="discovery-meta">Shared with you</span>{/if}
{:else}
<span class="discovery-name">+ New workspace…</span>
{/if}
</li>
{/each}
</ul>
{#if matches.length === 0}
<p class="discovery-empty" aria-live="polite">
{query.trim() ? 'No workspace outside your tabs matches.' : 'Every workspace is already open.'}
</p>
{/if}
<!-- Pending invitations go here: PLAN-3002 U4b / TASK-3277. -->
<RecentlyDeletedWorkspaces active={open} />
</div>
{/if}
<style>
.discovery {
position: absolute;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
width: 300px;
max-height: min(70vh, 480px);
overflow-y: auto;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
z-index: 30;
padding-top: var(--space-2);
}
.discovery-search {
display: block;
width: calc(100% - 2 * var(--space-3));
margin: 0 var(--space-3) var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.9em;
}
.discovery-search:focus {
outline: none;
border-color: var(--accent-blue);
}
.discovery-options {
list-style: none;
margin: 0;
padding: 0;
}
.discovery-option {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
cursor: pointer;
font-size: 0.9em;
color: var(--text-primary);
}
.discovery-option.active {
background: var(--bg-hover);
}
.discovery-option.create {
color: var(--text-muted);
border-top: 1px solid var(--border);
}
.discovery-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.discovery-meta {
flex-shrink: 0;
color: var(--text-muted);
font-size: 0.8em;
}
.discovery-empty {
margin: 0;
padding: var(--space-2) var(--space-4);
color: var(--text-muted);
font-size: 0.85em;
}
</style>
@@ -0,0 +1,220 @@
// Runs in the jsdom vitest project (filename ends `.svelte.test.ts`).
//
// TASK-3276 (PLAN-3002 U4): the TopBar "+" discovery surface. It lists the
// workspaces that are NOT open as tabs, searches them, opens one as a KEPT tab
// and lands on its last route, and offers create. Driven through the real
// tabs store over a mocked API, so "not open" means what a committed server
// answer says.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, fireEvent } from '@testing-library/svelte';
import { tick, flushSync } from 'svelte';
const mocks = vi.hoisted(() => ({
goto: vi.fn(async () => {}),
workspaces: [] as { id: string; slug: string; name: string; owner_username: string; is_guest?: boolean }[],
tabs: {
list: vi.fn(),
open: vi.fn(),
close: vi.fn(),
reorder: vi.fn(),
update: vi.fn(),
},
listDeleted: vi.fn(async () => []),
}));
vi.mock('$app/navigation', () => ({ goto: mocks.goto }));
vi.mock('$lib/api/client', () => ({
PadApiError: class extends Error {},
api: { workspaces: { tabs: mocks.tabs, listDeleted: mocks.listDeleted, restore: vi.fn() } },
}));
vi.mock('$lib/stores/workspace.svelte', () => ({
workspaceStore: {
get workspaces() {
return mocks.workspaces;
},
current: null,
loadAll: vi.fn(async () => {}),
},
}));
import WorkspaceDiscovery from './WorkspaceDiscovery.svelte';
import { tabsStore } from '$lib/stores/tabs.svelte';
import { uiStore } from '$lib/stores/ui.svelte';
const ws = (slug: string, name: string, extra: { is_guest?: boolean } = {}) => ({
id: slug,
slug,
name,
owner_username: 'u',
...extra,
});
function answer(slugs: string[], lastRoutes: Record<string, string> = {}) {
return {
tabs: slugs.map((slug, i) => ({
slug,
name: slug,
owner_username: 'u',
is_guest: false,
ephemeral: false,
position: i,
created_at: '',
updated_at: '',
...(lastRoutes[slug] ? { last_route: lastRoutes[slug] } : {}),
})),
};
}
let onclose: ReturnType<typeof vi.fn>;
let trigger: HTMLButtonElement;
async function mountOpen(openSlugs: string[]) {
mocks.tabs.list.mockResolvedValueOnce(answer(openSlugs));
await tabsStore.load();
trigger = document.createElement('button');
document.body.appendChild(trigger);
onclose = vi.fn();
render(WorkspaceDiscovery, { props: { open: true, onclose, trigger } });
await tick();
await tick();
flushSync();
}
const input = () => document.querySelector<HTMLInputElement>('.discovery-search')!;
const optionSlugs = () =>
Array.from(document.querySelectorAll<HTMLElement>('.discovery-option[data-ws-slug]')).map(
(el) => el.dataset.wsSlug
);
const activeOption = () => document.querySelector<HTMLElement>('.discovery-option[aria-selected="true"]');
async function key(k: string) {
await fireEvent.keyDown(input(), { key: k });
flushSync();
}
async function settle() {
for (let i = 0; i < 6; i++) await Promise.resolve();
await tick();
flushSync();
}
beforeEach(() => {
mocks.goto.mockClear();
for (const fn of Object.values(mocks.tabs)) fn.mockReset();
mocks.workspaces = [ws('alpha', 'Alpha'), ws('beta', 'Beta'), ws('gamma', 'Gamma'), ws('delta', 'Delta', { is_guest: true })];
try {
localStorage.clear();
} catch {}
});
afterEach(() => {
cleanup();
uiStore.closeCreateWorkspace();
document.body.innerHTML = '';
});
describe('WorkspaceDiscovery: what it lists', () => {
it('lists only the workspaces that are not open as tabs, then create', async () => {
await mountOpen(['alpha', 'gamma']);
expect(optionSlugs()).toEqual(['beta', 'delta']);
expect(document.querySelector('.discovery-option.create')).not.toBeNull();
});
it('focuses the search when it opens', async () => {
await mountOpen([]);
expect(document.activeElement).toBe(input());
});
it('filters by name or slug, case-insensitively', async () => {
await mountOpen([]);
await fireEvent.input(input(), { target: { value: 'ALP' } });
flushSync();
expect(optionSlugs()).toEqual(['alpha']);
// A slug that shares nothing with its name, so a name-only filter
// cannot pass this half.
mocks.workspaces = [...mocks.workspaces, ws('ops-2024', 'Operations')];
flushSync();
await fireEvent.input(input(), { target: { value: 'ops-20' } });
flushSync();
expect(optionSlugs()).toEqual(['ops-2024']);
});
it('says when nothing outside the tabs matches', async () => {
await mountOpen([]);
await fireEvent.input(input(), { target: { value: 'zzz' } });
flushSync();
expect(optionSlugs()).toEqual([]);
expect(document.querySelector('.discovery-empty')?.textContent).toMatch(/no workspace/i);
});
it('marks a guest workspace with the shared wording', async () => {
await mountOpen(['alpha', 'beta', 'gamma']);
expect(document.querySelector('.discovery-option[data-ws-slug="delta"] .discovery-meta')?.textContent).toBe(
'Shared with you'
);
});
});
describe('WorkspaceDiscovery: the keyboard path', () => {
it('arrows move the active option, wrapping, and the input names it', async () => {
await mountOpen(['gamma', 'delta']);
// Options: alpha, beta, create.
expect(activeOption()?.dataset.wsSlug).toBe('alpha');
await key('ArrowDown');
expect(activeOption()?.dataset.wsSlug).toBe('beta');
await key('ArrowDown');
expect(activeOption()?.classList.contains('create')).toBe(true);
await key('ArrowDown');
expect(activeOption()?.dataset.wsSlug).toBe('alpha');
await key('ArrowUp');
expect(activeOption()?.classList.contains('create')).toBe(true);
expect(input().getAttribute('aria-activedescendant')).toBe(activeOption()?.id);
});
it('Enter opens the active workspace as a KEPT tab, then lands on its last route', async () => {
await mountOpen(['alpha']);
mocks.tabs.open.mockResolvedValueOnce(answer(['alpha', 'beta'], { beta: '/u/beta/docs' }));
await fireEvent.input(input(), { target: { value: 'bet' } });
flushSync();
await key('Enter');
await settle();
expect(mocks.tabs.open).toHaveBeenCalledWith('beta', false);
expect(onclose).toHaveBeenCalled();
expect(mocks.goto).toHaveBeenCalledWith('/u/beta/docs');
});
it('a failed open still navigates', async () => {
await mountOpen([]);
mocks.tabs.open.mockRejectedValueOnce(new TypeError('network down'));
await key('Enter');
await settle();
expect(mocks.goto).toHaveBeenCalledWith('/u/alpha');
});
it('Enter on create opens the create flow', async () => {
await mountOpen(['alpha', 'beta', 'gamma', 'delta']);
await key('Enter');
expect(uiStore.createWorkspaceOpen).toBe(true);
expect(mocks.tabs.open).not.toHaveBeenCalled();
});
it('Escape closes and returns focus to "+"', async () => {
await mountOpen([]);
await key('Escape');
expect(onclose).toHaveBeenCalled();
expect(document.activeElement).toBe(trigger);
});
});
describe('WorkspaceDiscovery: the pointer path', () => {
it('a click on a workspace opens it the same way', async () => {
await mountOpen([]);
mocks.tabs.open.mockResolvedValueOnce(answer(['gamma']));
document.querySelector<HTMLElement>('.discovery-option[data-ws-slug="gamma"]')!.click();
await settle();
expect(mocks.tabs.open).toHaveBeenCalledWith('gamma', false);
expect(mocks.goto).toHaveBeenCalledWith('/u/gamma');
});
});
@@ -1,13 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api } from '$lib/api/client';
import { workspaceStore } from '$lib/stores/workspace.svelte';
import { uiStore } from '$lib/stores/ui.svelte';
import { toastStore } from '$lib/stores/toast.svelte';
import BottomSheet from '$lib/components/common/BottomSheet.svelte';
import { viewport } from '$lib/stores/breakpoint.svelte';
import { workspaceRestoreTarget } from '$lib/utils/workspace-route';
import type { DeletedWorkspace } from '$lib/types';
import RecentlyDeletedWorkspaces from '$lib/components/layout/RecentlyDeletedWorkspaces.svelte';
interface Props {
/**
@@ -70,71 +68,6 @@
uiStore.openCreateWorkspace();
}
// ── Recently deleted ─────────────────────────────────────────────────
// Soft-deleted workspaces still inside the restore window (TASK-1974).
// The section only renders when the fetch returns at least one entry, so
// a quiet failure (or an empty list) leaves the switcher untouched.
let deleted = $state<DeletedWorkspace[]>([]);
// Collapsed by default; the header toggles it. Kept separate from `open`
// so the choice persists while the dropdown stays open.
let deletedExpanded = $state(false);
// Slug currently being restored — drives the per-row disabled/pending
// state so a double-click can't fire two restores.
let restoringSlug = $state<string | null>(null);
// Monotonic token guarding async listDeleted() responses against races —
// mirrors workspaceStore's membershipSeq. Each loadDeleted() call claims a
// token; a response only writes if its token is still current, so an older
// open-triggered fetch can't clobber the fresher post-restore refresh (and
// re-surface a just-restored workspace).
let deletedSeq = 0;
async function loadDeleted() {
const seq = ++deletedSeq;
try {
const list = await api.workspaces.listDeleted();
if (seq === deletedSeq) deleted = list;
} catch {
// Swallow — the restore affordance is a bonus, never a blocker for
// switching workspaces. Clear the list so a stale fetch doesn't
// linger and the section stays hidden.
if (seq === deletedSeq) deleted = [];
}
}
// Refresh the deleted list each time the switcher opens.
$effect(() => {
if (open) {
loadDeleted();
}
});
async function restore(ws: DeletedWorkspace) {
if (restoringSlug) return;
restoringSlug = ws.slug;
try {
try {
await api.workspaces.restore(ws.slug);
} catch {
// Only the restore call itself failing is a restore failure.
toastStore.show(`Couldn't restore "${ws.name}"`, 'error');
return;
}
// Restore succeeded — confirm before refreshing so a failing
// reload can't masquerade as a restore failure.
toastStore.show(`Restored "${ws.name}"`, 'success');
// Refresh both lists so the restored workspace drops out of the
// deleted section and reappears in the active list. loadDeleted()
// swallows its own errors; guard loadAll() so a reload failure
// stays silent — the restore already went through.
try {
await Promise.all([loadDeleted(), workspaceStore.loadAll()]);
} catch {
// Reload failure is non-fatal.
}
} finally {
restoringSlug = null;
}
}
</script>
{#snippet workspaceList()}
@@ -152,46 +85,6 @@
</button>
{/snippet}
{#snippet deletedSection()}
<!--
Recently-deleted workspaces still inside the restore window. Hidden
entirely when the list is empty (or the fetch failed) — no header, no
row — so it never adds noise to a switcher with nothing to restore.
-->
{#if deleted.length > 0}
<div class="deleted-section">
<button
class="item deleted-header"
onclick={() => (deletedExpanded = !deletedExpanded)}
aria-expanded={deletedExpanded}
>
<span class="deleted-caret" aria-hidden="true">{deletedExpanded ? '▾' : '▸'}</span>
<span class="deleted-title">Recently deleted</span>
<span class="deleted-count">{deleted.length}</span>
</button>
{#if deletedExpanded}
<div class="deleted-list">
{#each deleted as ws (ws.slug)}
<div class="deleted-item">
<span class="deleted-name" title={ws.name}>{ws.name}</span>
<span class="deleted-days">
{ws.days_left} {ws.days_left === 1 ? 'day' : 'days'} left
</span>
<button
class="restore-btn"
onclick={() => restore(ws)}
disabled={restoringSlug === ws.slug}
>
{restoringSlug === ws.slug ? 'Restoring…' : 'Restore'}
</button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/snippet}
<div class="switcher">
<button
class="current"
@@ -217,7 +110,7 @@
>
<div class="sheet-body">
{@render workspaceList()}
{@render deletedSection()}
<RecentlyDeletedWorkspaces active={open} roomy />
</div>
</BottomSheet>
{:else if open}
@@ -226,7 +119,7 @@
<div class="backdrop" onclick={() => open = false}></div>
<div class="dropdown">
{@render workspaceList()}
{@render deletedSection()}
<RecentlyDeletedWorkspaces active={open} />
</div>
{/if}
</div>
@@ -295,62 +188,4 @@
border-radius: var(--radius-sm);
}
/* ── Recently deleted ─────────────────────────────────────────────── */
.deleted-section { border-top: 1px solid var(--border); }
.deleted-header {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.deleted-caret { font-size: 0.9em; flex-shrink: 0; }
.deleted-title { flex: 1; min-width: 0; }
.deleted-count {
flex-shrink: 0;
padding: 0 var(--space-2);
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
font-size: 0.9em;
}
.deleted-list { display: flex; flex-direction: column; }
.deleted-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
font-size: 0.9em;
}
.deleted-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-secondary);
}
.deleted-days {
flex-shrink: 0;
color: var(--text-muted);
font-size: 0.85em;
white-space: nowrap;
}
.restore-btn {
flex-shrink: 0;
padding: var(--space-1) var(--space-2);
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--accent-blue);
cursor: pointer;
font-size: 0.85em;
}
.restore-btn:hover:not(:disabled) { background: var(--bg-hover); }
.restore-btn:disabled { opacity: 0.6; cursor: default; }
/* Inside the mobile sheet, give the deleted rows the same roomier
padding as the workspace rows above. */
.sheet-body .deleted-item { padding: var(--space-2) var(--space-3); }
</style>