Repository navigation
feat(frontend): interaction polish A - toasts, chat delete with Undo, approval expiry, memory panel, new-chat guide, drafts - #318
Conversation
…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>
|
Merged How each conflict was resolved
Test changes needed by the interaction of the two pull requests
Both pull requests' behaviour is covered together by: Checks (in a clean worktree at the merge commit)
Known limits
Rollback: revert |
Problem
Six interaction rough edges in the frontend, each verified against the code before changing it:
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./modelsanswered.sessionStorage. See Limits: this one is only partly achievable from the frontend.Root cause
useUiStorearmed an unconditional 4.5 s timer for every kind of toast and nothing rendereddismissToast;runModelJobshowed the specific reason only as a toast and then threw a generic error.expiredapproval fell through to the generic status label (an expired approval ends as a cancelled task).MemoryPanelkept edits in a local draft with no dirty state, and confirmed with a native dialog.loadWorkspacecalledsetModels(UNAVAILABLE_MODELS)before the request, so "not answered yet" and "failed" were the same state.composerDraftusedsessionStorageonly.Change
One commit per item, each verified on its own (typecheck, lint and the tests it touches).
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 arerole="alert", the restrole="status"; the region is no longer a live region itself (keepingaria-liveon 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 anonClosethat runs when it goes away unused.runModelJobpasses the specific reason toonFailure; the download form renders it inline.AppShell,App. The dialog is a plain alert dialog naming the chat. Confirming adds the chat to a pending set thatvisibleChatsfilters out of the sidebar, the command palette and the fallback choice, then shows "Chat deleted" with Undo for six seconds. TheDELETEis 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.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. Anexpiredapproval 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.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.runtimeAvailability,App,AppShell,ChatPage, newNewChatGuide. The inventory starts asnullin the store;resolveRuntimeAvailabilityhas aninventory-loadingstate ("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/modelsstill 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.composerDraft,App, test setup. Draft text is also written tolocalStorageunder 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 insessionStorage; 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 clearscortex.composer.*fromlocalStoragebetween tests.Design decisions worth a look: the toast region has neither
aria-livenoraria-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 throughLocalModelMenuandMessageComposer); 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().notifystill 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 andonClosefrom 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).mainversions, 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.real-api.spec.tsexcluded 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.Security / data-loss / concurrency
localStoragewrites 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).onCloseruns at most once.Limits
private_modeisTrue, pinned bytests/test_launcher.py) and serves the UI from an OS-chosen port (DEFAULT_PORT = 0), solocalStorageis 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 inlocalStoragehas the same limitation.)Plan item: FE-21, FE-24, FE-29, FE-30, FE-31, FE-32
🤖 Generated with Claude Code