Skip to content

feat(ai-create): global 'AI is generating…' bar that persists across navigation#130

Open
dantaspaulo wants to merge 4 commits into
trypostit:mainfrom
dantaspaulo:feat/global-ai-generation-bar
Open

feat(ai-create): global 'AI is generating…' bar that persists across navigation#130
dantaspaulo wants to merge 4 commits into
trypostit:mainfrom
dantaspaulo:feat/global-ai-generation-bar

Conversation

@dantaspaulo

Copy link
Copy Markdown
Contributor

Problem

AI generation runs on the backend — the StreamPostCreation job broadcasts
.ai.creation.completed on the private user.{id}.ai-creation.{creationId}
channel — but only the dedicated loading screen listened to that channel. As
soon as the user navigated away, they lost any visual sign that a generation was
still running; only the final bell notification told them.

Change

A global singleton composable, useAiGeneration, becomes the sole owner of
the Echo subscription. Its module-level state persists across Inertia
navigations (SPA) and survives a hard reload via sessionStorage — it
re-subscribes to still-running generations, with a safety timeout so the bar
never gets stuck.

A thin bar at the top of the content shows "AI is generating your post…". On
completion it turns into a clickable "Your post is ready — View post"; on failure
it becomes a dismissible error.

The loading screen no longer subscribes to Echo directly — it now observes
the composable and redirects when its generation is done. That also removes the
two-owners conflict of both the screen and the composable calling leave() on
the same channel.

How

  • useAiGeneration.ts (new) — owns the channel subscription and the shared
    state (track, hydrate, complete, per-id find).
  • AiGenerationBar.vue (new) — the bar, mounted at the top of SidebarInset in
    AppSidebarLayout.
  • Loading.vue — observes the composable instead of Echo; owns no subscription.
  • lang/{en,es,pt-BR}/posts.phpbar_* strings.

The channel name, event (.ai.creation.completed) and payload
({ post_id, error }) are unchanged — the same contract the loading screen
already used.

@paulocastellano

Copy link
Copy Markdown
Contributor

Thanks for this — the core design is really nice. Making a singleton composable the sole owner of the subscription, persisting the state across Inertia navigations, hydrating from sessionStorage after a hard reload, and the safety timeout so the bar never gets stuck — that's a solid improvement over the loading screen owning the channel.

Before we merge, though, there's one case I don't think it handles yet, and it's a realistic one: a user can have more than one generation running at the same time (start one, navigate away, start another). The composable is built for that on the loading side — generations is an array and the bar shows "AI is generating :count posts…". But the terminal (done/error) side is effectively single-slot, so the delivery of results is fragile:

  1. A finished generation stays hidden while another is still running. The bar's priority is isGenerating → doneGeneration → errorGeneration, and isGenerating is true whenever any generation is still loading. So if A finishes while B is still generating, the bar keeps showing "generating 1 post…" and never announces that A is ready until B also finishes.

  2. Worse — A's "ready" bar can be skipped entirely. complete() starts a DONE_TTL_MS (12s) timer that removes the generation. If B takes more than ~12s longer than A, A is removed before B finishes, so when B completes only B is surfaced and A's "ready → View post" bar never appears at all — the user only gets A's bell notification.

  3. Only one terminal state shows at a time, and done masks error. doneGeneration/errorGeneration return the first match, and done is checked before error in the template. With two completed generations you handle them one at a time (each click opens one and reveals the next, or each auto-dismisses after 12s), and a success can hide a failure. The "View post" CTA points to a single post too, so there's no way to see "2 posts ready" at once.

Concrete scenario: generate A → leave → generate B. A finishes, but the bar still says "generating 1 post" (B); 12s later A is auto-removed; B finishes → the bar shows only "B ready". A's completion bar never appears.

So "N generating" works, but concurrent completions don't — I don't think we should merge it in this state.

Could you think through a design for the multi-generation case before we take this further? A couple of directions to consider (your call on the approach): surface each completion as it happens even while others are still running (queue/stack them), and/or a bar that can represent "N ready" with access to each post. Happy to pair on it if useful.

@dantaspaulo

Copy link
Copy Markdown
Contributor Author

You were right on all three counts, and the second one is the sharp edge: complete() started the 12s TTL even while the strip was invisible, so in your exact scenario A was removed before it ever got a chance to be shown. Thanks for catching it before merge.

Reworked it in two parts.

The TTL only starts once the bar has actually surfaced a generation. complete() no longer assumes the terminal state is visible; the bar calls markSeen(id) when it renders one, and only then does the 12s clock start. That kills defect #2 at the root, independently of layout. Until it is surfaced, the generation's own lifetime cap applies, so nothing can get stuck if the bar never mounts.

Terminal states became collections and the strips coexist instead of competing for one slot. doneGenerations / errorGenerations replace the single-slot doneGeneration / errorGeneration, and error / done / generating can all render at once — so a completion is announced the moment it lands even with others still running (#1), and a success can no longer hide a failure (#3). With more than one completion the strip reads "N posts are ready" and each click opens the oldest, revealing the next — the per-post access you asked for, without adding a dropdown.

Tests. This is pure composable logic driven by a WebSocket event, so a browser test cannot reach it: without Reverb in CI, Echo never delivers the event. I added Vitest (+ jsdom) and unit-tested the composable with Echo mocked, capturing the per-channel listener so a test delivers a completion exactly the way the server does. Five cases, one per defect, plus oldest-first opening and bulk error dismissal. I verified the suite actually catches the bug — dropping the seenAt gate fails the TTL case.

Two things worth flagging, both your call:

  • New dev dependencies (vitest, jsdom) and a npm run test:js step wired into the quality workflow. There was no frontend test infrastructure before, and I could not cover this honestly without it. Happy to drop it and ship the fix alone if you would rather decide on that separately.
  • resources/js/routes is Wayfinder-generated and gitignored, so @/routes/* resolves to a stub under tests/js/stubs — that keeps the suite runnable with no artisan step first.

One thing I did not do: run Prettier over the touched files. The existing ones predate this PR and do not match printWidth: 80, so formatting them would have buried the actual change in noise. New files are formatted.

…navigation

AI generation runs on the backend (the StreamPostCreation job broadcasts
.ai.creation.completed on the private user.{id}.ai-creation.{creationId}
channel), but only the dedicated loading screen listened to that channel. As
soon as the user navigated away, they lost any visual sign that a generation
was still running — only the final bell notification told them.

Introduce a global singleton composable (useAiGeneration) that is the sole owner
of the Echo subscription. Its module-level state persists across Inertia
navigations (SPA) and survives a hard reload via sessionStorage (it re-subscribes
to still-running generations, with a safety timeout). A thin bar at the top of
the content shows 'AI is generating your post…'; on completion it turns into a
clickable 'Your post is ready — View post', and on failure into a dismissible
error.

The loading screen no longer subscribes to Echo directly — it observes the
composable and redirects when its generation is done, which also removes the
two-owners conflict of both calling leave() on the same channel.

- useAiGeneration.ts (new): owns the subscription + state.
- AiGenerationBar.vue (new): the bar, mounted in AppSidebarLayout.
- Loading.vue: observes the composable instead of Echo directly.
- lang/{en,es,pt-BR}/posts.php: bar_* strings.
Review feedback: the bar handled N concurrent generations on the loading
side, but the terminal (done/error) side was effectively single-slot, so
concurrent completions were delivered unreliably.

Three concrete defects followed from that:

1. A finished generation stayed hidden while another was still running,
   because `isGenerating` took priority over `doneGeneration`.
2. Worse, the 12s TTL ran while hidden. If the second generation took
   longer than that, the first was removed without EVER being shown — the
   user only learned about it from the bell notification.
3. A success masked a failure, since both read the first match and `done`
   was checked before `error` in the template.

The fix has two parts:

- The TTL only starts once the bar has actually surfaced the generation
  (`markSeen`), so nothing is discarded unseen. Until surfaced, the
  generation's own lifetime cap applies, so nothing gets stuck either.
- Terminal states become COLLECTIONS and the strips coexist instead of
  competing for one slot: error, done and "generating" can all show at
  once. With more than one done, the strip reads "N posts are ready" and
  each click opens the oldest, revealing the next.
The overlapping-generations bug is pure composable logic driven by a
WebSocket event, so a browser test cannot reach it — without Reverb in CI,
Echo never delivers the event. Vitest runs the unit in milliseconds with
Echo mocked, capturing the per-channel listener so a test can deliver a
completion exactly the way the server does.

Five cases, one per defect fixed: a completion surfaces while another is
still running; an unseen completion outlives the done TTL; a failure is not
masked by a success; clicks open oldest-first; errors dismiss together.

Verified the suite catches the bug: dropping the `seenAt` gate fails the
TTL case.

`resources/js/routes` is generated by Wayfinder and gitignored, so
`@/routes/*` resolves to a stub — the suite runs with no artisan step.

New dev dependencies: vitest and jsdom. `npm run test:js` runs the suite,
wired into the quality workflow.
The parity test requires every locale to carry the same keys as `en`, and
the bar's `bar_*` strings only existed in en/es/pt-BR. Adds them to the
twelve locales that landed since this branch was cut, following each
file's own wording for "post" and its formal/informal register.
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