From dcfca38e6e66c54abab640de09afb0f4afbf64d4 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 27 Aug 2026 11:10:48 -0600 Subject: [PATCH] feat(web): add truthful dot matrix status language --- .../settings/DesktopClientSettings.test.ts | 1 + .../ThreadBackgroundLiveness.test.ts | 21 ++ .../orchestration/ThreadBackgroundLiveness.ts | 10 +- apps/web/THIRD_PARTY_NOTICES.md | 12 ++ apps/web/src/components/AgentsPanel.test.tsx | 27 +++ apps/web/src/components/AgentsPanel.tsx | 24 +-- apps/web/src/components/ChatView.tsx | 2 +- .../components/ConnectionStatusDot.test.tsx | 33 +++ .../src/components/ConnectionStatusDot.tsx | 29 +-- apps/web/src/components/LegacySidebar.tsx | 2 +- .../ProviderUpdateEnvironmentRows.tsx | 2 +- .../components/ServerUpdateAction.test.tsx | 16 +- .../web/src/components/ServerUpdateAction.tsx | 6 +- apps/web/src/components/Sidebar.logic.test.ts | 60 ++++-- apps/web/src/components/Sidebar.logic.ts | 25 +-- apps/web/src/components/Sidebar.tsx | 86 ++++---- .../src/components/ThreadStatusIndicators.tsx | 2 +- .../components/chat/MessagesTimeline.test.tsx | 8 +- .../src/components/chat/MessagesTimeline.tsx | 196 +++++++++-------- .../cloud/CloudEnvironmentConnectList.tsx | 8 +- .../settings/ConnectionsSettings.tsx | 10 +- .../settings/DotMatrixSettings.logic.ts | 65 ++++++ .../settings/DotMatrixSettings.test.tsx | 20 ++ .../components/settings/DotMatrixSettings.tsx | 188 ++++++++++++++++ .../components/settings/SettingsPanels.tsx | 2 + .../settings/settingsSearch.test.ts | 2 + .../src/components/settings/settingsSearch.ts | 10 + .../sidebar/SidebarProviderUpdatePill.tsx | 2 +- .../web/src/components/ui/dot-matrix.test.tsx | 181 +++++++++++----- apps/web/src/components/ui/dot-matrix.tsx | 204 +++++++++++++----- apps/web/src/index.css | 61 +++--- apps/web/src/routes/__root.tsx | 11 + docs/internals/dot-matrix-status-language.md | 82 +++++++ docs/user/status-indicators.md | 26 +++ packages/contracts/src/settings.test.ts | 15 ++ packages/contracts/src/settings.ts | 8 + 36 files changed, 1100 insertions(+), 357 deletions(-) create mode 100644 apps/web/src/components/ConnectionStatusDot.test.tsx create mode 100644 apps/web/src/components/settings/DotMatrixSettings.logic.ts create mode 100644 apps/web/src/components/settings/DotMatrixSettings.test.tsx create mode 100644 apps/web/src/components/settings/DotMatrixSettings.tsx create mode 100644 docs/internals/dot-matrix-status-language.md create mode 100644 docs/user/status-indicators.md diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f5fb08d7d..9dfb47068 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -14,6 +14,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { appearanceContrast: 100, + dotMatrixMotion: "smooth", browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, browserDefaultZoomFactor: 1.25, browserDefaultAppearance: "dark", diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts index 4a4b68ced..7348e2d1a 100644 --- a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts +++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts @@ -28,6 +28,27 @@ describe("ThreadBackgroundLiveness", () => { expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull(); }); + it("does not present a waiting task as active work", () => { + const liveness = ThreadBackgroundLiveness.make(); + const input = { + threadId: "thread", + taskId: "task", + taskType: undefined, + } as const; + liveness.recordTaskLiveness({ ...input, status: "running", kind: "started" }); + expect(liveness.getThreadBackgroundLiveness("thread")).toBe("working"); + + liveness.recordTaskLiveness({ ...input, status: "waiting", kind: "updated" }); + expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull(); + + // A description-only tick cannot falsely restart it. + liveness.recordTaskLiveness({ ...input, status: undefined, kind: "progress" }); + expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull(); + + liveness.recordTaskLiveness({ ...input, status: "running", kind: "progress" }); + expect(liveness.getThreadBackgroundLiveness("thread")).toBe("working"); + }); + it("agents present as working; monitors as monitoring; agents win", () => { const liveness = ThreadBackgroundLiveness.make(); const threadId = "t-live-1"; diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts index d4d6da06d..aeff76e32 100644 --- a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts +++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts @@ -119,13 +119,15 @@ export function make(): ThreadBackgroundLivenessService["Service"] { return; } - // Idle counts as not-live: a resting (resumable) Codex child isn't - // doing anything, and an all-idle fleet must not pin Working. - const terminal = + // Idle and waiting count as not-live: neither state is actively doing + // work, so an all-resting fleet must not pin Working. A later explicit + // running transition adds the task back. + const inactive = input.kind === "completed" || input.status === "idle" || + input.status === "waiting" || (input.status !== undefined && TERMINAL_STATUSES.has(input.status)); - if (terminal) { + if (inactive) { drop(input.threadId, input.taskId); return; } diff --git a/apps/web/THIRD_PARTY_NOTICES.md b/apps/web/THIRD_PARTY_NOTICES.md index c9a675ef4..95d3c9238 100644 --- a/apps/web/THIRD_PARTY_NOTICES.md +++ b/apps/web/THIRD_PARTY_NOTICES.md @@ -9,3 +9,15 @@ Copyright (c) 2016 Roberto Huertas Licensed under the MIT License. The full license text is available in the upstream repository: . + +## assistant-ui Dot Matrix + +The Dot Matrix state patterns in `src/components/ui/dot-matrix.tsx` are adapted +from assistant-ui's [standalone Dot Matrix](https://www.assistant-ui.com/standalone/dot-matrix) +component. Pylon adds queued, terminal, and orchestration states and offers +smooth or stepped animation timing for status surfaces. + +Copyright (c) AgentbaseAI Inc. + +Licensed under the MIT License. The full license text is available in the +upstream repository: . diff --git a/apps/web/src/components/AgentsPanel.test.tsx b/apps/web/src/components/AgentsPanel.test.tsx index d7031db31..ffa6284e9 100644 --- a/apps/web/src/components/AgentsPanel.test.tsx +++ b/apps/web/src/components/AgentsPanel.test.tsx @@ -86,6 +86,33 @@ describe("AgentsPanel agent cancellation", () => { expect(markup).toContain("Working"); }); + it("keeps queued, running, waiting, and settled agent states distinct", () => { + const statuses: RuntimeSubagent["status"][] = [ + "pending", + "running", + "waiting", + "idle", + "completed", + "failed", + "cancelled", + ]; + const statusModel = { + ...model, + directAgents: statuses.map((status) => agent(`agent-${status}`, status, status)), + }; + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-state="orchestrating"'); + expect(markup).toContain('data-state="queued"'); + expect(markup).toContain('data-state="waiting"'); + expect(markup).toContain('data-state="paused"'); + expect(markup).toContain('data-state="success"'); + expect(markup).toContain('data-state="error"'); + expect(markup).toContain('data-state="stopped"'); + expect(markup).toContain("Queued"); + expect(markup).toContain("Waiting"); + }); + it("keeps every agent row read-only without an advertised capability", () => { const markup = renderToStaticMarkup(); expect(markup).not.toContain('aria-label="Stop '); diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index db98237b7..4257d50eb 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -71,24 +71,20 @@ import { DotMatrix, type DotMatrixState } from "./ui/dot-matrix"; import { AgentLiveActivity } from "./AgentLiveActivity"; /** - * In-flight states all present as Working (one steady state, per the - * monitoring-pill design: detail belongs in the activity sub-line, and a - * stalled/waiting/queued subagent is still the fleet doing its job, not a - * user problem). Only settled states differentiate. + * Agent status is a truth-bearing visual contract: the orbit is reserved for + * running subagents, while queued and waiting agents use their own patterns. + * Settled outcomes stay static except for attention states such as failure. */ const STATUS_VISUALS: Record = { - pending: { matrix: "spinner", label: "Working" }, - running: { matrix: "spinner", label: "Working" }, - waiting: { matrix: "spinner", label: "Working" }, - // Idle reads as settled (muted, not primary): a resting Codex child looks - // done unless resumed — live-test: sky idle dots read as stuck in-progress. - idle: { matrix: "idle", label: "Idle · resumable" }, - completed: { matrix: "done", label: "Completed" }, + pending: { matrix: "queued", label: "Queued" }, + running: { matrix: "orchestrating", label: "Working" }, + waiting: { matrix: "waiting", label: "Waiting" }, + idle: { matrix: "paused", label: "Idle · resumable" }, + completed: { matrix: "success", label: "Completed" }, failed: { matrix: "error", label: "Failed" }, - // Stopped is settled-but-not-finished: inert dots, no success or error hue. - cancelled: { matrix: "idle", label: "Stopped" }, - interrupted: { matrix: "idle", label: "Stopped" }, + cancelled: { matrix: "stopped", label: "Stopped" }, + interrupted: { matrix: "stopped", label: "Stopped" }, }; function StatusDot({ status }: { status: RuntimeSubagent["status"] }) { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 33b069d7a..bc6ec1d72 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5085,7 +5085,7 @@ function ChatViewContent(props: ChatViewProps) { icon: ( ), diff --git a/apps/web/src/components/ConnectionStatusDot.test.tsx b/apps/web/src/components/ConnectionStatusDot.test.tsx new file mode 100644 index 000000000..3e0213fb8 --- /dev/null +++ b/apps/web/src/components/ConnectionStatusDot.test.tsx @@ -0,0 +1,33 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { ConnectionStatusDot, connectionPhaseDotMatrixState } from "./ConnectionStatusDot"; + +describe("ConnectionStatusDot", () => { + it("maps connection lifecycle facts to semantic states", () => { + expect(connectionPhaseDotMatrixState("connected")).toBe("success"); + expect(connectionPhaseDotMatrixState("connecting")).toBe("connecting"); + expect(connectionPhaseDotMatrixState("reconnecting")).toBe("connecting"); + expect(connectionPhaseDotMatrixState("error")).toBe("error"); + expect(connectionPhaseDotMatrixState("offline")).toBe("offline"); + expect(connectionPhaseDotMatrixState("available")).toBe("offline"); + }); + + it("renders the connected outcome as a static success glyph", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('data-state="success"'); + expect(markup).toContain("text-success"); + expect(markup).not.toContain("data-animated"); + }); + + it("keeps a persisted pairing link static while it waits to be used", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('data-state="queued"'); + expect(markup).toContain("text-warning"); + expect(markup).not.toContain("data-animated"); + }); +}); diff --git a/apps/web/src/components/ConnectionStatusDot.tsx b/apps/web/src/components/ConnectionStatusDot.tsx index 810008a39..4df2e2fab 100644 --- a/apps/web/src/components/ConnectionStatusDot.tsx +++ b/apps/web/src/components/ConnectionStatusDot.tsx @@ -4,26 +4,6 @@ import { cn } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { DotMatrix, type DotMatrixState } from "~/components/ui/dot-matrix"; -/** Canonical connection-phase → dot color mapping shared by every status dot. */ -export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase): string { - switch (phase) { - case "connected": - return "bg-success"; - case "connecting": - case "reconnecting": - return "bg-warning"; - case "error": - return "bg-destructive"; - default: - return "bg-muted-foreground/40"; - } -} - -/** Ping halo for transitional phases; null renders no ping. */ -export function connectionPhasePingClassName(phase: EnvironmentConnectionPhase): string | null { - return phase === "connecting" || phase === "reconnecting" ? "bg-warning/60 duration-2000" : null; -} - /** * Connection phase as a DotMatrix state. The dot carries hue and motion * together, so callers pass a phase rather than assembling colors themselves. @@ -33,20 +13,23 @@ export function connectionPhaseDotMatrixState( ): ConnectionStatusDotProps["state"] { switch (phase) { case "connected": - return "live"; + return "success"; case "connecting": case "reconnecting": return "connecting"; case "error": return "error"; default: - return "idle"; + return "offline"; } } type ConnectionStatusDotProps = { tooltipText?: string | null; - state: Extract; + state: Extract< + DotMatrixState, + "success" | "connecting" | "waiting" | "queued" | "error" | "offline" + >; /** Only needed when a caller wants a hue other than the state's canonical * tone (see dot-matrix.tsx's TONE map). */ colorClassName?: string | undefined; diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 853ce5ebe..8aaaf02ef 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -786,7 +786,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr /> } > - + {terminalStatus.label} diff --git a/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx b/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx index 93f91dd0c..4af426d72 100644 --- a/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx +++ b/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx @@ -121,7 +121,7 @@ function EnvironmentUpdateRow({ trailing = ; break; case "success": - trailing = ; + trailing = ; break; case "failed": case "unchanged": diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx index 8150f217f..6681462dc 100644 --- a/apps/web/src/components/ServerUpdateAction.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -114,16 +114,14 @@ describe("ServerUpdateProgress", () => { ); expect(markup).toContain("Restarting…"); - // One row, no versions and no step rail. The wait carries the shared - // DotMatrix "spinner" marker (blue, from its canonical tone) rather than - // a bespoke breathing dot, so there is nothing green to read as "done" - // and no second pulse animation to own. + // One row, no versions and no step rail. Restarting uses the shared + // neutral syncing pattern; only a completed outcome may turn green. expect(markup).not.toContain("0.0.30"); expect(markup).not.toContain("Resum"); expect(markup).not.toContain("text-success"); - expect(markup).toContain("text-primary"); - expect(markup).toContain('data-state="spinner"'); - expect(markup).not.toContain('data-state="done"'); + expect(markup).toContain("text-foreground"); + expect(markup).toContain('data-state="syncing"'); + expect(markup).not.toContain('data-state="success"'); expect(markup).not.toContain("animate-status-pulse"); expect(markup).not.toContain("animate-spin"); }); @@ -142,6 +140,7 @@ describe("ServerUpdateProgress", () => { expect(markup).toContain("Downloading…"); expect(markup).not.toContain("Install"); + expect(markup).toContain('data-state="downloading"'); }); it("keeps the failure visible with its retryable error", () => { @@ -160,6 +159,7 @@ describe("ServerUpdateProgress", () => { expect(markup).toContain('role="alert"'); expect(markup).toContain("The package could not be verified."); expect(markup).not.toContain("animate-status-pulse"); - expect(markup).not.toContain('data-state="spinner"'); + expect(markup).not.toContain('data-state="syncing"'); + expect(markup).not.toContain('data-state="downloading"'); }); }); diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 6f2e29706..8f688e743 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -57,7 +57,11 @@ export function ServerUpdateProgress({ } return (
- + {serverUpdateStageLabel(state.stage)}
); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5b27479cf..c6930ae49 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -737,6 +737,23 @@ describe("resolveSidebarThreadStatus", () => { ).toBe("working"); }); + it("distinguishes background delegation from root work", () => { + expect( + resolveSidebarThreadStatus({ + ...idle, + backgroundLiveness: "working", + session: { ...session, status: "ready" as const }, + }), + ).toBe("delegating"); + expect( + resolveSidebarThreadStatus({ + ...idle, + backgroundLiveness: "monitoring", + session: { ...session, status: "ready" as const }, + }), + ).toBe("monitoring"); + }); + it("reports failed only while the session status is error", () => { expect( resolveSidebarThreadStatus({ @@ -1130,7 +1147,7 @@ describe("resolveThreadStatusPill", () => { hasPendingUserInput: true, }, }), - ).toMatchObject({ label: "Pending Approval", matrix: "approval" }); + ).toMatchObject({ label: "Pending Approval", matrix: "warning" }); }); it("shows awaiting input when plan mode is blocked on user answers", () => { @@ -1141,7 +1158,7 @@ describe("resolveThreadStatusPill", () => { hasPendingUserInput: true, }, }), - ).toMatchObject({ label: "Awaiting Input", matrix: "input" }); + ).toMatchObject({ label: "Awaiting Input", matrix: "waiting" }); }); it("falls back to working when the thread is actively running without blockers", () => { @@ -1149,9 +1166,8 @@ describe("resolveThreadStatusPill", () => { resolveThreadStatusPill({ thread: baseThread, }), - // The sidebar row uses the ring spinner, not the row-sweep: the sweep - // is reserved for the chat stream's own working row. - ).toMatchObject({ label: "Working", matrix: "spinner" }); + // A root turn uses neutral loading; the ring is reserved for delegation. + ).toMatchObject({ label: "Working", matrix: "loading" }); }); it("shows connecting while the session is starting", () => { @@ -1176,7 +1192,23 @@ describe("resolveThreadStatusPill", () => { }, }, }), - ).toMatchObject({ label: "Plan Ready", matrix: "plan" }); + ).toMatchObject({ label: "Plan Ready", matrix: "info" }); + }); + + it("uses the orchestration ring only for settled-turn background agents", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + backgroundLiveness: "working", + session: { ...baseThread.session, status: "ready", activeTurnId: null }, + }, + }), + ).toMatchObject({ + label: "Working", + matrix: "orchestrating", + colorClass: "text-foreground", + }); }); it("does not manufacture completed state without a client visit marker", () => { @@ -1210,7 +1242,7 @@ describe("resolveThreadStatusPill", () => { }, }, }), - ).toMatchObject({ label: "Completed", matrix: "done" }); + ).toMatchObject({ label: "Completed", matrix: "success" }); }); }); @@ -1247,20 +1279,20 @@ describe("resolveProjectStatusIndicator", () => { { label: "Completed", colorClass: "text-emerald-600", - matrix: "done", + matrix: "success", }, { label: "Pending Approval", colorClass: "text-amber-600", - matrix: "approval", + matrix: "warning", }, { label: "Working", colorClass: "text-sky-600", - matrix: "working", + matrix: "loading", }, ]), - ).toMatchObject({ label: "Pending Approval", matrix: "approval" }); + ).toMatchObject({ label: "Pending Approval", matrix: "warning" }); }); it("prefers plan-ready over completed when no stronger action is needed", () => { @@ -1269,15 +1301,15 @@ describe("resolveProjectStatusIndicator", () => { { label: "Completed", colorClass: "text-emerald-600", - matrix: "done", + matrix: "success", }, { label: "Plan Ready", colorClass: "text-violet-600", - matrix: "plan", + matrix: "info", }, ]), - ).toMatchObject({ label: "Plan Ready", matrix: "plan" }); + ).toMatchObject({ label: "Plan Ready", matrix: "info" }); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 058d71d9b..99b7b7698 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -471,6 +471,7 @@ export type SidebarThreadStatus = | "approval" | "input" | "working" + | "delegating" | "monitoring" | "failed" | "ready"; @@ -498,7 +499,7 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si // Background work outlives the turn: fleets read as working; monitoring // only when watch loops are the sole live work. if (thread.backgroundLiveness === "working") { - return "working"; + return "delegating"; } if (thread.backgroundLiveness === "monitoring") { return "monitoring"; @@ -659,7 +660,7 @@ export function resolveThreadStatusPill(input: { return { label: "Pending Approval", colorClass: "text-warning", - matrix: "approval", + matrix: "warning", }; } @@ -667,22 +668,22 @@ export function resolveThreadStatusPill(input: { return { label: "Awaiting Input", colorClass: "text-warning", - matrix: "input", + matrix: "waiting", }; } if (thread.session?.status === "running") { return { label: "Working", - colorClass: "text-primary", - matrix: "spinner", + colorClass: "text-foreground", + matrix: "loading", }; } if (thread.session?.status === "starting") { return { label: "Connecting", - colorClass: "text-primary", + colorClass: "text-foreground", matrix: "connecting", }; } @@ -698,7 +699,7 @@ export function resolveThreadStatusPill(input: { return { label: "Plan Ready", colorClass: "text-muted-foreground", - matrix: "plan", + matrix: "info", }; } @@ -709,16 +710,16 @@ export function resolveThreadStatusPill(input: { if (thread.backgroundLiveness === "working") { return { label: "Working", - colorClass: "text-primary", - matrix: "spinner", + colorClass: "text-foreground", + matrix: "orchestrating", }; } if (thread.backgroundLiveness === "monitoring") { return { label: "Monitoring", - colorClass: "text-primary", - matrix: "live", + colorClass: "text-foreground", + matrix: "listening", }; } @@ -726,7 +727,7 @@ export function resolveThreadStatusPill(input: { return { label: "Completed", colorClass: "text-success", - matrix: "done", + matrix: "success", }; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 17702034c..07239735b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -838,58 +838,66 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // working threads aren't your problem yet) — only the colored status label // stands out. const isInFlight = - status === "working" || status === "monitoring" || status === "approval" || status === "input"; + status === "working" || + status === "delegating" || + status === "monitoring" || + status === "approval" || + status === "input"; const shouldRecede = (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; - // Status hues follow the shared five-color system (see dot-matrix.tsx's - // TONE map) so a thread reads the same color everywhere it surfaces. + // Neutral activity follows adaptive foreground; only semantic outcomes + // force color. Distinct glyphs keep root work, delegation, and monitoring + // truthful even when their user-facing labels are similar. const topStatus = status === "working" ? { label: "Working", icon: "working" as const, - className: "text-primary", + className: "text-foreground", } - : status === "monitoring" + : status === "delegating" ? { - // The calm sibling of Working: same hue, because the thread does - // have live activity, but the steady `live` glyph instead of the - // spinner, because a watch loop is presence rather than progress. - label: "Monitoring", - icon: "monitoring" as const, - className: "text-primary", + label: "Working", + icon: "delegating" as const, + className: "text-foreground", } - : status === "approval" + : status === "monitoring" ? { - label: "Approval", - icon: "approval" as const, - className: "text-warning", + label: "Monitoring", + icon: "monitoring" as const, + className: "text-foreground", } - : status === "input" + : status === "approval" ? { - label: "Input", - icon: "input" as const, + label: "Approval", + icon: "approval" as const, className: "text-warning", } - : status === "failed" + : status === "input" ? { - label: "Failed", - icon: "failed" as const, - className: "text-destructive", + label: "Input", + icon: "input" as const, + className: "text-warning", } - : isWoke + : status === "failed" ? { - label: "Woke", - icon: "woke" as const, - className: "text-warning", + label: "Failed", + icon: "failed" as const, + className: "text-destructive", } - : isUnread + : isWoke ? { - label: "Done", - icon: "done" as const, - className: "text-success", + label: "Woke", + icon: "woke" as const, + className: "text-warning", } - : null; + : isUnread + ? { + label: "Done", + icon: "done" as const, + className: "text-success", + } + : null; const isWokeStatus = topStatus?.icon === "woke"; const branchMismatch = resolveLocalCheckoutBranchMismatch({ @@ -1194,7 +1202,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { data-testid={`sidebar-terminal-status-${thread.id}`} className={cn("inline-flex shrink-0 items-center justify-center", terminalStatus.colorClass)} > - + ) : null; const pinIndicator = props.isPinned ? ( @@ -1473,23 +1481,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )} > {topStatus.icon === "working" ? ( - + + ) : topStatus.icon === "delegating" ? ( + ) : topStatus.icon === "monitoring" ? ( - + ) : topStatus.icon === "approval" ? ( - + ) : topStatus.icon === "input" ? ( - + ) : topStatus.icon === "failed" ? ( ) : topStatus.icon === "done" ? ( - + ) : null} {/* The label alone is the live region: a role="status" wrapper around the ticking duration would make screen readers announce every second. */} {topStatus.label} - {status === "working" ? ( + {status === "working" || status === "delegating" ? ( diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 5730e41fc..5748f83c6 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -617,7 +617,7 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma /> } > - + {terminalStatus.label} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 285535f5b..ab90e8f0a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1171,6 +1171,9 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Running pnpm"); expect(markup).toContain("tool call failed"); + expect(markup).toContain('data-state="terminal"'); + expect(markup).toContain("text-inherit"); + expect(markup).toContain('data-state="error"'); }); it("renders working state as the active-turn header", () => { @@ -1222,10 +1225,10 @@ describe("MessagesTimeline", () => { expect(assistantIndex).toBeGreaterThan(workingIndex); expect(markup).toContain('class="border-b border-border/60 pb-2 pt-1"'); expect(markup).toContain( - 'class="px-1 text-sm leading-relaxed text-muted-foreground tabular-nums"', + 'class="flex items-center gap-2 px-1 text-sm leading-relaxed text-muted-foreground tabular-nums"', ); expect(markup).not.toContain('class="pt-0.5 pb-5 pl-1.5"'); - expect(markup).not.toContain('data-state="working"'); + expect(markup).toContain('data-state="loading"'); }); it("aligns the iconless Thinking row with the working timer", () => { @@ -1240,6 +1243,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Working for"); expect(markup).toContain("Thinking"); + expect(markup).toContain('data-state="thinking"'); expect(markup).toContain("gap-1.5 py-0.5 px-1"); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 326954d71..c2b570e37 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -34,7 +34,6 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; import { deriveTimelineEntries, - workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workEntryIndicatesToolSuccess, workLogEntryIsMissingResponse, @@ -61,7 +60,6 @@ import { PaintbrushIcon, SearchIcon, SquarePenIcon, - TerminalIcon, Undo2Icon, WrenchIcon, ZapIcon, @@ -1307,18 +1305,17 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
{steps.map((step) => (
- {/* Step status reads through the shared DotMatrix language, the - same done/spinner/idle mapping the plan sidebar used before - plans folded into the transcript. The marker carries its own - canonical tone, so only the step text is toned here. */} + {/* Plan progress uses the same semantic DotMatrix states as the + rest of the app: thinking while active, success when done, + and idle before work starts. */}
-
- {row.createdAt ? ( - <> - Working for - - ) : ( - "Working..." - )} - {workingStepLabel ? ( - · {workingStepLabel} - ) : null} +
+ + + {row.createdAt ? ( + <> + Working for + + ) : ( + "Working..." + )} + {workingStepLabel ? ( + · {workingStepLabel} + ) : null} +
{row.showThinking ? ( @@ -1465,23 +1469,29 @@ function LiveActivityRow({ failed?: boolean; }) { return ( -
- -
-
-
- +
+
+ +
+
+
+ +
+ {failed ? ( + + + + ) : null}
); } @@ -1493,37 +1503,29 @@ function ThinkingActivityRow() { function LiveActivityContent({ label, iconName, - failed = false, - announceFailure = false, highlighted = false, }: { label: string; iconName: WorkEntryIconName | undefined; - failed?: boolean; - announceFailure?: boolean; highlighted?: boolean; }) { - const resolvedIconName = failed ? "x" : iconName; - return (
- {resolvedIconName ? ( + {iconName ? ( @@ -1594,20 +1596,22 @@ function WorkGroupToggleTimelineRow({ aria-expanded={row.expanded} onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} > - + {row.summary} + {row.hasFailure ? ( + + + + ) : null} ); } @@ -2199,7 +2203,7 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN case "bot": return ; case "check": - return ; + return ; case "circle-alert": return ; case "eye": @@ -2215,7 +2219,10 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN case "square-pen": return ; case "terminal": - return ; + // Live rows duplicate this icon inside their moving foreground mask. + // Inherit the row tone so the matrix brightens with the label instead of + // remaining pinned to its standalone muted terminal color. + return ; case "wrench": return ; case "x": @@ -2543,8 +2550,8 @@ const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation( * A1 spawn CTA: one anchored row per workflow run (or per-turn direct-spawn * batch). Live status is derived from the shared agent panel model at render * time — the row itself never re-renders a roster; the Agents panel is the - * only roster. Freezes to past tense when every member settles. Static dot, - * no animation. + * only roster. Freezes to past tense when every member settles. The circular + * orbit is reserved for active orchestration; queued/waiting work uses ellipsis. */ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: TimelineWorkEntry }) { const { workEntry } = props; @@ -2566,9 +2573,8 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0), ); - const running = agents.filter( - (agent) => agent.status === "running" || agent.status === "pending", - ).length; + const pending = agents.filter((agent) => agent.status === "pending").length; + const running = agents.filter((agent) => agent.status === "running").length; const waiting = agents.filter((agent) => agent.status === "waiting").length; const failed = agents.filter((agent) => agent.status === "failed").length; // The coordinator's own status is authoritative for workflows: dynamic @@ -2581,7 +2587,7 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time coordinatorStatus === "failed" || coordinatorStatus === "cancelled" || coordinatorStatus === "interrupted"; - const live = workflowGroup !== undefined ? !coordinatorSettled : running + waiting > 0; + const live = workflowGroup !== undefined ? !coordinatorSettled : pending + running + waiting > 0; // Same rule as the panel footer: providers may aggregate member usage into // the coordinator, so count the coordinator only when no members exist. const totalTokens = agents.reduce( @@ -2593,19 +2599,32 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time const workflowName = workflowGroup?.workflow.workflowName ?? workflowGroup?.workflow.title ?? null; - // One steady in-flight presentation (monitoring-pill rule): waiting and - // stalled agents read as working; only settled states differentiate. - const working = running + waiting; - const matrix: DotMatrixState = live ? "spinner" : failed > 0 ? "error" : "done"; + const coordinatorWorking = coordinatorStatus === "running"; + const hasActiveWork = livePhase !== undefined || running > 0 || coordinatorWorking; + const matrix: DotMatrixState = live + ? hasActiveWork + ? "orchestrating" + : pending > 0 || coordinatorStatus === "pending" + ? "queued" + : "waiting" + : failed > 0 + ? "error" + : "success"; const lead = live ? `Kicked off ${agentCount} subagent${agentCount === 1 ? "" : "s"}` : `Ran ${agentCount} subagent${agentCount === 1 ? "" : "s"}`; const status = live ? livePhase ? `${livePhase.title} · ${livePhase.activeCount} working` - : working > 0 - ? `${working} working` - : "working" + : running > 0 + ? `${running} working` + : pending > 0 + ? `${pending} queued` + : waiting > 0 + ? `${waiting} waiting` + : coordinatorWorking + ? "working" + : "waiting" : failed > 0 ? `${failed} failed` : "✓ completed"; @@ -2666,8 +2685,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry); - const entryIconName = - showWarningIndicator || showFailedIndicator ? "x" : workEntryIconName(workEntry); + const entryIconName = showWarningIndicator ? "circle-alert" : workEntryIconName(workEntry); const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); const canExpand = expandedBody !== null; @@ -2682,13 +2700,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry)); const iconWrapperClass = cn( "flex size-6 shrink-0 items-center justify-center", - showWarningIndicator || showFailedIndicator + showWarningIndicator || showDestructiveRowStyle ? "text-destructive" - : showDestructiveRowStyle - ? "text-destructive" - : workEntry.tone === "tool" || showFailedIndicator - ? "text-icon-muted" - : iconConfig.className, + : workLogEntryIsToolLike(workEntry) + ? "text-icon-muted" + : iconConfig.className, ); const headingClass = showWarningIndicator ? "font-medium text-warning" @@ -2728,12 +2744,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { {...rowToggleProps} >
- + ) : null} - + {showFailedIndicator ? ( + } > @@ -2780,7 +2798,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { } > - + Completed diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 94926051e..c8cbc1a68 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -180,19 +180,19 @@ export function CloudEnvironmentConnectRows({ // default primary: here it means "not yet available", not "in motion". const connectionDot = savedConnection ? savedConnection.tone === "connected" - ? { state: "live" as const, colorClassName: undefined } + ? { state: "success" as const, colorClassName: undefined } : savedConnection.tone === "connecting" ? { state: "connecting" as const, colorClassName: "text-warning" } : savedConnection.tone === "error" ? { state: "error" as const, colorClassName: undefined } - : { state: "idle" as const, colorClassName: "text-muted-foreground/35" } + : { state: "offline" as const, colorClassName: "text-muted-foreground/35" } : availability === "online" - ? { state: "live" as const, colorClassName: undefined } + ? { state: "success" as const, colorClassName: undefined } : availability === "error" ? { state: "error" as const, colorClassName: undefined } : availability === "checking" ? { state: "connecting" as const, colorClassName: "text-warning" } - : { state: "idle" as const, colorClassName: "text-muted-foreground/35" }; + : { state: "offline" as const, colorClassName: "text-muted-foreground/35" }; const statusText = savedConnection ? savedConnection.statusText : availability === "online" diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 663736e70..6d4dc7feb 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -688,8 +688,8 @@ const PairingLinkListRow = memo(function PairingLinkListRow({

{primaryLabel}

@@ -938,7 +938,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({

{primaryLabel}

@@ -1368,7 +1368,7 @@ function SavedBackendListRow({ const isConnecting = connectionState === "connecting" || connectionState === "reconnecting"; const connectionDot = connectionState === "connected" - ? { state: "live" as const, colorClassName: undefined } + ? { state: "success" as const, colorClassName: undefined } : connectionState === "connecting" || connectionState === "reconnecting" ? // Deliberate override: connecting here means "not yet available", // not "in motion", so it borrows the warning tone rather than the @@ -1376,7 +1376,7 @@ function SavedBackendListRow({ { state: "connecting" as const, colorClassName: "text-warning" } : connectionState === "error" ? { state: "error" as const, colorClassName: undefined } - : { state: "idle" as const, colorClassName: "text-muted-foreground/40" }; + : { state: "offline" as const, colorClassName: "text-muted-foreground/40" }; const statusTooltip = connectionStatusText(environment.connection); const errorTraceId = environment.connection.traceId; const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({ diff --git a/apps/web/src/components/settings/DotMatrixSettings.logic.ts b/apps/web/src/components/settings/DotMatrixSettings.logic.ts new file mode 100644 index 000000000..4a90bfd51 --- /dev/null +++ b/apps/web/src/components/settings/DotMatrixSettings.logic.ts @@ -0,0 +1,65 @@ +import type { DotMatrixState } from "../ui/dot-matrix"; + +export const DOT_MATRIX_STATUS_DESCRIPTIONS: Readonly> = { + idle: "Available, but no work is active.", + loading: "Generic root work when no narrower fact is known.", + orchestrating: "Active subagent or workflow coordination.", + queued: "Accepted but not started.", + thinking: "Provider reasoning or planning is active.", + streaming: "Response or tool output is arriving.", + searching: "A known search operation is active.", + syncing: "State is reconciling or a service is restarting.", + connecting: "A connection handshake or reconnect is active.", + waiting: "Blocked or waiting without claiming active work.", + uploading: "A known outbound transfer is active.", + downloading: "A known inbound transfer is active.", + listening: "An active watch or monitor loop.", + speaking: "Fast output or voice playback activity.", + recording: "Capture is active.", + success: "Healthy or completed successfully.", + error: "Failed and needs attention.", + warning: "Approval or warning needs attention.", + info: "Informational state.", + paused: "Paused and resumable.", + stopped: "Stopped or cancelled.", + offline: "Unavailable or disconnected.", + terminal: "Completed or inactive terminal identity.", + "terminal-active": "Active terminal identity with a synchronized breath.", +}; + +export const DOT_MATRIX_STATUS_GROUPS: ReadonlyArray<{ + title: string; + states: ReadonlyArray; +}> = [ + { + title: "Active work", + states: [ + "loading", + "thinking", + "streaming", + "searching", + "syncing", + "uploading", + "downloading", + ], + }, + { + title: "Coordination, presence, and media", + states: [ + "orchestrating", + "queued", + "connecting", + "waiting", + "listening", + "speaking", + "recording", + ], + }, + { + title: "Outcomes and resting states", + states: ["success", "error", "warning", "info", "paused", "stopped", "offline", "idle"], + }, + { title: "Terminal", states: ["terminal", "terminal-active"] }, +]; + +export const dotMatrixSettingsStates = DOT_MATRIX_STATUS_GROUPS.flatMap((group) => group.states); diff --git a/apps/web/src/components/settings/DotMatrixSettings.test.tsx b/apps/web/src/components/settings/DotMatrixSettings.test.tsx new file mode 100644 index 000000000..650669d56 --- /dev/null +++ b/apps/web/src/components/settings/DotMatrixSettings.test.tsx @@ -0,0 +1,20 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { dotMatrixStates } from "../ui/dot-matrix"; +import { DotMatrixSettings } from "./DotMatrixSettings"; +import { dotMatrixSettingsStates } from "./DotMatrixSettings.logic"; + +describe("DotMatrixSettings", () => { + it("shows every Dot Matrix state exactly once", () => { + expect([...dotMatrixSettingsStates].sort()).toEqual([...dotMatrixStates].sort()); + expect(new Set(dotMatrixSettingsStates).size).toBe(dotMatrixStates.length); + }); + + it("does not mount the live catalog until the user opens it", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("View catalog"); + expect(html).not.toContain('data-slot="dot-matrix"'); + expect(html).not.toContain('id="dot-matrix-status-catalog"'); + }); +}); diff --git a/apps/web/src/components/settings/DotMatrixSettings.tsx b/apps/web/src/components/settings/DotMatrixSettings.tsx new file mode 100644 index 000000000..5b1579aa8 --- /dev/null +++ b/apps/web/src/components/settings/DotMatrixSettings.tsx @@ -0,0 +1,188 @@ +import { DEFAULT_DOT_MATRIX_MOTION, type DotMatrixMotion } from "@t3tools/contracts/settings"; +import { useState } from "react"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; +import { useMediaQuery } from "~/hooks/useMediaQuery"; +import { DotMatrix, dotMatrixAnimatedStates, type DotMatrixState } from "../ui/dot-matrix"; +import { Button } from "../ui/button"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { + DOT_MATRIX_STATUS_DESCRIPTIONS, + DOT_MATRIX_STATUS_GROUPS, +} from "./DotMatrixSettings.logic"; +import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +const MOTION_LABELS: Readonly> = { + smooth: "Smooth", + efficient: "Efficient", +}; + +function TerminalRowContent({ highlighted = false }: { highlighted?: boolean }) { + return ( +
+ + IPython +
+ ); +} + +function StreamingTerminalRowPreview() { + return ( +
+ +
+
+
+ +
+
+
+
+ ); +} + +function StatusCard({ state, motionPaused }: { state: DotMatrixState; motionPaused: boolean }) { + const animated = dotMatrixAnimatedStates.includes(state); + const motionLabel = animated ? (motionPaused ? "Paused" : "Animated") : "Static"; + return ( +
+ +
+
+ {state} + + {motionLabel} + +
+

+ {DOT_MATRIX_STATUS_DESCRIPTIONS[state]} +

+
+
+ ); +} + +export function DotMatrixSettings() { + const motion = useClientSettings((settings) => settings.dotMatrixMotion); + const updateSettings = useUpdateClientSettings(); + const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); + const [catalogOpen, setCatalogOpen] = useState(false); + + return ( + + updateSettings({ dotMatrixMotion: DEFAULT_DOT_MATRIX_MOTION })} + /> + ) : null + } + control={ + + } + /> + + setCatalogOpen((open) => !open)} + > + {catalogOpen ? "Hide catalog" : "View catalog"} + + } + /> + + {catalogOpen ? ( +
+
+

Live catalog

+

+ These patterns are the status language used across threads, agents, tools, + connections, and updates. Motion represents a known active state; settled states stay + still. +

+
+ + {DOT_MATRIX_STATUS_GROUPS.map((group) => ( +
+

+ {group.title} +

+
+ {group.states.map((state) => ( + + ))} +
+
+ ))} + +
+

+ Composed activity +

+
+
+

Streaming terminal row

+

+ Live command output uses one foreground sweep across the terminal identity and its + label. It does not add a second Dot Matrix animation. +

+
+ +
+
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 2eb04a62f..7465a7909 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -108,6 +108,7 @@ import { TYPOGRAPHY_ADVANCED_STORAGE_KEY, } from "../../appearanceFonts"; import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; +import { DotMatrixSettings } from "./DotMatrixSettings"; import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; import { NumberField, @@ -1175,6 +1176,7 @@ export function AppearanceSettingsPanel() { ) : null} + ); diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 09fd7a9a6..2969e0fec 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -46,6 +46,8 @@ describe("searchSettings", () => { it("matches normalized title substrings", () => { expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); expect(searchSettings("glass").map((item) => item.id)).toEqual(["setting-glass-opacity"]); + expect(searchSettings("status motion").map((item) => item.id)).toEqual(["dot-matrix-motion"]); + expect(searchSettings("status catalog").map((item) => item.id)).toEqual(["dot-matrix-catalog"]); expect(searchSettings("xyzzy")).toEqual([]); }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 52458295e..73573d998 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -69,6 +69,16 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Glass opacity", to: "/settings/appearance", }, + { + id: "dot-matrix-motion", + title: "Status motion", + to: "/settings/appearance", + }, + { + id: "dot-matrix-catalog", + title: "Status catalog", + to: "/settings/appearance", + }, { id: "environment-identification", title: "Environment identification", diff --git a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx index 6516a9f2f..d5c842edf 100644 --- a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx @@ -176,7 +176,7 @@ export function SidebarProviderUpdatePill() { {displayedView.tone === "loading" ? ( ) : displayedView.tone === "success" ? ( - + ) : displayedView.tone === "error" ? ( ) : ( diff --git a/apps/web/src/components/ui/dot-matrix.test.tsx b/apps/web/src/components/ui/dot-matrix.test.tsx index dc5685868..4ec213982 100644 --- a/apps/web/src/components/ui/dot-matrix.test.tsx +++ b/apps/web/src/components/ui/dot-matrix.test.tsx @@ -1,88 +1,141 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { DotMatrix, type DotMatrixState } from "./dot-matrix"; +import { + DotMatrix, + dotMatrixAnimatedStates, + dotMatrixStates, + type DotMatrixState, +} from "./dot-matrix"; const countAnimated = (html: string) => (html.match(/data-animated="true"/g) ?? []).length; const countChase = (html: string) => (html.match(/data-animated="chase"/g) ?? []).length; +const ASSISTANT_UI_STATES = [ + "idle", + "loading", + "thinking", + "streaming", + "searching", + "syncing", + "connecting", + "waiting", + "uploading", + "downloading", + "listening", + "speaking", + "recording", + "success", + "error", + "warning", + "info", + "paused", + "stopped", + "offline", +] as const; + describe("DotMatrix", () => { it("renders a full 5x5 grid with a status label", () => { - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup(); expect((html.match(/ { - expect(countAnimated(renderToStaticMarkup())).toBe(25); - expect(countAnimated(renderToStaticMarkup())).toBe(0); + it("exports assistant-ui's ordered language plus Pylon's extensions", () => { + expect(dotMatrixStates).toEqual([ + "idle", + "loading", + "orchestrating", + "queued", + ...ASSISTANT_UI_STATES.slice(2), + "terminal", + "terminal-active", + ]); + for (const state of dotMatrixStates) { + expect(renderToStaticMarkup()).toContain(`data-state="${state}"`); + } }); - it("animates only glyph dots in blinking glyph states", () => { - // BANG glyph has 4 dots, CROSS has 9. - expect(countAnimated(renderToStaticMarkup())).toBe(4); + it("uses the expected state-specific animation patterns", () => { + expect(countAnimated(renderToStaticMarkup())).toBe(25); + expect(countAnimated(renderToStaticMarkup())).toBe(3); + expect(countAnimated(renderToStaticMarkup())).toBe(4); expect(countAnimated(renderToStaticMarkup())).toBe(9); + expect(countAnimated(renderToStaticMarkup())).toBe(8); + expect(countChase(renderToStaticMarkup())).toBe(12); + expect(dotMatrixAnimatedStates).toContain("terminal-active"); }); - it("gives static states no animation timeline at all", () => { - for (const state of ["plan", "done", "idle"] as const) { + it("breathes the active terminal glyph in sync", () => { + const html = renderToStaticMarkup(); + expect(html.match(/animation-duration:1.7s/g)).toHaveLength(8); + expect(html.match(/animation-delay:0s/g)).toHaveLength(8); + }); + + it("gives resting states no animation timeline", () => { + for (const state of [ + "idle", + "queued", + "success", + "info", + "paused", + "stopped", + "offline", + "terminal", + ] as const) { const html = renderToStaticMarkup(); expect(html).not.toContain("animation-duration"); - expect(html).not.toContain('data-animated="true"'); + expect(html).not.toContain("data-animated"); } }); - it("renders identical markup across renders (deterministic hash)", () => { - const a = renderToStaticMarkup(); - const b = renderToStaticMarkup(); + it("renders identical markup across renders", () => { + const a = renderToStaticMarkup(); + const b = renderToStaticMarkup(); expect(a).toBe(b); }); - it("falls back to the state name as the accessible label", () => { - expect(renderToStaticMarkup()).toContain( - 'aria-label="connecting"', - ); - }); - - it("animates only the ring dots in spinner, using the chase keyframe", () => { - const html = renderToStaticMarkup(); - // RING glyph has 12 dots (perimeter minus corners); the 4 corners plus 9 - // interior dots must stay unanimated. - expect(countAnimated(html)).toBe(0); - expect(countChase(html)).toBe(12); - const corners = ['cx="2" cy="2"', 'cx="18" cy="2"', 'cx="2" cy="18"', 'cx="18" cy="18"']; - for (const corner of corners) { - const cornerCircle = html.split(" c.includes(corner)); - expect(cornerCircle).toBeDefined(); - expect(cornerCircle).not.toContain("data-animated"); - } - }); - - it("never emits a positive animation-delay (unsigned hash regression)", () => { - const html = renderToStaticMarkup(); - const delays = [...html.matchAll(/animation-delay:\s*([-\d.]+)s/g)].map((m) => Number(m[1])); - expect(delays.length).toBeGreaterThan(0); - for (const delay of delays) { - expect(delay).toBeLessThanOrEqual(0); + it("never emits a positive animation delay", () => { + for (const state of dotMatrixStates) { + const html = renderToStaticMarkup(); + const delays = [...html.matchAll(/animation-delay:\s*([-\d.]+)s/g)].map((match) => + Number(match[1]), + ); + for (const delay of delays) { + expect(delay, `${state} emitted a positive delay`).toBeLessThanOrEqual(0); + } } }); - it("applies each state's canonical tone by default", () => { + it("uses adaptive foreground for neutral activity and color for outcomes", () => { const toneByState: Record = { - working: "text-primary", - connecting: "text-primary", - spinner: "text-primary", - done: "text-success", - live: "text-success", - error: "text-destructive", - recording: "text-destructive", - approval: "text-warning", - input: "text-warning", idle: "text-muted-foreground", + loading: "text-foreground", + orchestrating: "text-foreground", + queued: "text-muted-foreground", + thinking: "text-foreground", + streaming: "text-foreground", + searching: "text-foreground", + syncing: "text-foreground", + connecting: "text-foreground", + waiting: "text-foreground", + uploading: "text-foreground", + downloading: "text-foreground", + listening: "text-foreground", + speaking: "text-foreground", + recording: "text-destructive", + success: "text-success", + error: "text-destructive", + warning: "text-warning", + info: "text-primary", + paused: "text-muted-foreground", + stopped: "text-muted-foreground", + offline: "text-muted-foreground", terminal: "text-muted-foreground", - plan: "text-muted-foreground", + "terminal-active": "text-foreground", }; for (const [state, tone] of Object.entries(toneByState) as [DotMatrixState, string][]) { const html = renderToStaticMarkup(); @@ -91,10 +144,24 @@ describe("DotMatrix", () => { } }); - it("lets a caller override the default tone via className (twMerge keeps the override)", () => { - const html = renderToStaticMarkup(); + it("is decorative unless a caller provides a label", () => { + const html = renderToStaticMarkup(); + const rootTag = html.slice(0, html.indexOf(">") + 1); + expect(rootTag).toContain('aria-hidden="true"'); + expect(rootTag).not.toContain('role="status"'); + }); + + it("lets callers override the default tone", () => { + const html = renderToStaticMarkup(); const rootTag = html.slice(0, html.indexOf(">") + 1); - expect(rootTag).toContain("text-foreground"); - expect(rootTag).not.toContain("text-primary"); + expect(rootTag).toContain("text-warning"); + expect(rootTag).not.toContain("text-foreground"); + }); + + it("renders a three-dot cursor in Pylon's terminal glyph", () => { + const html = renderToStaticMarkup(); + for (const cx of ["10", "14", "18"]) { + expect(html).toContain(`cx="${cx}" cy="18"`); + } }); }); diff --git a/apps/web/src/components/ui/dot-matrix.tsx b/apps/web/src/components/ui/dot-matrix.tsx index 70976cdb9..9aaa8e035 100644 --- a/apps/web/src/components/ui/dot-matrix.tsx +++ b/apps/web/src/components/ui/dot-matrix.tsx @@ -48,6 +48,25 @@ const INFO = glyph([ [3, 2], [4, 2], ]); +const PAUSE = glyph([ + [1, 1], + [2, 1], + [3, 1], + [1, 3], + [2, 3], + [3, 3], +]); +const STOP = glyph([ + [1, 1], + [1, 2], + [1, 3], + [2, 1], + [2, 2], + [2, 3], + [3, 1], + [3, 2], + [3, 3], +]); const ELLIPSIS = glyph([ [2, 0], [2, 2], @@ -60,18 +79,18 @@ const RECORD = glyph([ [2, 3], [3, 2], ]); -/* ">_" shell prompt: a full-height chevron plus a cursor underscore. */ +/* Pylon extension: a full-height chevron plus a cursor underscore. */ const PROMPT = glyph([ [0, 0], [1, 1], [2, 2], [3, 1], [4, 0], + [4, 2], [4, 3], [4, 4], ]); -/* The 12-dot perimeter of the 5x5 grid, corners excluded, so it reads as a - circle rather than a square. */ +/* Pylon extension: a circular fleet/orchestration indicator. */ const RING = glyph([ [0, 1], [0, 2], @@ -98,21 +117,61 @@ type StateConfig = { dim?: number; /** Blink parameters per on dot, keyed by index and grid position. */ blink?: (i: number, row: number, col: number) => Blink; - /** Use the narrow-duty-cycle chase keyframe instead of the 50/50 blink, so a - * single dot travels around the glyph rather than half of it lighting at once. */ + /** Use Pylon's fading-tail orbit instead of the shared blink keyframe. */ chase?: boolean; }; +/** + * Pylon's status language follows assistant-ui's standalone Dot Matrix states. + * Neutral activity inherits the adaptive foreground tone; color is reserved for + * semantic outcomes. Static states do not receive an animation timeline. + */ const STATES = { - /** Row sweep with per-column jitter — the agent is actively producing work. */ - working: { + idle: { base: 0.3 }, + loading: { + blink: (i: number) => ({ + duration: 0.9 + hash(i, 2, 700), + delay: -hash(i, 1, 1200), + lo: 0.15, + }), + }, + orchestrating: { + glyph: RING, + dim: 0.06, + chase: true, + blink: (_i: number, row: number, col: number) => { + const turn = (Math.atan2(row - CENTER, col - CENTER) + Math.PI) / (2 * Math.PI); + return { duration: 1.1, delay: -(1 - turn) * 1.1, lo: 0.12 }; + }, + }, + queued: { glyph: ELLIPSIS }, + thinking: { + blink: (_i: number, row: number, col: number) => ({ + duration: 1.2, + delay: -(row + col) * 0.09, + lo: 0.2, + }), + }, + streaming: { blink: (_i: number, row: number, col: number) => ({ duration: 0.9, delay: -(row * 0.12 + hash(col, 3, 900)), lo: 0.15, }), }, - /** Center-out ripple — a connection is being established. */ + searching: { + blink: (_i: number, _row: number, col: number) => ({ + duration: 1.1, + delay: -col * 0.12, + lo: 0.2, + }), + }, + syncing: { + blink: (_i: number, row: number, col: number) => { + const turn = (Math.atan2(row - CENTER, col - CENTER) + Math.PI) / (2 * Math.PI); + return { duration: 1.3, delay: -turn * 1.3, lo: 0.2 }; + }, + }, connecting: { blink: (_i: number, row: number, col: number) => ({ duration: 1.4, @@ -120,18 +179,7 @@ const STATES = { lo: 0.15, }), }, - /** A single dot chasing around a ring — the agent is actively producing work. */ - spinner: { - glyph: RING, - dim: 0.06, - chase: true, - blink: (_i: number, row: number, col: number) => { - const turn = (Math.atan2(row - CENTER, col - CENTER) + Math.PI) / (2 * Math.PI); - return { duration: 1.1, delay: -(1 - turn) * 1.1, lo: 0.12 }; - }, - }, - approval: { glyph: BANG, blink: () => ({ duration: 1.6, delay: 0, lo: 0.45 }) }, - input: { + waiting: { glyph: ELLIPSIS, blink: (_i: number, _row: number, col: number) => ({ duration: 1.2, @@ -139,57 +187,104 @@ const STATES = { lo: 0.2, }), }, - plan: { glyph: INFO }, - done: { glyph: CHECK }, + uploading: { + blink: (_i: number, row: number) => ({ + duration: 1, + delay: -(GRID - 1 - row) * 0.12, + lo: 0.2, + }), + }, + downloading: { + blink: (_i: number, row: number) => ({ + duration: 1, + delay: -row * 0.12, + lo: 0.2, + }), + }, + listening: { + blink: (_i: number, _row: number, col: number) => ({ + duration: 0.7 + hash(col, 4, 500), + delay: -hash(col, 5, 900), + lo: 0.25, + }), + }, + speaking: { + blink: (_i: number, _row: number, col: number) => ({ + duration: 0.4 + hash(col, 6, 350), + delay: -hash(col, 7, 700), + lo: 0.2, + }), + }, + recording: { + glyph: RECORD, + dim: 0.12, + blink: () => ({ duration: 1.4, delay: 0, lo: 0.3 }), + }, + success: { glyph: CHECK }, error: { glyph: CROSS, blink: () => ({ duration: 1.1, delay: 0, lo: 0.4 }) }, - idle: { base: 0.3 }, - terminal: { glyph: PROMPT, blink: () => ({ duration: 1.6, delay: 0, lo: 0.5 }) }, - recording: { glyph: RECORD, dim: 0.12, blink: () => ({ duration: 1.4, delay: 0, lo: 0.3 }) }, - live: { glyph: RECORD, dim: 0.12, blink: () => ({ duration: 2, delay: 0, lo: 0.55 }) }, + warning: { glyph: BANG, blink: () => ({ duration: 1.6, delay: 0, lo: 0.45 }) }, + info: { glyph: INFO }, + paused: { glyph: PAUSE }, + stopped: { glyph: STOP }, + offline: { base: 0.15 }, + terminal: { glyph: PROMPT }, + "terminal-active": { + glyph: PROMPT, + blink: () => ({ duration: 1.7, delay: 0, lo: 0.35 }), + }, } satisfies Record; export type DotMatrixState = keyof typeof STATES; +export const dotMatrixStates = Object.keys(STATES) as ReadonlyArray; +export const dotMatrixAnimatedStates = dotMatrixStates.filter((state) => "blink" in STATES[state]); + export type DotMatrixProps = Omit, "children"> & { state: DotMatrixState; label?: string; }; -/** Each state's canonical hue — the five-color status language shared across - * every DotMatrix call site. `working`/`connecting`/`spinner` are "in - * motion", `done`/`live` are "settled well", `error`/`recording` are - * "needs attention now", `approval`/`input` are "needs a decision", and - * `idle`/`terminal`/`plan` are unlabeled resting states. */ const TONE: Record = { - working: "text-primary", - connecting: "text-primary", - spinner: "text-primary", - done: "text-success", - live: "text-success", - error: "text-destructive", - recording: "text-destructive", - approval: "text-warning", - input: "text-warning", idle: "text-muted-foreground", + loading: "text-foreground", + orchestrating: "text-foreground", + queued: "text-muted-foreground", + thinking: "text-foreground", + streaming: "text-foreground", + searching: "text-foreground", + syncing: "text-foreground", + connecting: "text-foreground", + waiting: "text-foreground", + uploading: "text-foreground", + downloading: "text-foreground", + listening: "text-foreground", + speaking: "text-foreground", + recording: "text-destructive", + success: "text-success", + error: "text-destructive", + warning: "text-warning", + info: "text-primary", + paused: "text-muted-foreground", + stopped: "text-muted-foreground", + offline: "text-muted-foreground", terminal: "text-muted-foreground", - plan: "text-muted-foreground", + "terminal-active": "text-foreground", }; /** - * 5×5 dot-matrix status indicator — Pylon's shared status language. Each - * state carries a canonical tone from `TONE` (dots render in `currentColor`, - * so the tone class sets it); callers may override with a `className` text - * color when a surface genuinely needs to differ. Size stays a caller - * concern — pick a size class (14px+ keeps dots legible). Animated states - * blink per-dot with stepped timing; static glyph states carry no animation - * timeline, so a wall of settled threads costs the compositor nothing. + * A 5×5 status indicator adapted from assistant-ui's standalone Dot Matrix. + * Each state combines a stable pattern, motion, and semantic tone. Active + * neutral states use `text-foreground`, which reads white on dark themes and + * dark on light themes. Callers own size and may override tone with className. */ function DotMatrix({ className, state, label, ...props }: DotMatrixProps) { const config: StateConfig = STATES[state]; return ( 0 ? blink.lo / rest : 1, } : {}), } as CSSProperties diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 8667f786d..cb31bd92f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -255,68 +255,75 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } -/* Dot-matrix status indicator. Per-dot duration/delay arrive inline from the - DotMatrix component; steps() holds keep many concurrent indicators cheap for - the compositor (same duty-cycle discipline as status-ping above). The blink - animates element opacity while resting brightness lives in fill-opacity, so - state changes cross-fade independently of the blink. */ +/* Dot Matrix is adapted from assistant-ui's standalone state language. The + registered amplitude bounds cross-fade when a state changes. Only live states + receive an animation timeline. Smooth motion is the polished default; the + client-local Efficient preference switches those timelines to stepped frames. */ +@property --dot-matrix-hi { + syntax: ""; + inherits: false; + initial-value: 1; +} +@property --dot-matrix-lo { + syntax: ""; + inherits: false; + initial-value: 0.15; +} @keyframes dot-matrix-blink { 0%, - 40% { - opacity: 1; - animation-timing-function: steps(4); - } - 50%, - 90% { - opacity: var(--dot-matrix-blink-lo, 1); - animation-timing-function: steps(4); - } 100% { - opacity: 1; + opacity: var(--dot-matrix-hi, 1); + } + 50% { + opacity: var(--dot-matrix-lo, 0.15); } } -/* A head with a fading tail for "chase" states — multiple dots light at - decreasing brightness to create a trailing effect around the glyph. */ +/* Pylon's orchestrating state keeps the existing circular head and fading tail + so subagent and workflow activity has a distinct identity. */ @keyframes dot-matrix-chase { 0% { - opacity: 1; - animation-timing-function: steps(2); + opacity: var(--dot-matrix-hi, 1); } 12% { opacity: 0.78; - animation-timing-function: steps(2); } 24% { opacity: 0.56; - animation-timing-function: steps(2); } 36% { opacity: 0.38; - animation-timing-function: steps(2); } 48% { opacity: 0.22; - animation-timing-function: steps(2); } 60%, 100% { - opacity: var(--dot-matrix-blink-lo, 1); + opacity: var(--dot-matrix-lo, 0.15); } } .dot-matrix-dot { - transition: fill-opacity 0.3s; + transition: + opacity 0.3s, + --dot-matrix-hi 0.3s, + --dot-matrix-lo 0.3s; } .dot-matrix-dot[data-animated="true"], .dot-matrix-dot[data-animated="chase"] { - animation: dot-matrix-blink 1s infinite; + animation: dot-matrix-blink 1s infinite ease-in-out; } .dot-matrix-dot[data-animated="chase"] { animation-name: dot-matrix-chase; } +html[data-dot-matrix-motion="efficient"] .dot-matrix-dot[data-animated="true"] { + animation-timing-function: steps(4); +} +html[data-dot-matrix-motion="efficient"] .dot-matrix-dot[data-animated="chase"] { + animation-timing-function: steps(2); +} @media (prefers-reduced-motion: reduce) { .dot-matrix-dot[data-animated="true"], .dot-matrix-dot[data-animated="chase"] { - animation: none; + animation: none !important; } } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 7c715dff9..537488db3 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -133,6 +133,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? : null} @@ -164,6 +165,16 @@ function ContrastAppearanceSync() { return null; } +function DotMatrixMotionSync() { + const motion = useClientSettings((settings) => settings.dotMatrixMotion); + + useEffect(() => { + document.documentElement.dataset.dotMatrixMotion = motion; + }, [motion]); + + return null; +} + function GlassAppearanceSync() { const glassOpacity = useClientSettings((settings) => settings.glassOpacity); diff --git a/docs/internals/dot-matrix-status-language.md b/docs/internals/dot-matrix-status-language.md new file mode 100644 index 000000000..67bb596a8 --- /dev/null +++ b/docs/internals/dot-matrix-status-language.md @@ -0,0 +1,82 @@ +# Dot Matrix status language + +Pylon uses one 5×5 Dot Matrix primitive for compact lifecycle status on the web +and desktop surfaces. The component is adapted from assistant-ui's standalone +Dot Matrix and adds Pylon-specific orchestration, queue, and terminal states +for product concepts that the reference palette does not represent. + +## Truth contract + +A pattern represents a known lifecycle fact. Motion alone does not mean +"Working," and a label must not upgrade queued or waiting work into active work. + +| Product fact | Dot Matrix state | Motion | +| ------------------------------------------------ | ------------------------------- | ----------------------------------- | +| Root turn is running | `loading` | deterministic twinkle | +| Provider exposes active thinking | `thinking` | diagonal wave | +| Subagent or workflow is actively running | `orchestrating` | circular fading-tail orbit | +| Work is queued but has not started | `queued` | static ellipsis | +| Work is waiting or blocked without user approval | `waiting` | animated ellipsis | +| Watch loop is active | `listening` | column activity | +| Environment is connecting | `connecting` | outward ripple | +| Connection is healthy | `success` | static check | +| Server update is downloading or restarting | `downloading` / `syncing` | directional / rotational wave | +| Terminal identity is inactive or complete | `terminal` | static prompt and three-dot cursor | +| Terminal indicator is active outside a live row | `terminal-active` | synchronized prompt breath | +| Terminal work is streaming in the transcript | `terminal` inside the live row | one foreground sweep with its label | +| Successful, failed, or warning outcome | `success` / `error` / `warning` | semantic glyph | +| Paused, stopped, or offline | matching state | static | + +The full assistant-ui palette remains available for facts such as streaming, +searching, uploading, downloading, listening, speaking, and recording. Callers +must not select a more specific state unless Pylon has that fact. + +The circular `orchestrating` state is a Pylon extension. It is reserved for +subagent and workflow coordination. Generic root work must not use it. The +static `queued` state distinguishes pending work from animated waiting. The +The `terminal` and `terminal-active` states are Pylon extensions. Active thread +and sidebar terminal indicators use the synchronized breathing variant. A streaming +transcript row keeps the terminal glyph itself static and sweeps one foreground +highlight across both the glyph and its label, avoiding competing animation. + +## Color + +Neutral activity uses `currentColor` through the semantic `text-foreground` +token. It appears near-white on dark themes and near-black on light themes. +Callers do not force blue for generic activity. + +Color is reserved for meaning: + +- healthy or successful: `text-success`; +- failure and recording: `text-destructive`; +- warning or approval: `text-warning`; +- informational state: `text-primary`; +- resting and disconnected state: `text-muted-foreground`. + +## Motion and accessibility + +Only live or attention states receive CSS animation timelines. Static outcomes +and resting states do not animate. Dot timing is deterministic so server and +client markup agree. Smooth opacity timing is the client-local default. Users +can select Efficient stepped timing in Settings → Appearance → Status language +for lower continuous rendering cost on their device. `prefers-reduced-motion` +overrides either choice and disables every Dot Matrix animation while keeping +the state pattern and tone visible. + +A meaningful standalone matrix with a label has `role="img"`. Without a label, +the primitive is decorative by default. Adjacent status text owns live-region +semantics so screen readers do not announce the same state twice. + +## Settings catalog + +Settings → Appearance → Status language exposes the full state vocabulary on +demand through View catalog. The catalog mounts its live animations only while +open, and also shows the composed streaming terminal row, which is not a +separate Dot Matrix state. Keep this catalog complete whenever a state is added +or removed. + +## Surfaces + +The web primitive is shared by the browser and Electron desktop client. Mobile +uses a separate React Native indicator and must map the same lifecycle facts, +but it does not share this SVG or CSS implementation. diff --git a/docs/user/status-indicators.md b/docs/user/status-indicators.md new file mode 100644 index 000000000..3ff79641d --- /dev/null +++ b/docs/user/status-indicators.md @@ -0,0 +1,26 @@ +# Status indicators + +Pylon uses compact Dot Matrix patterns to distinguish active work, queued work, +waiting, orchestration, connection phases, terminal activity, and settled +outcomes. Motion represents a known active state rather than a generic +“Working” label. + +## Choose the motion style + +1. Open **Settings**. +2. Select **Appearance**. +3. Find **Status language**. +4. Set **Status motion** to one of these options: + - **Smooth** uses fluid fades and is the default. + - **Efficient** uses stepped frames to reduce continuous rendering on the + current device. + +Your operating system’s Reduce Motion preference pauses status animation in +either mode. + +## Preview the status language + +Under **Status catalog**, select **View catalog** to inspect every pattern and +its meaning. The catalog also previews the streaming terminal-row treatment. +Select **Hide catalog** when you are done; its live previews are removed rather +than continuing to animate in the background. diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index a128f88c7..12970b79a 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -98,6 +98,21 @@ describe("ClientSettings appearance contrast", () => { }); }); +describe("ClientSettings Dot Matrix motion", () => { + it("defaults to smooth motion", () => { + expect(decodeClientSettings({}).dotMatrixMotion).toBe("smooth"); + }); + + it.each(["smooth", "efficient"] as const)("accepts %s motion", (value) => { + expect(decodeClientSettingsPatch({ dotMatrixMotion: value }).dotMatrixMotion).toBe(value); + }); + + it("rejects unsupported motion styles", () => { + expect(() => decodeClientSettings({ dotMatrixMotion: "off" })).toThrow(); + expect(() => decodeClientSettingsPatch({ dotMatrixMotion: "off" })).toThrow(); + }); +}); + describe("ClientSettings environment identification", () => { it("defaults to artwork and accepts each presentation mode", () => { expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 74f18373e..5e1faf524 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -83,6 +83,10 @@ export const AppearanceContrast = Schema.Int.check( ); export type AppearanceContrast = typeof AppearanceContrast.Type; export const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100; + +export const DotMatrixMotion = Schema.Literals(["smooth", "efficient"]); +export type DotMatrixMotion = typeof DotMatrixMotion.Type; +export const DEFAULT_DOT_MATRIX_MOTION: DotMatrixMotion = "smooth"; /** * Font size preferences, in CSS pixels. The ranges are deliberately narrow: * the interface size scales every rem-based dimension in the app, so the @@ -144,6 +148,9 @@ export const ClientSettingsSchema = Schema.Struct({ appearanceContrast: AppearanceContrast.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)), ), + dotMatrixMotion: DotMatrixMotion.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_DOT_MATRIX_MOTION)), + ), browserDefaultViewport: PreviewViewportSetting.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_VIEWPORT)), ), @@ -961,6 +968,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ appearanceContrast: Schema.optionalKey(AppearanceContrast), + dotMatrixMotion: Schema.optionalKey(DotMatrixMotion), browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting), browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor), browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),