From 7835342cd3a8184cbfadd1dec71c3191dc3188d0 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Fri, 29 May 2026 18:16:09 -0400 Subject: [PATCH] harden terminal ui error states --- .beads/issues.jsonl | 1 + apps/web/app/globals.css | 10 + apps/web/app/terminal.test.ts | 20 + apps/web/app/terminal.tsx | 40 +- .../2026-05-29-harden-terminal-ui-errors.html | 614 ++++++++++++++++++ 5 files changed, 675 insertions(+), 10 deletions(-) create mode 100644 docs/turns/2026-05-29-harden-terminal-ui-errors.html diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 570dd9a..fb7d590 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -24,6 +24,7 @@ {"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-aq9","title":"Harden terminal UI error and overflow states","description":"Harden the web terminal against oversized API errors, non-JSON synthetic admin failures, and long status text so live trading panes remain stable under bad network/backend responses.","status":"closed","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-29T22:10:16Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:13:37Z","closed_at":"2026-05-29T22:13:37Z","close_reason":"Hardened terminal UI error rendering, synthetic admin failure parsing, long-message wrapping, and added focused tests.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-ggm","title":"Harden web terminal UI states","description":"Improve the web terminal surface so it handles loading, empty data, API failures, overflow, and accessible live-status behavior more robustly.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T21:59:45Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:05:45Z","started_at":"2026-05-29T21:59:59Z","closed_at":"2026-05-29T22:05:45Z","close_reason":"Hardened web terminal status announcements, empty states, table semantics, clipped-cell fallbacks, tests, validation, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-dk5","title":"Remove frontend cooker route","description":"Remove the experimental /frontend-cooker page and update repository references that still list it as an available public route.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T13:50:38Z","created_by":"dirtydishes","updated_at":"2026-05-29T13:53:05Z","started_at":"2026-05-29T13:50:48Z","closed_at":"2026-05-29T13:53:05Z","close_reason":"Removed the /frontend-cooker Next.js route, cleaned route/scanner references, documented the work, and validated the web build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-ep2","title":"Configure Impeccable live mode","description":"Initialize the repository's Impeccable live-mode configuration so future design iteration can start without first-time setup.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T08:03:47Z","created_by":"dirtydishes","updated_at":"2026-05-29T08:05:01Z","started_at":"2026-05-29T08:03:52Z","closed_at":"2026-05-29T08:05:01Z","close_reason":"Configured Impeccable live mode and documented validation.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 7cbc952..092961a 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -10,6 +10,7 @@ --text: oklch(0.93 0.014 250); --text-dim: oklch(0.74 0.018 250); --text-faint: oklch(0.59 0.016 250); + --text-muted: var(--text-dim); --accent: oklch(0.78 0.12 74); --accent-soft: oklch(0.78 0.12 74 / 0.1); --green: oklch(0.74 0.13 151); @@ -661,6 +662,7 @@ h3 { color: var(--text-muted); font-size: 0.78rem; line-height: 1.35; + overflow-wrap: anywhere; } .flow-filter-checkbox-grid, @@ -1249,6 +1251,12 @@ h3 { color: var(--text-dim); } +.drawer-note, +.drawer-empty, +.note { + overflow-wrap: anywhere; +} + .chart-surface { width: 100%; height: 460px; @@ -1457,6 +1465,7 @@ h3 { color: oklch(0.91 0.08 72); font-size: 0.78rem; line-height: 1.35; + overflow-wrap: anywhere; } .data-table-wrap { @@ -2291,6 +2300,7 @@ h3 { .synthetic-control-error { color: var(--red); + overflow-wrap: anywhere; } .drawer-header { diff --git a/apps/web/app/terminal.test.ts b/apps/web/app/terminal.test.ts index 80d727f..1c9dc6c 100644 --- a/apps/web/app/terminal.test.ts +++ b/apps/web/app/terminal.test.ts @@ -14,6 +14,7 @@ import { countActiveFlowFilterGroups, filterOptionTapeItems, findAnchorRestoreIndex, + formatUiErrorMessage, formatCompactUsd, formatOptionContractLabel, flushPausableTapeData, @@ -80,6 +81,25 @@ describe("tape status hardening", () => { }); }); +describe("terminal error message hardening", () => { + it("normalizes whitespace and clamps oversized messages before rendering", () => { + const longMessage = `API failed\n\n${"x".repeat(320)}`; + + const formatted = formatUiErrorMessage(longMessage); + + expect(formatted).toHaveLength(240); + expect(formatted).toStartWith("API failed x"); + expect(formatted).toEndWith("..."); + expect(formatted).not.toContain("\n"); + }); + + it("uses a fallback when an error payload is empty", () => { + expect(formatUiErrorMessage(" ", "Synthetic status could not be loaded")).toBe( + "Synthetic status could not be loaded" + ); + }); +}); + const makeItem = (traceId: string, seq: number, ts: number) => ({ trace_id: traceId, seq, diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx index 2826266..10dfd0b 100644 --- a/apps/web/app/terminal.tsx +++ b/apps/web/app/terminal.tsx @@ -507,6 +507,20 @@ const sampleToLimit = (items: T[], limit: number): T[] => { return sampled; }; +export const formatUiErrorMessage = (message: unknown, fallback = "Request failed"): string => { + const raw = + message instanceof Error + ? message.message + : typeof message === "string" + ? message + : String(message ?? ""); + const normalized = raw.replace(/\s+/g, " ").trim(); + if (!normalized) { + return fallback; + } + return normalized.length > 240 ? `${normalized.slice(0, 237)}...` : normalized; +}; + const readErrorDetail = async (response: Response): Promise => { const statusLabel = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`; const text = await response.text(); @@ -530,9 +544,9 @@ const readErrorDetail = async (response: Response): Promise => { error?: string; message?: string; }; - return payload.detail ?? payload.error ?? payload.message ?? `${statusLabel}: ${truncated}`; + return formatUiErrorMessage(payload.detail ?? payload.error ?? payload.message ?? `${statusLabel}: ${truncated}`); } catch { - return `${statusLabel}: ${truncated}`; + return formatUiErrorMessage(`${statusLabel}: ${truncated}`); } }; @@ -4479,7 +4493,7 @@ const CandleChart = ({ if (!active) { return; } - setError(error instanceof Error ? error.message : String(error)); + setError(formatUiErrorMessage(error)); setStatus("disconnected"); setHasData(false); } @@ -7596,8 +7610,8 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => { >
{state.mode === "live" && optionHistoryError ? ( -
- Older option history failed to load: {optionHistoryError} +
+ Older option history failed to load: {formatUiErrorMessage(optionHistoryError)}
) : null} {items.length === 0 ? ( @@ -8869,6 +8883,9 @@ function SyntheticControlDock() { setLoading(false); return; } + if (!response.ok) { + throw new Error(await readErrorDetail(response)); + } const nextStatus = (await response.json()) as SyntheticAdminStatusResponse; setStatus(nextStatus); if (!dirtyRef.current) { @@ -8879,7 +8896,7 @@ function SyntheticControlDock() { } } catch (loadError) { if (!cancelled) { - setError(loadError instanceof Error ? loadError.message : String(loadError)); + setError(formatUiErrorMessage(loadError, "Synthetic status could not be loaded")); } } finally { if (!cancelled) { @@ -8917,8 +8934,7 @@ function SyntheticControlDock() { }) .then(async (response) => { if (!response.ok) { - const body = await response.json().catch(() => null); - throw new Error(body?.detail ?? body?.error ?? "Synthetic control update failed"); + throw new Error(await readErrorDetail(response)); } return (await response.json()) as SyntheticAdminControlResponse; }) @@ -8939,7 +8955,7 @@ function SyntheticControlDock() { }) .catch((updateError) => { dirtyRef.current = false; - setError(updateError instanceof Error ? updateError.message : String(updateError)); + setError(formatUiErrorMessage(updateError, "Synthetic control update failed")); setDraft(savedRef.current); }) .finally(() => { @@ -9147,7 +9163,11 @@ function SyntheticControlDock() {
- {error ?

{error}

: null} + {error ? ( +

+ {error} +

+ ) : null} )} diff --git a/docs/turns/2026-05-29-harden-terminal-ui-errors.html b/docs/turns/2026-05-29-harden-terminal-ui-errors.html new file mode 100644 index 0000000..0f1b353 --- /dev/null +++ b/docs/turns/2026-05-29-harden-terminal-ui-errors.html @@ -0,0 +1,614 @@ + + + + + + Harden Terminal UI Error States + + + +
+
+

Turn document ยท 2026-05-29 18:12 EDT

+

Harden Terminal UI Error States

+

+ This pass made the Islandflow web terminal more stable when backend or network responses are + messy: oversized error payloads are clamped, non-JSON admin failures are handled explicitly, + and long warning text now wraps inside terminal surfaces. +

+
+ Beads: islandflow-aq9 + Surface: apps/web + Command: impeccable harden +
+
+ +
+

Summary

+

+ The terminal now sanitizes UI-facing error strings before rendering them, handles failed + synthetic-admin status responses before parsing JSON, and gives long warnings, drawer notes, + and filter copy safe wrapping behavior. +

+
+ +
+

Changes Made

+
    +
  • + Added formatUiErrorMessage in apps/web/app/terminal.tsx to normalize + whitespace, provide fallback text, and clamp rendered error messages to 240 characters. +
  • +
  • + Routed chart, option-history, and synthetic-control error displays through the formatter so + server payloads cannot flood or distort the interface. +
  • +
  • + Updated synthetic admin status loading to check response.ok before JSON parsing, + reusing the existing response-detail reader for JSON, HTML, and plain-text failures. +
  • +
  • + Added aria-live and role="alert" where warning/error text changes need + to be announced by assistive technology. +
  • +
  • + Added wrapping containment for drawer notes, empty states, flow-filter copy, synthetic errors, + and history-load warnings. +
  • +
+
+ +
+

Context

+

+ Islandflow is a real-time market-data terminal. During live trading or replay investigation, + backend failures should be clear and recoverable without breaking table lanes, drawers, charts, + or operator controls. The hardened paths are intentionally small and utility-first so the + product keeps its dense instrument-panel character. +

+
+ +
+

Important Implementation Details

+
    +
  • + The formatter accepts unknown, so callers can safely pass native errors, strings, + empty payloads, or unexpected values without branching at every render site. +
  • +
  • + readErrorDetail still preserves useful HTTP status context, but now returns a + UI-safe string even when the backend sends HTML, a huge plain-text body, or malformed JSON. +
  • +
  • + The CSS keeps the terminal layout dense. It wraps only message-like text surfaces, not the + horizontally scrollable market-data tables. +
  • +
  • + The dev-server smoke test showed desktop /options had no visible-overflow + offenders. Mobile still reports a document width wider than the viewport because the options + data table intentionally keeps a minimum width and scrolls horizontally. +
  • +
+
+ +
+

Relevant Diff Snippets

+

+ The snippets below were rendered with @pierre/diffs/ssr. They show the core UI error formatter, synthetic-admin failure handling, CSS wrapping guardrails, and regression coverage. +

+
apps/web/app/terminal.tsx
-2+16
506 unmodified lines
507
508
509
510
511
512
17 unmodified lines
530
531
532
533
534
535
536
537
506 unmodified lines
return sampled;
};
+
const readErrorDetail = async (response: Response): Promise<string> => {
const statusLabel = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`;
const text = await response.text();
17 unmodified lines
error?: string;
message?: string;
};
return payload.detail ?? payload.error ?? payload.message ?? `${statusLabel}: ${truncated}`;
} catch {
return `${statusLabel}: ${truncated}`;
}
};
506 unmodified lines
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
17 unmodified lines
544
545
546
547
548
549
550
551
506 unmodified lines
return sampled;
};
+
export const formatUiErrorMessage = (message: unknown, fallback = "Request failed"): string => {
const raw =
message instanceof Error
? message.message
: typeof message === "string"
? message
: String(message ?? "");
const normalized = raw.replace(/\s+/g, " ").trim();
if (!normalized) {
return fallback;
}
return normalized.length > 240 ? `${normalized.slice(0, 237)}...` : normalized;
};
+
const readErrorDetail = async (response: Response): Promise<string> => {
const statusLabel = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`;
const text = await response.text();
17 unmodified lines
error?: string;
message?: string;
};
return formatUiErrorMessage(payload.detail ?? payload.error ?? payload.message ?? `${statusLabel}: ${truncated}`);
} catch {
return formatUiErrorMessage(`${statusLabel}: ${truncated}`);
}
};
+
apps/web/app/terminal.tsx
-2+4
8882 unmodified lines
8869
8870
8871
8872
8873
8874
42 unmodified lines
8917
8918
8919
8920
8921
8922
8923
8924
8882 unmodified lines
setLoading(false);
return;
}
const nextStatus = (await response.json()) as SyntheticAdminStatusResponse;
setStatus(nextStatus);
if (!dirtyRef.current) {
42 unmodified lines
})
.then(async (response) => {
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(body?.detail ?? body?.error ?? "Synthetic control update failed");
}
return (await response.json()) as SyntheticAdminControlResponse;
})
8882 unmodified lines
8883
8884
8885
8886
8887
8888
8889
8890
8891
42 unmodified lines
8934
8935
8936
8937
8938
8939
8940
8882 unmodified lines
setLoading(false);
return;
}
if (!response.ok) {
throw new Error(await readErrorDetail(response));
}
const nextStatus = (await response.json()) as SyntheticAdminStatusResponse;
setStatus(nextStatus);
if (!dirtyRef.current) {
42 unmodified lines
})
.then(async (response) => {
if (!response.ok) {
throw new Error(await readErrorDetail(response));
}
return (await response.json()) as SyntheticAdminControlResponse;
})
+
apps/web/app/globals.css
+7
9 unmodified lines
10
11
12
13
14
15
1234 unmodified lines
1249
1250
1251
1252
1253
1254
9 unmodified lines
--text: oklch(0.93 0.014 250);
--text-dim: oklch(0.74 0.018 250);
--text-faint: oklch(0.59 0.016 250);
--accent: oklch(0.78 0.12 74);
--accent-soft: oklch(0.78 0.12 74 / 0.1);
--green: oklch(0.74 0.13 151);
1234 unmodified lines
color: var(--text-dim);
}
+
.chart-surface {
width: 100%;
height: 460px;
9 unmodified lines
10
11
12
13
14
15
16
1234 unmodified lines
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
9 unmodified lines
--text: oklch(0.93 0.014 250);
--text-dim: oklch(0.74 0.018 250);
--text-faint: oklch(0.59 0.016 250);
--text-muted: var(--text-dim);
--accent: oklch(0.78 0.12 74);
--accent-soft: oklch(0.78 0.12 74 / 0.1);
--green: oklch(0.74 0.13 151);
1234 unmodified lines
color: var(--text-dim);
}
+
.drawer-note,
.drawer-empty,
.note {
overflow-wrap: anywhere;
}
+
.chart-surface {
width: 100%;
height: 460px;
+
apps/web/app/terminal.test.ts
+19
80 unmodified lines
80
81
82
83
84
85
80 unmodified lines
});
});
+
const makeItem = (traceId: string, seq: number, ts: number) => ({
trace_id: traceId,
seq,
80 unmodified lines
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
80 unmodified lines
});
});
+
describe("terminal error message hardening", () => {
it("normalizes whitespace and clamps oversized messages before rendering", () => {
const longMessage = `API failed\n\n${"x".repeat(320)}`;
+
const formatted = formatUiErrorMessage(longMessage);
+
expect(formatted).toHaveLength(240);
expect(formatted).toStartWith("API failed x");
expect(formatted).toEndWith("...");
expect(formatted).not.toContain("\n");
});
+
it("uses a fallback when an error payload is empty", () => {
expect(formatUiErrorMessage(" ", "Synthetic status could not be loaded")).toBe(
"Synthetic status could not be loaded"
);
});
});
+
const makeItem = (traceId: string, seq: number, ts: number) => ({
trace_id: traceId,
seq,
+
+ +
+

Expected Impact for End-Users

+

+ Users should see shorter, more useful failure messages when API routes, charts, history loading, + or synthetic-admin controls fail. The terminal should remain readable even when an upstream + service returns a long stack trace, an HTML error page, or text with unusual spacing. +

+
+ +
+

Validation

+
    +
  • Passed: bun test apps/web/app/terminal.test.ts
  • +
  • Passed: bun --cwd=apps/web run build
  • +
  • + Passed: Playwright smoke loaded + http://localhost:3000/options at desktop and mobile widths; desktop scan found no + visible-overflow offenders. +
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • + This hardens UI rendering of failures. It does not change backend retry behavior or websocket + reconnection policy. +
  • +
  • + Mobile /options keeps a wider-than-viewport table by design. The mitigation is the + existing horizontal table scroll, which preserves data-column legibility. +
  • +
+
+ +
+

Follow-up Work

+
    +
  • + islandflow-3by: add browser or DOM coverage for the shared terminal navigation + drawer interactions. +
  • +
  • + Consider adding a small DOM-level test for synthetic-admin non-JSON failure rendering if the + project adds a React component test harness. +
  • +
+
+
+ +