m4: global API-error surface (no more silent failures)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 31s

The api client now catches network failures and non-JSON bodies robustly,
and routes unexpected errors (offline / 5xx) to a global toast — 4xx stay
with the caller so forms keep their inline messages. Toast actions are now
optional (undo toasts keep their button; error toasts are message-only), and
ToastHost moved from AppShell to the app root so toasts show everywhere,
including the login screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-21 00:07:04 -04:00
co-authored by Claude Opus 4.8
parent ecbdd12ba9
commit d3bb091f73
6 changed files with 55 additions and 20 deletions
+5
View File
@@ -1,3 +1,8 @@
<script setup lang="ts">
import ToastHost from "./components/ToastHost.vue";
</script>
<template> <template>
<RouterView /> <RouterView />
<ToastHost />
</template> </template>
+35 -8
View File
@@ -1,25 +1,52 @@
import { useUiStore } from "../stores/ui";
export interface ApiError { export interface ApiError {
error: string; error: string;
status: number; status: number;
} }
// Surface infra / unexpected failures (network, 5xx) globally. 4xx are left to
// the caller — forms and views show their own inline messages for those.
function notifyError(err: ApiError) {
try {
useUiStore().showToast(err.error);
} catch {
/* pinia not active yet (very early boot) — skip the toast */
}
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> { async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const resp = await fetch(path, { let resp: Response;
method, try {
credentials: "include", // send/receive the signed session cookie resp = await fetch(path, {
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined, method,
body: body !== undefined ? JSON.stringify(body) : undefined, credentials: "include", // send/receive the signed session cookie
}); headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch {
const err: ApiError = { error: "Can't reach the server. Check your connection.", status: 0 };
notifyError(err);
throw err;
}
const text = await resp.text(); const text = await resp.text();
const data: unknown = text ? JSON.parse(text) : {}; let data: unknown = {};
if (text) {
try {
data = JSON.parse(text);
} catch {
data = {}; // non-JSON body (e.g. a proxy error page) — fall through to status handling
}
}
if (!resp.ok) { if (!resp.ok) {
const message = const message =
typeof data === "object" && data !== null && "error" in data typeof data === "object" && data !== null && "error" in data
? String((data as { error: unknown }).error) ? String((data as { error: unknown }).error)
: "Request failed."; : "Something went wrong. Please try again.";
const err: ApiError = { error: message, status: resp.status }; const err: ApiError = { error: message, status: resp.status };
if (resp.status >= 500) notifyError(err);
throw err; throw err;
} }
-3
View File
@@ -8,7 +8,6 @@ import { useUiStore } from "../stores/ui";
import CommandPalette from "./CommandPalette.vue"; import CommandPalette from "./CommandPalette.vue";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import LabelsModal from "./LabelsModal.vue"; import LabelsModal from "./LabelsModal.vue";
import ToastHost from "./ToastHost.vue";
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors"; import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
const route = useRoute(); const route = useRoute();
@@ -297,8 +296,6 @@ async function signOut() {
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" /> <CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
<ToastHost />
<div <div
v-if="showShortcuts" v-if="showShortcuts"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
+2 -1
View File
@@ -19,11 +19,12 @@ const ui = useUiStore();
> >
<span>{{ ui.toast.message }}</span> <span>{{ ui.toast.message }}</span>
<button <button
v-if="ui.toast.action"
type="button" type="button"
class="font-semibold text-brand hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand" class="font-semibold text-brand hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
@click="ui.runToastAction()" @click="ui.runToastAction()"
> >
{{ ui.toast.actionLabel }} {{ ui.toast.action.label }}
</button> </button>
<button <button
type="button" type="button"
+3 -2
View File
@@ -106,7 +106,8 @@ export const useNotesStore = defineStore("notes", () => {
const setPinned = (id: string, pinned: boolean) => mutate(id, { pinned }); const setPinned = (id: string, pinned: boolean) => mutate(id, { pinned });
const setArchived = async (id: string, archived: boolean): Promise<void> => { const setArchived = async (id: string, archived: boolean): Promise<void> => {
await mutate(id, { archived }); await mutate(id, { archived });
if (archived) useUiStore().showToast("Note archived", "Undo", () => void setArchived(id, false)); if (archived)
useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) });
}; };
const setColor = (id: string, color: NoteColor) => mutate(id, { color }); const setColor = (id: string, color: NoteColor) => mutate(id, { color });
const setKind = (id: string, kind: NoteKind) => mutate(id, { kind }); const setKind = (id: string, kind: NoteKind) => mutate(id, { kind });
@@ -176,7 +177,7 @@ export const useNotesStore = defineStore("notes", () => {
async function trash(id: string): Promise<void> { async function trash(id: string): Promise<void> {
reconcile(await api.post<Note>(`/api/notes/${id}/trash`)); reconcile(await api.post<Note>(`/api/notes/${id}/trash`));
useUiStore().showToast("Note moved to trash", "Undo", () => void restore(id)); useUiStore().showToast("Note moved to trash", { label: "Undo", run: () => void restore(id) });
} }
async function restore(id: string): Promise<void> { async function restore(id: string): Promise<void> {
+10 -6
View File
@@ -1,11 +1,15 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
interface ToastAction {
label: string;
run: () => void;
}
interface Toast { interface Toast {
id: number; id: number;
message: string; message: string;
actionLabel: string; action?: ToastAction;
action: () => void;
} }
// Cross-component UI signals that don't belong to any single view (e.g. a // Cross-component UI signals that don't belong to any single view (e.g. a
@@ -22,10 +26,10 @@ export const useUiStore = defineStore("ui", () => {
let toastTimer: ReturnType<typeof setTimeout> | undefined; let toastTimer: ReturnType<typeof setTimeout> | undefined;
let toastSeq = 0; let toastSeq = 0;
function showToast(message: string, actionLabel: string, action: () => void) { function showToast(message: string, action?: ToastAction) {
toastSeq += 1; toastSeq += 1;
const id = toastSeq; const id = toastSeq;
toast.value = { id, message, actionLabel, action }; toast.value = { id, message, action };
clearTimeout(toastTimer); clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toastTimer = setTimeout(() => {
if (toast.value?.id === id) toast.value = null; if (toast.value?.id === id) toast.value = null;
@@ -38,9 +42,9 @@ export const useUiStore = defineStore("ui", () => {
} }
function runToastAction() { function runToastAction() {
const action = toast.value?.action; const run = toast.value?.action?.run;
dismissToast(); dismissToast();
action?.(); run?.();
} }
return { composeTick, requestCompose, toast, showToast, dismissToast, runToastAction }; return { composeTick, requestCompose, toast, showToast, dismissToast, runToastAction };