Skip to content

feat(frontend): interaction polish A - toasts, chat delete with Undo, approval expiry, memory panel, new-chat guide, drafts - #318

Merged
dovvnloading merged 7 commits into
mainfrom
feat/frontend-ux-a
Sep 29, 2026
Merged

dovvnloading merged 7 commits into
mainfrom
feat/frontend-ux-a

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Problem

Six interaction rough edges in the frontend, each verified against the code before changing it:

  • Toasts (FE-21). Error toasts vanished after 4.5 s and could not be dismissed, yet were sometimes the only place a failure reason appeared: a failed GGUF download told the form to "see the notification" after the notification was gone.
  • Deleting a chat (FE-24). It required typing the chat's exact title, and once confirmed there was no way back.
  • Approval expiry (FE-29). A code task waiting for approval showed no expiry, so a person who came back after the window closed hit a failing "Allow once" with no explanation, and an expired approval read as "Cancelled".
  • Memory panel (FE-30). "Clear all" used window.confirm (which blocks the JavaScript thread; ChatPage already avoids it), and edits or removals gave no sign they were only local until "Save changes", which was always enabled.
  • New chat and startup (FE-31). A new thread was an empty canvas, and the composer said "The selected local model is unavailable" on launches where nothing was wrong, because the inventory was seeded with an errored placeholder until /models answered.
  • Composer drafts (FE-32). Draft text lived only in sessionStorage. See Limits: this one is only partly achievable from the frontend.

Root cause

  • FE-21: useUiStore armed an unconditional 4.5 s timer for every kind of toast and nothing rendered dismissToast; runModelJob showed the specific reason only as a toast and then threw a generic error.
  • FE-24: the dialog gated its button on a typed confirmation, and the delete request was sent immediately.
  • FE-29: the expiry text existed only in the non-code branch of the tray; the code summary rendered a fixed "Approval needed" and an expired approval fell through to the generic status label (an expired approval ends as a cancelled task).
  • FE-30: MemoryPanel kept edits in a local draft with no dirty state, and confirmed with a native dialog.
  • FE-31: loadWorkspace called setModels(UNAVAILABLE_MODELS) before the request, so "not answered yet" and "failed" were the same state.
  • FE-32: composerDraft used sessionStorage only.

Change

One commit per item, each verified on its own (typecheck, lint and the tests it touches).

  • FE-21 useUiStore, ToastProvider, App, ModelsPanel. Errors stay until dismissed and have a Dismiss button. Success and info toasts still expire and pause while hovered or focused. Errors are role="alert", the rest role="status"; the region is no longer a live region itself (keeping aria-live on it as well would announce each toast twice). An identical visible error is not stacked, and the stack is capped at five. A toast can carry an action and an onClose that runs when it goes away unused. runModelJob passes the specific reason to onFailure; the download form renders it inline.
  • FE-24 AppShell, App. The dialog is a plain alert dialog naming the chat. Confirming adds the chat to a pending set that visibleChats filters out of the sidebar, the command palette and the fallback choice, then shows "Chat deleted" with Undo for six seconds. The DELETE is sent when that toast goes away unused, so it does not depend on a separate timer. Undo restores the chat exactly (nothing was removed from the store) and navigates back to it only if the person is still where the delete left them. A failed delete un-hides the chat and shows a persistent error. This is a client-side delay; there is no server change.
  • FE-29 ExecutionTaskTray. A pending code approval shows "expires in N min" (or "under a minute", or "expiring now"), recomputed on a 30 s tick and once more at the deadline by a small component that owns its clock. An expired approval shows an Expired chip and a note that the window closed and the task did not run, no actions, and no source review (nothing is left to decide). The non-code branch says "Expired" instead of "Cancelled" and the tray announcement says so. The backend stays the authority: an approval that ran out locally but is not yet marked expired keeps its buttons and the server refuses them.
  • FE-30 MemoryPanel. Clear all uses the shared alert dialog. Removing a row strikes it through with an Undo until saved and leaves it out of the save. Save is disabled while clean and "Unsaved changes" shows while dirty; whitespace the server trims does not count as a change. The e2e spec drives the dialog.
  • FE-31 runtimeAvailability, App, AppShell, ChatPage, new NewChatGuide. The inventory starts as null in the store; resolveRuntimeAvailability has an inventory-loading state ("Checking local models…", still asking for a model when none is selected); the header connection dot is neutral until there is a verdict; a failed /models still ends as unavailable. A new thread shows the model that will answer (or that none is selected), three keyboard hints and a line about attachments, and disappears when the first message starts.
  • FE-32 composerDraft, App, test setup. Draft text is also written to localStorage under the same keys and read back when the session copy is gone; every storage access is wrapped. Drafts over 20,000 characters are kept for the session only. Sending clears both copies. Attachment metadata stays in sessionStorage; file content is never written. Drafts of chats that no longer exist are pruned once the complete chat list has loaded, and when a delete is carried out; the new-chat draft is never pruned. The shared test setup now clears cortex.composer.* from localStorage between tests.

Design decisions worth a look: the toast region has neither aria-live nor aria-atomic (each toast is its own live region); the new-thread guide names the model, or points at the picker when none is selected, but has no button of its own to open it (the picker lives inside the composer, and opening it from outside would mean threading state through LocalModelMenu and MessageComposer); and drafts are also discarded when a delete is carried out, not only at load.

Compatibility and rollback

Frontend only: no API, contract or backend change, and no new dependency. useToast().notify still works as before (it now returns the toast id and takes an optional third argument). Rolling back is reverting the commits; each is independent enough to revert alone except that FE-24 uses the toast action and onClose from FE-21, and FE-32 adds two calls to code introduced by FE-24.

Checks

From the worktree root or frontend/:

  • npm ci; npm run typecheck: pass. npm run lint: pass.
  • npm test -- --run: 67 files, 809 tests passed (58 files, 731 tests before this change).
  • npm run test:coverage: statements 90.0, branches 84.8, functions 88.7, lines 92.5 (floors 84/79/83/87 unchanged; measured before the final rebase).
  • The tests that pin the repaired behaviours fail without the change: with the ten changed source files put back to their main versions, all 14 touched test files fail (64 of the 120 tests in them; the other 56 are existing tests and guards that hold either way), and all 120 pass with the change.
  • Each of the six commits was checked in a scratch copy (typecheck, ESLint, the test files it touches): all pass (before the final rebase, which brought in no frontend changes).
  • Playwright, the mocked specs on a private port with a throwaway config (32 tests, real-api.spec.ts excluded because it needs the Python e2e backend): all 32 passed on the final commit, including the memory clear flow through the new dialog. The default config was not used because it reuses whatever is listening on its fixed ports.
  • Looked at the toasts, dialogs, new-thread guide, loading state, memory panel and approval tray in Chromium (dark theme only).
  • Python: not touched, so the Python 3.10, 3.12, 3.13 and 3.14 matrix was not run and no interpreter other than the default one was used. The pre-push hook ran the whole quick tier on the final commits and all 11 checks passed (605 s): dependency pins and lockfiles, ruff, actionlint and zizmor, mypy, pytest (2727 passed, 3 warnings, 25 subtests), the artifact-boundary review, the API contract check, tsc, eslint, and vitest (67 files, 809 tests).

Security / data-loss / concurrency

  • Deleting: the request is deferred, never dropped. If the app closes inside the six-second window the chat is simply still there next launch (the deletion is not carried out), so a mid-window close cannot lose a chat. Undo cannot race the request: the request is sent only from the toast's close path, and using the action removes the toast without calling it. Two deletes each have their own toast. A failed request un-hides the chat and reports why.
  • Drafts: persisting draft text to localStorage writes typed prompt text to the browser profile's disk storage where the profile persists; nothing is logged or sent. It is bounded (20,000 characters per draft, pruned against the chat list, quota errors swallowed) and never contains attachment content. The desktop shell's private profile means it stores nothing there (see Limits).
  • Approvals: no approval logic changed; the tray only displays more. Expiry is enforced by the backend, not by this display.
  • Toasts: a persistent error stack is capped at five and de-duplicated; a toast's onClose runs at most once.

Limits

  • FE-32 does not yet make drafts survive a restart of the packaged desktop window. The launcher starts the WebView in private mode (private_mode is True, pinned by tests/test_launcher.py) and serves the UI from an OS-chosen port (DEFAULT_PORT = 0), so localStorage is neither persisted nor on a stable origin between launches there. The change helps a browser session and a fixed --port; the packaged window needs either a persistent profile with a stable port or a backend-stored draft, which is a maintainer decision and outside this change. (The theme cache in localStorage has the same limitation.)
  • FE-24: hovering or focusing the Undo toast holds the delete off for as long as it is held. If the whole workspace remounts (for example after a session expiry) inside the window, the toast still commits the delete, but the hidden state is lost until then, so the chat may reappear for a moment.
  • FE-29: the countdown uses the local clock; a task the server has not yet marked expired keeps its buttons. The source review is not offered for an expired task.
  • FE-31: the picker inside the composer still reads "No local models" during the moment the inventory loads, and the composer's placeholder text is unchanged.
  • Not verified: the Windows desktop window itself, high-contrast and light themes (the CSS reuses existing tokens and the token and forced-colors tests pass), and Python 3.10 to 3.14 (no Python change).

Plan item: FE-21, FE-24, FE-29, FE-30, FE-31, FE-32

🤖 Generated with Claude Code

dovvnloading and others added 7 commits September 29, 2026 04:33
…load failed

Error toasts vanished after 4.5 seconds, could not be dismissed, and were
sometimes the only place a failure reason appeared: a failed GGUF download
told the form to "see the notification" after the notification was gone.

- Errors now stay until dismissed and have a Dismiss button. Success and info
  toasts still expire and now pause while hovered or focused.
- Errors are announced as alerts and everything else as status; the region no
  longer announces on top of its children. An identical error that is already
  showing is not stacked, and the stack is capped at five.
- A toast can carry an action button and an onClose callback that runs when it
  goes away unused, which the next change builds on.
- runModelJob hands the specific failure reason to its caller, and the GGUF
  download form shows it inline.

Plan item: FE-21

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Deleting a chat meant typing its exact title (quotes and all, for an
"Untitled chat") and then had no way back. That is production-database
friction for something a person does to tidy a sidebar.

- The dialog is now a plain confirmation naming the chat.
- Confirming hides the chat at once (it is filtered out of the sidebar,
  the command palette and the fallback choice) and shows "Chat deleted" with
  an Undo for six seconds. The delete request is sent only when that window
  closes. Undo puts the chat back exactly as it was and returns to it if the
  person is still where deleting it left them.
- A delete that fails when the window closes brings the chat back and reports
  the reason in a toast that stays until dismissed.
- The request is not sent if the app closes inside the window, so the chat is
  still there on the next launch rather than lost.

Plan item: FE-24

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A code task waiting for approval showed only "Approval needed", with no hint
that the approval window was running out. A person who read the source and came
back after it closed clicked "Allow once" and got a failure with no
explanation, and an expired approval showed as a plain "Cancelled".

- A pending approval shows "expires in N min" (or "under a minute", or
  "expiring now"), recomputed every 30 seconds and once more at the deadline.
- An expired approval shows an Expired chip and a note that the window closed
  before the task was allowed and that it did not run, with no approval
  actions and no source review to open (nothing is left to decide). Other task
  kinds say "Expired" instead of "Cancelled" too, and the tray announcement
  says so.
- The backend remains the authority on expiry: an approval that has run out
  locally but not yet been marked expired keeps its actions, and the server
  refuses them.

Plan item: FE-29

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The memory panel asked "Clear all" with window.confirm, which blocks the whole
JavaScript thread (ChatPage already avoids it for that reason), and gave no
sign that edits or removals were only local until "Save changes", which was
always enabled.

- Clear all uses the shared alert dialog, like the group delete does.
- Removing a row strikes it through and offers Undo until the change is saved;
  a removed row is left out of the save.
- Save changes is disabled while nothing differs from what was saved, and
  "Unsaved changes" says so while something does. Whitespace the server would
  trim is not counted as a change.
- A failed save no longer leaves an unhandled rejection; the workspace already
  reports it, and the marked rows stay as they were.

The e2e spec drives the dialog instead of a native confirm.

Plan item: FE-30

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ile models load

A new thread was an empty canvas whose only hint was a small status line, and
that line often said "The selected local model is unavailable" on every launch:
the workspace seeded its model inventory with an empty, errored placeholder
until /models answered, so the working model was reported as a fault.

- A new thread shows which model will answer (or that none is selected),
  three keyboard hints, and a line about attaching files. It goes away when the
  first message starts and never appears on a saved conversation.
- The inventory now starts as unknown (null in the store) rather than as an
  error. resolveRuntimeAvailability has an inventory-loading state that says
  "Checking local models…" and still asks for a model when none is selected;
  the header's connection dot is neutral until there is a verdict. A failed
  /models request still ends in the "unavailable" state.

The picker inside the composer still reads "No local models" for that moment;
it is a separate component and is not part of this change.

Plan item: FE-31

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…r profile persists

Composer drafts lived only in sessionStorage, so an unsent message was lost when
the page was closed.

- Draft text is now also written to localStorage under the same keys, and read
  back when the session copy is gone. Every storage access is wrapped, so a
  denied or full storage just means a draft that is not kept.
- The persisted copy is bounded: drafts over 20,000 characters (the composer
  allows 100,000) are kept for the session only.
- Sending clears both copies. Attachment metadata stays in sessionStorage as
  before and no file content is ever written.
- Drafts of chats that no longer exist are removed once the workspace has
  loaded the complete chat list, and when a delete is carried out. The
  new-chat draft is never pruned.

The desktop shell starts its WebView in private mode and serves the UI from a
different port on each launch, so localStorage does not outlive the app there;
this helps a browser or a fixed --port, not yet the packaged window.

Plan item: FE-32

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Resolves the conflicts with #314 (pick Hugging Face files from a listing,
cancel and resume model downloads), which rewrote the model-download code
this branch also touched.

- ModelsPanel.tsx: take main's extracted GGUFDownloadForm. Main's form
  already shows a failed download's reason inline (and a muted "cancelled"
  note), so this branch's inline-reason change is dropped as redundant.
- App.tsx: take main's useModelJobs / modelJobs.run in place of runModelJob
  (this branch's onFailure option is superseded by the job result's
  `failure`), keep models={inventory} (FE-31 placeholder) beside main's
  onCancelModelJob and onListHuggingFaceFiles, and keep main's
  modelBusy={saving} on the chat route.
- ModelsPanel.test.tsx: main's tests plus this branch's three failure-reason
  tests as their own describe block, adapted to main's props and wording.
- App.modelDownloadFailure.test.tsx: expect the reason as main's form shows
  it (without the appended "Check the details above" sentence).
- App.modelJobs.test.tsx: a failed download now raises an error toast that
  is an alert as well, so the inline copy is found by its field-error class.
- App.inventoryLoading.test.tsx: new test that a finished model job's
  inventory ends the loading placeholder and is not overwritten by the
  first inventory request answering late.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@dovvnloading

Copy link
Copy Markdown
Owner Author

Merged main (including #314, "pick Hugging Face files from a listing, cancel and resume model downloads") into this branch as a merge commit, 11ac5a5f. No rebase and no force-push. git diff origin/main --stat now lists 28 files, all frontend files that belong to this pull request. No backend, contracts, lockfile or workflow file differs from main.

How each conflict was resolved

frontend/src/features/models/ModelsPanel.tsx: took main's structure, with the extracted GGUFDownloadForm (it takes onListFiles). Main's form already shows a failed download's reason beside the button: it renders the rejection's message in a role="alert" paragraph with field-error, and a cancelled download as a quiet status note. This branch's only change there was the inline reason (its message plus "Check the details above and try again."), so it was dropped as redundant. Nothing of this branch remains in that file.

frontend/src/app/App.tsx (four blocks):

  • Imports: main's lines plus useMemo, which the delete-with-Undo code here uses. JobStatusResponse and SSEEvent are dropped because only the old runModelJob used them.
  • runModelJob vs useModelJobs: took main's useModelJobs block and the modelJobs.run(...) download handler. This branch's one change to runModelJob was an onFailure callback that carried the failure reason to the form. Main's ModelJobResult.failure does the same, so nothing needed porting.
  • Settings route: models={inventory} (the FE-31 placeholder while the first inventory request is in flight) together with main's onCancelModelJob={modelJobs.cancel} and onListHuggingFaceFiles={listHuggingFaceFiles}.
  • Chat route: main's modelBusy={saving}.
  • FE-31 with onModels: main's onModels bumps modelGenerationRef and calls setModels(refreshed). FE-31's loadWorkspace sets the store to null and only accepts the first inventory answer while its generation is current. The two compose: a finished model job ends the "Checking local models…" placeholder and cannot be overwritten by the first request answering late. No code change was needed. A test now pins it (below).

frontend/src/features/models/ModelsPanel.test.tsx (add/add): started from main's file unchanged, then added this branch's three failure-reason tests as a separate describe("the reason a download failed, beside the form"). They use main's renderPanel (with onListFiles: null, so the file name field is in plain view) and main's wording: the reason is shown verbatim, so the appended "Check the details above" sentence is no longer expected for a real reason. The generic fallback sentence for a rejection with no message is identical.

Test changes needed by the interaction of the two pull requests

  • App.modelDownloadFailure.test.tsx (this branch): expects the reason exactly as main's form shows it, without the appended sentence.
  • App.modelJobs.test.tsx (main's test, one assertion): a failed download now also raises an error toast, and FE-21 made error toasts role="alert". findByRole("alert") therefore matched two elements. It now finds the form's copy by its field-error class and checks that it is an alert with exactly the reason. Same behaviour, more precise query.
  • App.inventoryLoading.test.tsx (this branch): new test "lets a finished model job's inventory stand when the first request answers after it". It holds the first /models request, runs a Rescan from Settings (a model job), checks that the chat has left the loading state, then answers the first request with a different inventory and checks that the store and UI keep the job's inventory. Checked by mutation: removing the modelGenerationRef.current += 1 line from onModels makes this test fail with [ 'stale:1b' ] where [ 'qwen3:8b' ] was expected. The line was restored.

Both pull requests' behaviour is covered together by: ModelsPanel.test.tsx (main's file picker, Cancel, split sets and error classes, plus this branch's failure-reason tests, one file); App.modelJobs.test.tsx (main's job flow, with the reason inline beside a toast); App.modelDownloadFailure.test.tsx (reason on the form and a dismissible toast, including a download that could not be started); and App.inventoryLoading.test.tsx (placeholder, failure and the job-refresh case).

Checks (in a clean worktree at the merge commit)

  • npm ci, then from frontend/: npm run typecheck clean; npm run lint clean.
  • npm test -- --run: 72 files, 914 tests passed.
  • npm run test:coverage: exit 0, 914 tests passed, floors hold (statements 90.53%, branches 85.79%, functions 89.43%, lines 92.64%).
  • python -m ruff check backend tests tools main.py app_factory.py scripts: all checks passed. python -m mypy: no issues in 97 source files.
  • Pre-push hook, second attempt: all 11 checks passed (pytest 2738 passed, artifact boundary review 12 of 12, contracts up to date, tsc, eslint, vitest 72 files and 914 tests). The first attempt failed only in the frontend unit tests: two vitest workers exited unexpectedly ("Worker exited unexpectedly") on the loaded shared machine, with 70 of 72 files and 824 tests passing and no assertion failure. It was retried unchanged, not bypassed.

Known limits

  • The first pre-push attempt's crashed workers were not attributed to a specific test file. The rerun passed everything, so this is recorded as an environment failure, not a confirmed flake in a named test.
  • Not run: Playwright and the production build (the -Tier full gates). The merge touches no e2e or build configuration, but the pre-push hook does not run them.

Rollback: revert 11ac5a5f, which returns this branch to its pre-merge state (908e7e3e) and the conflicts with main.

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.

1 participant