198fbb7980
- GET /api/workflows: one LEAD() window pass in a read worker (read-pool.js, workflow-stats.js); idx_events_session restored, idx_events_tool added - compat-sqlite gains function/pluck/readonly so a plugin install without a better-sqlite3 build boots again; antigravity-usage uses db.js's Database - install-hooks adds no checkout hooks while the agenthub plugin is enabled (events were doubled) - terminal attach runs the live tmux server's own binary; the pane scrollbar gets a visible 2-column style, and claude panes start with CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 so tmux keeps history to scroll - Codex sessions' conversation in GET /api/sessions/:id/transcript - Codex/Antigravity sessions claimed by their workspace without hooks - UI-audit follow-ups, docs and OpenAPI
65 lines
2.0 KiB
TypeScript
65 lines
2.0 KiB
TypeScript
/**
|
|
* @file toast.ts
|
|
* @description App-wide toast queue: `toast.success(msg)` / `toast.error(msg)`
|
|
* from anywhere, rendered once by `components/Toaster.tsx` inside live regions.
|
|
* A success fades after a few seconds; an error stays until dismissed, because
|
|
* a message that vanishes before it is read is no message at all (WCAG 2.2.1).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
export type ToastKind = "success" | "error" | "info";
|
|
|
|
export interface ToastItem {
|
|
id: number;
|
|
kind: ToastKind;
|
|
message: string;
|
|
}
|
|
|
|
const SUCCESS_MS = 4000;
|
|
const MAX_TOASTS = 4;
|
|
let items: ToastItem[] = [];
|
|
let nextId = 1;
|
|
const listeners = new Set<(items: ToastItem[]) => void>();
|
|
|
|
function emit() {
|
|
for (const l of listeners) l(items);
|
|
}
|
|
|
|
/** Remove one toast. */
|
|
export function dismissToast(id: number): void {
|
|
items = items.filter((t) => t.id !== id);
|
|
emit();
|
|
}
|
|
|
|
function push(kind: ToastKind, message: string): number {
|
|
const id = nextId++;
|
|
// Same text twice in a row (a double click, a retry) shows once.
|
|
const next = [...items.filter((t) => t.message !== message), { id, kind, message }];
|
|
// Over four: the oldest non-error goes first, so an unread error is not pushed
|
|
// out by a few copies or saves; the newest toast always stays.
|
|
while (next.length > MAX_TOASTS) {
|
|
const older = next.slice(0, -1).findIndex((t) => t.kind !== "error");
|
|
next.splice(older === -1 ? 0 : older, 1);
|
|
}
|
|
items = next;
|
|
emit();
|
|
if (kind !== "error") window.setTimeout(() => dismissToast(id), SUCCESS_MS);
|
|
return id;
|
|
}
|
|
|
|
/** Subscribe to the queue; returns the unsubscribe. */
|
|
export function subscribeToasts(fn: (items: ToastItem[]) => void): () => void {
|
|
listeners.add(fn);
|
|
fn(items);
|
|
return () => {
|
|
listeners.delete(fn);
|
|
};
|
|
}
|
|
|
|
export const toast = {
|
|
success: (message: string) => push("success", message),
|
|
info: (message: string) => push("info", message),
|
|
error: (message: unknown) =>
|
|
push("error", message instanceof Error ? message.message : String(message)),
|
|
};
|