Skip to content

feat(web): swap bare Loading… text for skeleton placeholders#786

Merged
buremba merged 9 commits into
mainfrom
feat/web-loading-skeletons
May 17, 2026
Merged

feat(web): swap bare Loading… text for skeleton placeholders#786
buremba merged 9 commits into
mainfrom
feat/web-loading-skeletons

Conversation

@buremba
Copy link
Copy Markdown
Member

@buremba buremba commented May 17, 2026

Summary

Two passes of cleanup on the perceived-loading UX:

Pass 1 — Skeleton placeholders (replace bare `Loading…`)

DESIGN_GUIDELINES §5 calls for skeleton placeholders on content panels and reserves bare `Loading…` text for sub-1s auth handoffs and tiny inline regions. The codebase had drifted: 9 page-level panels showed text-only loaders; oauth-apps stacked two unsynced queries that flashed `Loading…` twice in sequence before content appeared.

  • Add `` shadcn primitive (`components/ui/skeleton.tsx`).
  • Rewrite `TabLoadingState` to render shimmer placeholders (`rows` / `cards` / `panel` variants) instead of spinner + visible label. Label moves to `aria-label` so screen readers still announce it.
  • Swap bare loaders on entity-list, clients (page + connect-card Suspense), events, members, oauth-apps, connector index, connection detail, entity-types/$slug, agents-workbench (page + watcher Suspense).
  • oauth-apps now renders page chrome before resolving members, so the heading is visible while the skeleton settles instead of blanking the whole page twice.

Pass 2 — Route loaders + unified pending skeleton

The deeper fix: route components were mounting first and then firing their primary `useQuery`, so users watched the page paint loading state. With `defaultPreload: 'intent'` already on, the missing piece was a loader to prefetch into the cache.

  • Hook factories (`createOrgQuery` / `createQuery`) now expose a `.queryOptions(...)` builder alongside the hook. Same key/fn, no drift between component and loader. `useResolvedPath` / `useWorkspaceRoot` / `useAgents` (direct `useQuery` hooks) get the same companion.
  • Router sets `defaultPendingComponent` (`TabLoadingState`) + `defaultPendingMs: 150` + `defaultPendingMinMs: 300`. Cached navigation is silent; only true cold loads see the skeleton (and once it shows, it stays at least 300ms — no flash).
  • 7 routes wire a `loader` that `ensureQueryData`s their primary data:
    • `/$owner/$entityType` workspace + entity schema + first page of rows
    • `/$owner/agents` workspace + agents
    • `/$owner/clients` workspace
    • `/$owner/connectors/$connectorKey` workspace + connector definitions
    • `/$owner/events` workspace + entity types + connections
    • `/$owner/members` workspace
    • `/$owner/oauth-apps` workspace + auth profiles + connector defs
  • Per-page `useQuery` / `isLoading` branches stay in place: they cover the refetch / invalidation case. The route pending UI is what users see during navigation; the per-page skeleton is what they see during refetch.

Auth-handoff loaders kept text-only

`/`, `owner-resolver`, `watchers/$watcherId` redirect, member-picker dropdown, events-tab count badge — all sub-1s or tiny inline per §5.

Also bundles small pre-existing local WIP from `packages/web` (connector sidebar filter, `OrgRootRedirect` synchronous ``, sidebar status-dot tidy, `$connectorKey.tsx` layout simplification).

Submodule: lobu-ai/owletto-web feat/loading-skeletons (commit 2237b6e).

Test plan

  • `bunx tsc --noEmit` in `packages/web` — clean
  • `bun run build` in `packages/web` — clean
  • Boot dev (`make dev`) and verify: hover a sidebar link, click — destination renders without a skeleton (cache warm from intent preload)
  • Throttle network to 3G, navigate cold — skeleton appears after ~150ms and persists ≥300ms (no flash)
  • Spot-check oauth-apps: page chrome appears immediately, body skeleton fills, single transition to content

Summary by CodeRabbit

  • Chores

    • Updated the web package reference to a newer snapshot.
  • Bug Fixes

    • Added a one-shot data-cleanup migration to archive orphaned watcher records.
    • Enforced that creating watchers requires an owning agent to prevent invalid entries.
  • New Features

    • List responses now include resolved linked-entity metadata for richer UI display.
  • Refactor

    • Improved feed listing for more efficient event counting and pagination.

Review Change Stack

Bump packages/web to feat/loading-skeletons (210afd3). See the
submodule commit for full detail.

DESIGN_GUIDELINES §5 calls for skeleton placeholders on content
panels; the codebase had drifted to 9 page-level panels showing bare
"Loading…" text and a few pages flashing it twice in sequence from
unsynced queries. This swap adds a <Skeleton> primitive, upgrades
TabLoadingState to render shimmer rows / cards / panels, and rewires
every page-level violation. Also includes a small connector-sidebar
filter tweak and the OrgRootRedirect synchronous-Navigate fix that
were sitting in the web working tree.
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 17, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Server admin tooling: list now resolves x-link-entity-type columns and batches lookups in parallel; feeds listing uses a paged CTE with grouped event counts; watcher creation requires agent_id. Adds a one-shot DB migration to archive orphan watchers, updates schema migrations, and bumps packages/web submodule.

Changes

Admin & DB changes

Layer / File(s) Summary
Entity listing: linked-column resolution and parallel batching
packages/server/src/tools/admin/manage_entity.ts
Adds linked_entities to list responses, runs relationship and linked-column lookups in parallel, and implements resolveLinkedColumns to batch metadata-based lookups into {slug, entity_type, name} mappings.
Feeds listing: CTE-based event counts
packages/server/src/tools/admin/manage_feeds.ts
Rewrites handleListFeeds to materialize a paged CTE, compute event_counts in a single grouped scan restricted to the page, and left-join counts back with COALESCE(0).
Watcher create validation
packages/server/src/tools/admin/manage_watchers.ts
handleCreate now requires agent_id and throws a ToolUserError when missing; minor spacing-only edit in update.
One-shot DB migration + schema update
db/migrations/20260517040000_archive_orphan_watchers.sql, db/schema.sql
Adds migration that archives active watchers with agent_id IS NULL (no-op down) and appends 20260517040000 to public.schema_migrations seed.

Submodule Update

Layer / File(s) Summary
Web submodule pointer update
packages/web
The packages/web git submodule reference is updated to a new commit hash.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • lobu-ai/lobu#726: Also updates the packages/web git submodule pointer to a different commit.
  • lobu-ai/lobu#756: Touches manage_watchers creation/lifecycle paths and may overlap with watcher creation validation changes.
  • lobu-ai/lobu#759: Another PR that includes a packages/web submodule pointer bump.

Suggested reviewers

  • codex-approver

Poem

🐰 I hopped through code with nimble feet,
Batched lookups hummed — so tidy, neat.
Feeds paged and counted in a single sweep,
Orphan watchers tucked to rest so deep.
A submodule bump — I nibbled and leapt, hooray!

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (2 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title describes skeleton placeholders for Loading text, but the PR includes significant scope beyond the web package: database migrations, server-side tool changes, and schema updates. Revise the title to reflect the full scope, such as: 'feat: replace loading text with skeletons and optimize server queries' or split into separate PRs by domain.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description covers the web changes comprehensively but lacks details on server-side changes (migrations, manage_entity, manage_feeds, manage_watchers). The test plan is incomplete with unchecked manual tests. Expand the summary to document the server-side migration and tool changes, and complete the test plan checkboxes including the pending manual verification steps.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/web-loading-skeletons

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/web`:
- Line 1: The packages/web pointer is currently set to commit
210afd38b4342b70f4c259bd47f618906820878c which CI reports is not an ancestor of
owletto-web/main; update the pin so it references a SHA that is reachable from
origin/main (or first merge commit 210afd38... into owletto-web/main and then
update the pointer). Locate where packages/web is pinned (the commit SHA string
210afd38b4342b70f4c259bd47f618906820878c), replace it with a commit SHA that is
an ancestor of origin/main, or perform the merge and then change the pin to the
resulting reachable SHA so the CI hard drift check passes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 35e4bfb6-b433-4604-a811-335a4d61750b

📥 Commits

Reviewing files that changed from the base of the PR and between 33e6c04 and ce2d66e.

📒 Files selected for processing (1)
  • packages/web

Comment thread packages/web Outdated
@codecov-commenter
Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

buremba added 4 commits May 17, 2026 03:16
…eton

Bump packages/web to 2237b6e — see the submodule commit for full
detail. Wires TanStack Router loaders + a router-level pending
skeleton so navigation lands on warm cache and only true cold loads
flash the placeholder.

- Hook factories expose queryOptions(...) so loaders can call
  ensureQueryData without duplicating queryKey/queryFn.
- 7 routes (entity-list, agents, clients, connector index, events,
  members, oauth-apps) prefetch their primary data; defaultPreload
  'intent' warms them on link hover.
- Router gets defaultPendingComponent (TabLoadingState) and
  defaultPendingMs:150 — cached navigation feels instant; only true
  cold loads see the skeleton.
Bump packages/web to 52d4463. See submodule commit for detail.

- 4 more routes get loaders: agent detail, connection detail, device
  detail, watcher redirect (via beforeLoad).
- useDevices + useMetricSeries get queryOptions companions so the
  agents-index / events-index loaders can prefetch sparkline data
  alongside primary list data — StatsStrip no longer paints in two
  waves.
- Device detail's "Loading device…" / FeedsSidebar "Loading…" are now
  skeletons.
- Watcher redirect moves from useEffect+navigate to throw redirect()
  in beforeLoad, so the old route never mounts and the redirect
  becomes a single navigation.
Bump packages/web to f3a7355 — drops the redirect-only watcher route,
the unused feed-expanded-row.tsx (450 LOC), useWorkspaceBootstrap, and
useDeleteNotification. OwnerTabPage loading block now uses the shared
<Skeleton> primitive.
…er orphan archive

Bundled with the loading-skeletons PR per scope discussion. Three
backend tweaks the FE pass leaned on or surfaced, plus a one-shot data
cleanup.

manage_entity.list — server-side linked-column resolution
- New `linked_entities` field on the list response: keyed by
  `${entityType}:${lookupField}` then by the raw metadata value. The FE
  used to fan out one manage_entity.list per `x-link-entity-type`
  column (~2.5s × 4 columns on the Company page); the loader now does
  it inline in one parallel Promise.all alongside relationship batches.

manage_feeds.list — collapse correlated event_count subquery
- The previous shape ran
  `SELECT COUNT(*) FROM current_event_records WHERE connection_id=…
  AND feed_key=…` per feed row. On busy connections that was ~880ms
  per row × N feeds — a multi-second feed list.
- Now uses a MATERIALIZED page CTE + a single GROUP BY over
  `(connection_id, feed_key)`, scoped by
  `connection_id = ANY(ARRAY(SELECT DISTINCT connection_id FROM page))`
  to keep the planner on one index scan instead of repeating it per
  pair.

manage_watchers.create — reject zombie watchers
- The scheduler at `packages/server/src/watchers/automation.ts:469`
  filters with `WHERE w.agent_id IS NOT NULL`, so a watcher without an
  agent never runs. The argument schema marks `agent_id` optional
  (shared across create/update/delete actions) but create now throws a
  `ToolUserError` when it's missing.

Migration 20260517040000_archive_orphan_watchers
- One-shot data cleanup: flips `status='active' AND agent_id IS NULL`
  rows to `archived`. Prod audit 2026-05-17 found 28 such rows across
  11 orgs — all originated from older create paths before agent_id was
  wired in. Migration is up-only (no-op down); the create-guard above
  prevents the orphan set from regrowing.

Submodule (packages/web): paired refactor of the entity-type table to
consume `linked_entities` instead of fanning out useQueries lookups.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/server/src/tools/admin/manage_watchers.ts`:
- Around line 931-940: The create-only guard for agent_id leaves a gap: the
create_from_version path can still insert active watchers with agent_id = NULL;
update the create_from_version logic in manage_watchers.ts (the
create_from_version function / code path) to enforce the same invariant as the
create branch by validating args.agent_id and throwing a ToolUserError when it's
missing, or ensure the inserted watcher rows explicitly set a non-null agent_id
(mirror the check/throw used where create enforces agent_id) so no active
watcher can be created without an owning agent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b580e8ab-c21b-46f4-a124-6c36f358f67e

📥 Commits

Reviewing files that changed from the base of the PR and between 929c6f0 and 2aed413.

📒 Files selected for processing (6)
  • db/migrations/20260517040000_archive_orphan_watchers.sql
  • db/schema.sql
  • packages/server/src/tools/admin/manage_entity.ts
  • packages/server/src/tools/admin/manage_feeds.ts
  • packages/server/src/tools/admin/manage_watchers.ts
  • packages/web
✅ Files skipped from review due to trivial changes (1)
  • packages/web

Comment thread packages/server/src/tools/admin/manage_watchers.ts
buremba added 2 commits May 17, 2026 03:49
Bump packages/web to 3b76016. ~30 unused exports either unexported
(when only used in-file) or deleted (dead). Shadcn primitive re-exports
left intact as surface area.
Bump packages/web to 9eafd4e. Fixes:
1. Entity-list loader was prefetching the wrong cache entry — component
   passed full useOrgContext() with organizationId, loader passed only
   { slug }. Loader now chains workspace fetch and mirrors component
   list options.
2. Restore /$owner/watchers/$watcherId — without it, old deep links
   silently land on /connectors. Brought back as a beforeLoad redirect.
3. Wrap chained workspace fetch in try/catch; switch secondary
   prefetches to prefetchQuery so component error states still drive
   the page UX instead of the global route-error.
4. StatusDot tone=null no longer returns null (caused layout shift in
   sidebar rows mixing healthy/unhealthy items) — transparent
   placeholder of the same size, aria-hidden.
@buremba
Copy link
Copy Markdown
Member Author

buremba commented May 17, 2026

pi review applied

Ran `pi -p` on the branch. Findings + resolutions:

Applied:

  1. Entity-list loader queryKey mismatch. The loader passed only `{ slug }` to `useEntityType/useEntitiesByType.queryOptions`, but the page calls those hooks with `useOrgContext()` (which includes `organizationId` for signed-in users). Different queryKey → cache miss → page still painted loading on first navigation. Loader now chains the workspace fetch, reuses the resolved org id, and mirrors the component's default list options (`limit: 50, sortBy: 'created_at', sortOrder: 'desc'`).

  2. /owner/watchers/ back-compat regression. Deleting the route caused those URLs to fall through to `OwnerResolver` and silently `` to `/connectors` (worse than 404). Brought the file back as a `beforeLoad`-only redirect to the agent watcher tab, with fallback to `/agents` on resolution failure. Pure redirect — never mounts.

  3. Loader-thrown errors replacing component error UX. `ensureQueryData` failures (401, 500) were hitting the global `RouteErrorComponent` and overriding the page's existing not-found / sign-in-redirect UI. Workspace fetch now uses `fetchQuery` inside try/catch, secondary prefetches use `prefetchQuery` (silent on error). Failed loads fall through to the component's own error branches.

  4. StatusDot layout shift on healthy state. `StatusDot` returned `null` when `tone === null`, causing the leading element in sidebar rows to shift horizontally when an item flipped between healthy and unhealthy. Now renders a transparent placeholder of the same size with `aria-hidden`, preserving alignment.

Skipped: pi also flagged a server-side concern in `packages/server/src/tools/admin/manage_watchers.ts` — `agent_id` is guarded on create but not on update, so the "active orphan watcher" state could be re-created post-migration. That's outside this PR's scope and worth a focused follow-up.

Typecheck + build clean on the head commit.

pi flagged that handleCreate in manage_watchers rejected agent_id=null
but handleUpdate didn't — so the active-orphan-watcher state could be
reproduced after the archive migration. Two layers of defense now:

- DB: new migration 20260517050000 deletes the residual agent_id IS NULL
  rows and sets `watchers.agent_id NOT NULL`. The existing partial
  index (predicate `WHERE agent_id IS NOT NULL`) becomes a tautology
  once the column is NOT NULL, so it's replaced with an unconditional
  index.
- App: handleUpdate now rejects explicit nulling and rejects scheduling
  a watcher with no owning agent — fails earlier with a clear error
  message instead of letting the constraint violation surface raw.

Also bundles a small manage_entity tweak to route a text-array bind
through the new pgTextArray helper (consistent with the rest of the
linked-columns resolver).
@buremba
Copy link
Copy Markdown
Member Author

buremba commented May 17, 2026

Pulled in the server-side fix pi flagged too:

  • New migration `20260517050000_watcher_agent_id_not_null.sql` deletes residual orphan rows and sets `watchers.agent_id NOT NULL`. The pre-existing partial index (`WHERE agent_id IS NOT NULL`) becomes a tautology and is replaced with an unconditional one.
  • `handleUpdate` in `manage_watchers.ts` now rejects `agent_id: null` and rejects scheduling a watcher with no owning agent — fails fast with a clear error message instead of letting the raw constraint violation surface.

DB + app guards layered: belt and suspenders.

@buremba buremba enabled auto-merge (squash) May 17, 2026 03:08
@buremba buremba merged commit 592d497 into main May 17, 2026
14 of 18 checks passed
@buremba buremba deleted the feat/web-loading-skeletons branch May 17, 2026 03:09
buremba added a commit that referenced this pull request May 17, 2026
…ump (#804)

* fix: schema.sql drift + manage_feeds feed_key narrowing + submodule bump

Three follow-ups dropped from PR #786 during the squash-merge:

- db/schema.sql: insert the COLUMN COMMENT on
  personal_access_tokens.worker_id and move
  idx_personal_access_tokens_worker_id into its alphabetical spot so
  the file matches `dbmate up` output. The migrations CI job has been
  failing on this drift since 20260517030000_pat_worker_id_binding was
  added.
- manage_feeds.list_feeds: narrow the event-counts scan by feed_key as
  well as connection_id (ANY-array on both axes); the LEFT JOIN at the
  end drops any over-count from the cross product. Cuts the 50-feed
  list on a busy connection from ~1.5s to ~670ms in prod.
- Bump packages/web to ca12cd2 (owletto/main after PR #141), so the
  parent points at a submodule SHA reachable from owletto/main rather
  than at the now-orphaned pre-squash 2d2f5bf.

* fix(schema): drop stray blank line so file matches dbmate up output

* chore(submodule): bump web for useFeeds prefetch gate (pi review of #803)

* test(watchers): pass agent_id on watchers.create across integration tests
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants