feat(frontend): say when a workspace holds only archived items

A workspace whose items are all archived read as empty, because the
placeholder is decided by the default listing. Reaching those items then
depended on the toolbar, which is why it had been left interactive while
dimmed — and that let a kind toggle replace the invitation with "no
items found" on a workspace that really was empty.

The state is named instead. When the default listing comes back empty,
one request asks whether anything archived exists, and the placeholder
says which of the two it is: "Everything in this workspace is archived"
with a link to show them, or the ordinary invitation. Held until that
answer lands rather than drawn and swapped, since the wrong one claims
the workspace is empty when it is not.

The toolbar is dimmed and `inert` again, its original design: the
archived case now carries its own way in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9
This commit is contained in:
Guilhem Lemouel
2026-09-07 18:06:41 +02:00
co-authored by Claude Opus 5
parent 2daa9ce73b
commit f802962be2
2 changed files with 61 additions and 11 deletions
@@ -1008,6 +1008,30 @@
let hubPick = $state<HubProjectPick | undefined>(undefined)
let hubPickerOpen = $state(false)
/**
* Whether a workspace the default listing found empty is empty at all, or just has nothing
* unarchived — two different states that want two different things said about them. Asked
* only in that case, and once per workspace: one request for one row, never on a workspace
* with something in it. A failure answers "no archived items", which shows the ordinary
* placeholder rather than promising items that may not be there.
*/
let archivedProbe = $state<{ workspace: string; hasArchived: boolean } | undefined>(undefined)
$effect(() => {
const ws = $workspaceStore
if (!ws || !workspaceEmpty || archivedProbe?.workspace === ws) return
untrack(() => void probeArchived(ws))
})
async function probeArchived(workspace: string) {
try {
const res = await ScriptService.listRunnables({ workspace, showArchived: true, perPage: 1 })
archivedProbe = { workspace, hasArchived: (res.items?.length ?? 0) > 0 }
} catch (error) {
console.error('Could not check for archived items:', error)
archivedProbe = { workspace, hasArchived: false }
}
}
let emptyStateAnswered = $derived(archivedProbe?.workspace === $workspaceStore)
// The workspace itself holds nothing — no filter is narrowing the list away. It stays
// false until the first load resolves: a skeleton already means "loading", and the
// empty state must not be mistaken for one. The controls it dims stay mounted, so
@@ -1689,11 +1713,10 @@
>
{#if !contentActive}
<!-- Kept mounted, not hidden, so the toolbar doesn't reflow the moment the first item
lands. Dimmed but still usable: "empty" here means the default listing found
nothing, and a workspace whose items are all archived looks exactly the same —
these controls are how it says so, and taking them off the pointer would leave
those items unreachable. -->
<div class="flex justify-start" class:opacity-40={workspaceEmpty}>
lands; `inert` takes it out of the tab order and off the pointer meanwhile. A
workspace with nothing but archived items reaches them from its own placeholder,
so these controls are not the way there. -->
<div class="flex justify-start" class:opacity-40={workspaceEmpty} inert={workspaceEmpty}>
<ToggleButtonGroup
selected={itemKind}
onSelected={(v) => {
@@ -1792,11 +1815,10 @@
{/if}
<div class="flex grow items-center justify-end gap-2 min-w-0">
<!-- Dimmed, never inert: "Only archived" lives in here, and it is the one thing a
workspace with nothing but archived items still needs. -->
<div
class="relative text-primary w-full min-w-[200px] max-w-[26rem]"
class:opacity-40={workspaceEmpty}
inert={workspaceEmpty}
>
<FilterSearchbar
schema={searchbarSchema}
@@ -1877,7 +1899,16 @@
workspace whose direct-deploy protection cleared `showEditButtons`, gets the plain
message instead of two actions it may not take. -->
{#if workspaceEmpty && !$userStore?.operator && showEditButtons}
<WorkspaceEmptyState onPick={(project) => (hubPick = project)} />
<!-- Held until the archived probe answers rather than drawn and swapped: the two
placeholders say different things, and showing the wrong one first says the
workspace is empty when it is not. -->
{#if emptyStateAnswered}
<WorkspaceEmptyState
archivedOnly={archivedProbe?.hasArchived === true}
onPick={(project) => (hubPick = project)}
onShowArchived={() => (filterValues.val = { ...filterValues.val, archived: true })}
/>
{/if}
{:else}
<NoItemFound {activeFilters} />
{/if}
@@ -10,9 +10,16 @@
interface Props {
/** A project was chosen here. The list owns the import dialog, and opens it on this. */
onPick: (project: HubProjectPick) => void
/**
* The workspace holds items, all of them archived. It is not empty and must not be
* told it is: the caption names what is there and offers the way to it instead.
*/
archivedOnly?: boolean
/** Switches the list to the archived view. */
onShowArchived: () => void
}
let { onPick }: Props = $props()
let { onPick, archivedOnly = false, onShowArchived }: Props = $props()
// Row opacities: the list fading out of existence. Static on purpose — motion is what
// makes a skeleton mean "loading", and this state means "empty".
@@ -38,7 +45,7 @@
<div
class="rounded-md border-[1.5px] border-dashed border-border-normal/60 bg-surface"
role="status"
aria-label="Your workspace is empty"
aria-label={archivedOnly ? 'Everything here is archived' : 'Your workspace is empty'}
>
{#each rowOpacities as opacity, i (i)}
<div
@@ -61,7 +68,19 @@
<div
class="border-t border-dashed border-border-light px-4 pb-[22px] pt-[18px] text-center text-[13.5px] leading-relaxed text-hint"
>
Your scripts, flows and apps will show up here.
{#if archivedOnly}
<!-- Its own line: the state and the invitation are two sentences, and splicing them
into one leaves a link doing the work of a conjunction. -->
<span class="block">
Everything in this workspace is archived.
<button
class="border-b border-transparent text-accent hover:border-accent"
onclick={onShowArchived}>Show archived items</button
>.
</span>
{:else}
Your scripts, flows and apps will show up here.
{/if}
<!-- The hub half goes when the instance has the hub turned off, and the remaining link
opens the sentence instead of continuing it. -->
{#if !$disableHubStore}