Skip to content

Reload flashes: Tasks empty state, plugin route chrome, composer model and reasoning #1676

Description

@lnittman

1. Tasks paints list chrome and skeleton rows before it knows there are no projects

Every visit to Tasks (reload or navigation) mounts with the list layout and skeleton rows, then swaps to the "No projects yet" empty state ~450ms later once listProjects resolves. Only a resolved empty list selects the empty state, so "projects unknown" renders as the populated layout, and the plugin's query primitive keeps nothing between mounts, so each visit starts from unknown. Populated instances get the same skeleton-then-rows flash on every visit. Switching between the sidebar views has the same shape once loaded: returning from an empty Active to All tasks paints Active's emptiness as "No tasks yet" for the length of the refetch, because the list keeps the previous view's result while the new one loads. Expected: a repeat visit paints the last resolved state (empty or populated) immediately and only changes if the fresh answer differs, and no view ever presents another view's rows or emptiness as its own.

Prototype: an opt-in persisted snapshot in useTasksQuery (localStorage, versioned key, validated against the query's own RPC output schema on read, written only after a successful fetch) for folders, projects, and the sidebar summary, seeding the query state on mount; the sidebar skeleton shows only while that data is absent, so a snapshot-hydrated mount never shows it and a refetch keeps the last-known rows on screen. The empty state still waits for a resolved empty list, and the list body treats a changed view (All, Active, a project) as loading until that view's own fetch settles, while filter and sort changes keep painting the rows they already have.

2. Reloading a plugin page shows "This plugin panel is not available. The plugin may still be loading, or it has been disabled or removed."

Registrations arrive only after plugin frontends boot, so on a reload or deep link PluginPanelView cannot find the panel yet and renders the not-available message for a few hundred milliseconds on every load, then the real page mounts. Expected: a valid deep link stays quiet while registration is pending and shows the unavailable state only once boot has confirmed there is no such panel.

Prototype: track whether plugin frontends have settled (initial boot resolved or failed, with a 15s fallback if system config never resolves); the route renders an empty shell until then, and only a settled boot with no matching panel shows the message.

3. The header title and the sidebar's plugin rows paint empty, then pop in

The app header's plugin title + icon, the sidebar's Automations/Tasks rows, and split-pane titles for plugin panels are all derived from the live registrations, so they are blank at first paint and appear when frontends boot. Expected: host-owned plugin chrome is stable from the first paint of a reload.

Prototype: remember the host-owned chrome of the panels this profile last saw (plugin id, panel id, path, title, icon, never the component) and paint it first; live registrations replace it under the same keys so a matching plugin reconciles in place without a remount. After settle only live registrations remain, and the memory is rewritten on every change so a removed plugin does not come back as a ghost. One hook feeds the header, the sidebar rows, and the split-pane header.

4. "Loading models..." for seconds on every full load

The composer's model control shows "Loading models..." until GET /system/execution-options returns, and each of those calls takes seconds on my instance (the server logs them as slow requests, 2-5s). The localStorage placeholder catalog that hides this wait is wired for Claude Code only, so every other provider waits on the network on every app open, reload, desktop cold start, and first visit of each environment/provider key. Expected: a provider whose catalog was verified before paints it immediately, kept provisional until the live request settles.

Prototype: generalize the catalog cache to key by environment, host, and provider; write it for any provider's verified catalog and serve it as placeholderData for every provider, with a provider-level "latest" entry as the fallback for a routing that was never fetched to completion (a thread composer can mount before its environment is known). The host's provider list is remembered per routing and replayed alongside, so a custom or installed ACP provider paints as itself; when the remembered provider is in no list the placeholder can vouch for, the composer waits as before. Claude Code keeps its curated fallback on a cold cache; other providers still wait when nothing was recorded. Consumers already treat placeholder rows as provisional (recovery gated on isPlaceholderData, ceiling ignored), so nothing downstream changes.

5. In a thread, reasoning shows the model default and then snaps to the thread's setting

The thread composer's model, reasoning, service tier, and permission mode come from the thread default-execution-options query. Until it resolves the hook falls back to neutral defaults, so a full load shows e.g. reasoning "Medium" and then snaps to "Extra High" once the query lands. Expected: the last server-resolved settings for that thread paint immediately, without enabling submission until the live resolution arrives.

Prototype: remember each thread's last server-resolved options (validated against resolvedThreadExecutionOptionsSchema) and replay them as placeholderData. Display uses the replay; submission and the permission controls keep waiting for the verified resolution (isPlaceholderData counts as loading in both the thread prompt area and the embedded chat).

6. The sidebar paints a two-row loading skeleton on every full load

The sidebar-bootstrap query starts from an empty cache on every full load, so the rail shows its two-row navigation skeleton (and every surface that reads project names from the shared cache starts blank) for a few hundred milliseconds until the response lands. Expected: a profile that has seen this rail before paints it immediately and revalidates live.

Prototype: keep the last bootstrap this profile received in the same last-known cache (validated against the wire schema on read, one entry per origin, written only from a successful fetch) and replay it as placeholderData; the live response replaces it in place. Rows are navigation, so a stale row degrades to an in-page load failure at worst; a cold profile has nothing to replay and keeps today's skeleton and unavailable state.

Notes

One theme, two prototypes, so each can land on its own. Item 1 is plugin code only (plugins/tasks, no SDK or host changes; isLoading semantics untouched because the list view's scroll restoration depends on its flip-on-refetch behavior; snapshots from older storage versions pruned once per load). Items 2 to 6 are host code only (apps/app); no plugin API surface is added or changed, and no server, daemon, or contract wire files change, so no HOST_DAEMON_PROTOCOL_VERSION bump. Items 3 to 6 sit on a small shared last-known localStorage cache primitive (scoped versioned keys, zod-validated reads, reads and writes that never throw so a restricted or full store degrades to "no cache" rather than rejecting a successful fetch or taking a render down, stale versions pruned). The composer placeholder's permission ceiling is the most restrictive mode so provisional data fails safe even for a future ungated reader. No new deps.

Measured on my dev instance with an 8ms DOM probe: Tasks chrome+skeleton at ~17ms then empty at ~450ms became empty state at first paint (~80-150ms after navigation); header title at 78ms and plugin rows/body at 139ms after navigation became first paint; root composer 6.9s cold to 115-159ms warm; thread composer paints "5.6-Sol Extra High" as soon as the thread record resolves (~200ms on a cold load, during which the provider is not yet known) instead of "Loading models..." for seconds or "Medium" then a snap; the sidebar rail's ~250-450ms skeleton became first-paint rows (with the fetch blocked entirely, the remembered rail still paints).

Prototype (item 1): lnittman/bb @ bb/tasks-first-paint (main...lnittman:bb/tasks-first-paint)
Prototype (items 2 to 5): lnittman/bb @ bb/reload-flashes-host (main...lnittman:bb/reload-flashes-host)

Before

tasks-before

plugin-route-before

composer-before

thread-composer-before

sidebar-before

After

tasks-after

plugin-route-after

composer-after

thread-composer-after

sidebar-after

AGENT GENERATED: by Claude Fable 5

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    confirmed-reproBug reproduced again from a clean trusted checkout; see linked reporttasksBuilt-in plugin: tasksuiApp shell, sidebar, composer, rendering

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions