@guuey/agent-layout 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Tone math (guuey#403 §2) — the two-panel tone pair, its perceptibility
3
+ * floor, and the chat-palette derivation.
4
+ *
5
+ * The floor is CALIBRATED, not invented (ggui#633's scars): the console's
6
+ * first cut at ΔL* 3.5 measured below perception; their shipped pair
7
+ * (#F4F3ED warm paper vs #E4E4E2 neutral chrome) reads clearly at ΔL* 5.2
8
+ * because the warm→neutral TEMPERATURE step does perceptual work lightness
9
+ * alone doesn't. Hence the OR-form floor:
10
+ *
11
+ * ΔL* ≥ 6 — OR — a hue-temperature step (Δab ≥ 2) with ΔL* ≥ 5
12
+ *
13
+ * Both arms reject the failed 3.5 cut; the strict arm alone would have
14
+ * rejected the founder-certified shipped pair. The platform's theme-doc
15
+ * gate applies its own mirror of this rule server-side; this module is the
16
+ * lib's own honesty (derived defaults satisfy the floor BY CONSTRUCTION —
17
+ * pairs that land under it get the documented nudge apart, never a silent
18
+ * pass). Calibration may RAISE the floor; it does not lower it.
19
+ */
20
+ /** A resolved tone pair for one mode: two backgrounds + their foregrounds. */
21
+ export interface TonePair {
22
+ /** Upper panel (app menus) background. */
23
+ upper: string;
24
+ /** Foreground on the upper tone. */
25
+ upperOn: string;
26
+ /** Lower panel (agent) background. */
27
+ lower: string;
28
+ /** Foreground on the lower tone. */
29
+ lowerOn: string;
30
+ }
31
+ /**
32
+ * The lib base defaults — the ggui#633 SHIPPED pair for light, and its
33
+ * temperature-step mirror for dark (warm umber menu vs neutral chrome,
34
+ * ΔL* 6.5 — the strict arm). Founder-certified "two-toned and
35
+ * well-distinguished" in light; the dark pair keeps the identical warm→
36
+ * neutral grammar so a mode flip changes brightness, not the layout's
37
+ * character. Both pairs assert the floor in this package's tests.
38
+ */
39
+ export declare const DEFAULT_TONES: Record<"light" | "dark", TonePair>;
40
+ /** CIELAB (D65) from a `#rrggbb` hex. Throws on a malformed color. */
41
+ export declare function hexToLab(hex: string): {
42
+ L: number;
43
+ a: number;
44
+ b: number;
45
+ };
46
+ /** The perceptibility deltas between two backgrounds. */
47
+ export declare function toneDelta(upper: string, lower: string): {
48
+ dL: number;
49
+ dab: number;
50
+ };
51
+ /** The OR-form floor (module header). */
52
+ export declare function meetsToneFloor(upper: string, lower: string): boolean;
53
+ /**
54
+ * Reject-under-floor with the explanatory message (the brandAccent
55
+ * posture): overrides that fail perceptibility throw HERE, at wiring time,
56
+ * never render as an invisible seam.
57
+ */
58
+ export declare function assertToneFloor(pair: TonePair): void;
59
+ /**
60
+ * Base-tier derivation (§2, platform's pick): tones DERIVE from the chat
61
+ * palette's surface/canvas pair, so an already-themed app gets coherent
62
+ * tones for free — theming chat themes the layout, no second authoring
63
+ * step. `surface` grounds the AGENT (lower) panel — it is the chat's own
64
+ * ground — and `canvas` grounds the MENU (upper) panel. Pairs that land
65
+ * under the floor get the documented nudge: the upper tone walks away from
66
+ * the lower along the lightness axis (toward white below L* 50, toward
67
+ * black above — always INTO the pair's existing contrast direction) until
68
+ * the strict arm passes. Deterministic, bounded, and asserted by tests.
69
+ */
70
+ export declare function deriveTones(palette: {
71
+ surface: string;
72
+ canvas: string;
73
+ ink: string;
74
+ }): Pick<TonePair, "upper" | "lower"> & {
75
+ nudged: boolean;
76
+ };
77
+ /** Linear sRGB-space hex mix (`t` toward `target`). Exported for tests. */
78
+ export declare function mixHex(base: string, target: string, t: number): string;
79
+ //# sourceMappingURL=tones.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tones.d.ts","sourceRoot":"","sources":["../src/tones.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,8EAA8E;AAC9E,MAAM,WAAW,QAAQ;IACvB,0CAA0C;IAC1C,KAAK,EAAE,MAAM,CAAC;IACd,oCAAoC;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,oCAAoC;IACpC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,OAAO,GAAG,MAAM,EAAE,QAAQ,CAa5D,CAAC;AAEF,sEAAsE;AACtE,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,CAsBzE;AAED,yDAAyD;AACzD,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAInF;AAED,yCAAyC;AACzC,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAGpE;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,QAAQ,GAAG,IAAI,CAQpD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;CACb,GAAG,IAAI,CAAC,QAAQ,EAAE,OAAO,GAAG,OAAO,CAAC,GAAG;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,CAe1D;AAED,2EAA2E;AAC3E,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CActE"}
package/dist/tones.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Tone math (guuey#403 §2) — the two-panel tone pair, its perceptibility
3
+ * floor, and the chat-palette derivation.
4
+ *
5
+ * The floor is CALIBRATED, not invented (ggui#633's scars): the console's
6
+ * first cut at ΔL* 3.5 measured below perception; their shipped pair
7
+ * (#F4F3ED warm paper vs #E4E4E2 neutral chrome) reads clearly at ΔL* 5.2
8
+ * because the warm→neutral TEMPERATURE step does perceptual work lightness
9
+ * alone doesn't. Hence the OR-form floor:
10
+ *
11
+ * ΔL* ≥ 6 — OR — a hue-temperature step (Δab ≥ 2) with ΔL* ≥ 5
12
+ *
13
+ * Both arms reject the failed 3.5 cut; the strict arm alone would have
14
+ * rejected the founder-certified shipped pair. The platform's theme-doc
15
+ * gate applies its own mirror of this rule server-side; this module is the
16
+ * lib's own honesty (derived defaults satisfy the floor BY CONSTRUCTION —
17
+ * pairs that land under it get the documented nudge apart, never a silent
18
+ * pass). Calibration may RAISE the floor; it does not lower it.
19
+ */
20
+ /**
21
+ * The lib base defaults — the ggui#633 SHIPPED pair for light, and its
22
+ * temperature-step mirror for dark (warm umber menu vs neutral chrome,
23
+ * ΔL* 6.5 — the strict arm). Founder-certified "two-toned and
24
+ * well-distinguished" in light; the dark pair keeps the identical warm→
25
+ * neutral grammar so a mode flip changes brightness, not the layout's
26
+ * character. Both pairs assert the floor in this package's tests.
27
+ */
28
+ export const DEFAULT_TONES = {
29
+ light: {
30
+ upper: "#F4F3ED",
31
+ upperOn: "#1F1E1B",
32
+ lower: "#E4E4E2",
33
+ lowerOn: "#1A1A1C",
34
+ },
35
+ dark: {
36
+ upper: "#302B24",
37
+ upperOn: "#ECE9E2",
38
+ lower: "#1E1E21",
39
+ lowerOn: "#E3E3E5",
40
+ },
41
+ };
42
+ /** CIELAB (D65) from a `#rrggbb` hex. Throws on a malformed color. */
43
+ export function hexToLab(hex) {
44
+ const m = /^#([0-9a-fA-F]{6})$/.exec(hex.trim());
45
+ if (m === null) {
46
+ throw new Error(`agent-layout tones: "${hex}" is not a #rrggbb color — tone math needs resolvable hex (CSS keywords and var() belong to the stylesheet tier, not the derivation door).`);
47
+ }
48
+ const h = m[1];
49
+ const chan = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
50
+ const lin = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
51
+ const R = lin(chan(0));
52
+ const G = lin(chan(2));
53
+ const B = lin(chan(4));
54
+ const X = (0.4124 * R + 0.3576 * G + 0.1805 * B) / 0.95047;
55
+ const Y = 0.2126 * R + 0.7152 * G + 0.0722 * B;
56
+ const Z = (0.0193 * R + 0.1192 * G + 0.9505 * B) / 1.08883;
57
+ const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
58
+ return {
59
+ L: 116 * f(Y) - 16,
60
+ a: 500 * (f(X) - f(Y)),
61
+ b: 200 * (f(Y) - f(Z)),
62
+ };
63
+ }
64
+ /** The perceptibility deltas between two backgrounds. */
65
+ export function toneDelta(upper, lower) {
66
+ const A = hexToLab(upper);
67
+ const B = hexToLab(lower);
68
+ return { dL: Math.abs(A.L - B.L), dab: Math.hypot(A.a - B.a, A.b - B.b) };
69
+ }
70
+ /** The OR-form floor (module header). */
71
+ export function meetsToneFloor(upper, lower) {
72
+ const { dL, dab } = toneDelta(upper, lower);
73
+ return dL >= 6 || (dab >= 2 && dL >= 5);
74
+ }
75
+ /**
76
+ * Reject-under-floor with the explanatory message (the brandAccent
77
+ * posture): overrides that fail perceptibility throw HERE, at wiring time,
78
+ * never render as an invisible seam.
79
+ */
80
+ export function assertToneFloor(pair) {
81
+ if (meetsToneFloor(pair.upper, pair.lower))
82
+ return;
83
+ const { dL, dab } = toneDelta(pair.upper, pair.lower);
84
+ throw new Error(`agent-layout tones: the upper/lower pair ${pair.upper}/${pair.lower} is below the perceptibility floor ` +
85
+ `(ΔL* ${dL.toFixed(1)}, Δab ${dab.toFixed(1)}; needs ΔL* ≥ 6, or a temperature step Δab ≥ 2 with ΔL* ≥ 5). ` +
86
+ `Two tones that measure alike defeat the category's defining behavior — pick a stronger pair.`);
87
+ }
88
+ /**
89
+ * Base-tier derivation (§2, platform's pick): tones DERIVE from the chat
90
+ * palette's surface/canvas pair, so an already-themed app gets coherent
91
+ * tones for free — theming chat themes the layout, no second authoring
92
+ * step. `surface` grounds the AGENT (lower) panel — it is the chat's own
93
+ * ground — and `canvas` grounds the MENU (upper) panel. Pairs that land
94
+ * under the floor get the documented nudge: the upper tone walks away from
95
+ * the lower along the lightness axis (toward white below L* 50, toward
96
+ * black above — always INTO the pair's existing contrast direction) until
97
+ * the strict arm passes. Deterministic, bounded, and asserted by tests.
98
+ */
99
+ export function deriveTones(palette) {
100
+ const lower = palette.surface;
101
+ let upper = palette.canvas;
102
+ let nudged = false;
103
+ if (!meetsToneFloor(upper, lower)) {
104
+ nudged = true;
105
+ const lowerL = hexToLab(lower).L;
106
+ // Walk upper away from lower in sRGB mix steps until ΔL* ≥ 6. The mix
107
+ // target keeps the walk inside the pair's own contrast direction.
108
+ const towardWhite = hexToLab(upper).L >= lowerL;
109
+ for (let i = 1; i <= 20 && !(toneDelta(upper, lower).dL >= 6); i++) {
110
+ upper = mixHex(palette.canvas, towardWhite ? "#FFFFFF" : "#000000", i * 0.05);
111
+ }
112
+ }
113
+ return { upper, lower, nudged };
114
+ }
115
+ /** Linear sRGB-space hex mix (`t` toward `target`). Exported for tests. */
116
+ export function mixHex(base, target, t) {
117
+ const pb = /^#([0-9a-fA-F]{6})$/.exec(base.trim());
118
+ const pt = /^#([0-9a-fA-F]{6})$/.exec(target.trim());
119
+ if (pb === null || pt === null)
120
+ throw new Error(`agent-layout tones: mixHex needs #rrggbb inputs`);
121
+ const out = [0, 2, 4]
122
+ .map((i) => {
123
+ const b = parseInt(pb[1].slice(i, i + 2), 16);
124
+ const g = parseInt(pt[1].slice(i, i + 2), 16);
125
+ return Math.round(b + (g - b) * Math.min(1, Math.max(0, t)))
126
+ .toString(16)
127
+ .padStart(2, "0");
128
+ })
129
+ .join("");
130
+ return `#${out.toUpperCase()}`;
131
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@guuey/agent-layout",
3
+ "version": "0.16.0",
4
+ "description": "Agent-mode layout: the two-tone shell where an app and its agent share one surface — the pane follows attention, the transport owns the stream.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/withguuey/guuey-sdks.git",
9
+ "directory": "packages/agent-layout"
10
+ },
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "default": "./dist/index.js"
17
+ },
18
+ "./react": {
19
+ "types": "./dist/react.d.ts",
20
+ "import": "./dist/react.js",
21
+ "default": "./dist/react.js"
22
+ },
23
+ "./styles.css": "./styles.css"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src",
28
+ "!src/**/*.test.ts",
29
+ "!src/**/*.test.tsx",
30
+ "styles.css",
31
+ "README.md"
32
+ ],
33
+ "peerDependencies": {
34
+ "react": ">=18"
35
+ },
36
+ "devDependencies": {
37
+ "@testing-library/dom": "^10.0.0",
38
+ "@testing-library/react": "^16.3.2",
39
+ "@types/node": "^24.0.0",
40
+ "@types/react": "^19.0.0",
41
+ "@types/react-dom": "^19.0.0",
42
+ "jsdom": "^25.0.1",
43
+ "react": "^19.0.0",
44
+ "react-dom": "^19.0.0",
45
+ "typescript": "^5.0.0",
46
+ "vitest": "^3.2.7"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc -p tsconfig.build.json",
50
+ "dev": "tsc --watch",
51
+ "typecheck": "tsc --noEmit",
52
+ "test": "vitest run",
53
+ "test:watch": "vitest"
54
+ }
55
+ }
package/src/bind.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The `@guuey/chat` bridge (guuey#403 §4) — pairs with the kit WITHOUT
3
+ * importing it. This package deliberately has NO dependency on
4
+ * `@guuey/chat` (the layout stays consumable by a surface that brings its
5
+ * own agent — ggui console's path calls `agentSubmit()` from
6
+ * `useAgentMode()` directly), so the kit's shapes are mirrored here
7
+ * STRUCTURALLY, the fs-contract discipline:
8
+ *
9
+ * - {@link GuueyChatActivityEventShape} mirrors `GuueyChatActivityEvent`
10
+ * (oss/packages/chat/src/react/guuey-chat.tsx — sync comment on the
11
+ * definition points back here);
12
+ * - {@link PlanViewSummaryShape} mirrors the two fields of
13
+ * `PlanViewSummary` this bridge reads (`key`, `origin`).
14
+ *
15
+ * The template's typecheck is the seam prover: it passes GuueyChat's real
16
+ * callbacks into these structural slots — a drift on either side breaks
17
+ * the template build, never silently.
18
+ */
19
+ import type { AgentModeInput } from "./machine.js";
20
+
21
+ /**
22
+ * Structural mirror of `GuueyChatActivityEvent` (@guuey/chat). `submit`
23
+ * fires on every successful send — composer, `handle.send`, and the
24
+ * `ui/message` doorbell paths alike; `settled` fires when the invoke
25
+ * returns to `ready` (success, error, and abort all settle).
26
+ */
27
+ export interface GuueyChatActivityEventShape {
28
+ type: "submit" | "settled";
29
+ }
30
+
31
+ /** The two roster fields the bridge reads (structural `PlanViewSummary`). */
32
+ export interface PlanViewSummaryShape {
33
+ key: string;
34
+ origin: "live" | "history";
35
+ }
36
+
37
+ /** What the bridge needs from the layout side — `useAgentMode().dispatch`. */
38
+ export type AgentModeBinding = (input: AgentModeInput) => void;
39
+
40
+ /** The GuueyChat-shaped props {@link bindGuueyChat} returns. */
41
+ export interface GuueyChatBindingProps {
42
+ onActivity: (event: GuueyChatActivityEventShape) => void;
43
+ onViewsChange: (views: readonly PlanViewSummaryShape[]) => void;
44
+ }
45
+
46
+ /**
47
+ * Wire a GuueyChat surface into the active-panel machine: spread the
48
+ * returned props onto `<GuueyChat>` (or compose them inside your own
49
+ * handlers — each is a plain function).
50
+ *
51
+ * - `submit` → `agentSubmit` (tone flips ON SUBMIT — the §3 law);
52
+ * - `settled` → `agentSettled` (no tone transition; ends the working
53
+ * state and the streaming flag);
54
+ * - a roster change whose LIVE view count GREW → `agentViewMounted`
55
+ * (the founder's (d): content replaced the working state). Counting
56
+ * growth — not presence — keeps earlier turns' lingering live views
57
+ * and rehydrated history views from clearing a fresh submit's pending
58
+ * window spuriously.
59
+ */
60
+ export function bindGuueyChat(dispatch: AgentModeBinding): GuueyChatBindingProps {
61
+ let liveCount = 0;
62
+ return {
63
+ onActivity: (event) => {
64
+ if (event.type === "submit") dispatch({ type: "agentSubmit" });
65
+ else dispatch({ type: "agentSettled" });
66
+ },
67
+ onViewsChange: (views) => {
68
+ const next = views.filter((v) => v.origin === "live").length;
69
+ if (next > liveCount) dispatch({ type: "agentViewMounted" });
70
+ liveCount = next;
71
+ },
72
+ };
73
+ }
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `@guuey/agent-layout` — the agent-mode layout category (guuey#403).
3
+ *
4
+ * Root entry: the pure machine, the tone math, the token names, and the
5
+ * `@guuey/chat` bridge — everything React-free. Components live under
6
+ * `@guuey/agent-layout/react`; the stylesheet under
7
+ * `@guuey/agent-layout/styles.css`.
8
+ */
9
+
10
+ export {
11
+ agentModeReduce,
12
+ INITIAL_AGENT_MODE_STATE,
13
+ type ActivePanel,
14
+ type AgentModeInput,
15
+ type AgentModeState,
16
+ } from "./machine.js";
17
+ export {
18
+ assertToneFloor,
19
+ DEFAULT_TONES,
20
+ deriveTones,
21
+ hexToLab,
22
+ meetsToneFloor,
23
+ mixHex,
24
+ toneDelta,
25
+ type TonePair,
26
+ } from "./tones.js";
27
+ export {
28
+ bindGuueyChat,
29
+ type AgentModeBinding,
30
+ type GuueyChatActivityEventShape,
31
+ type GuueyChatBindingProps,
32
+ type PlanViewSummaryShape,
33
+ } from "./bind.js";
34
+
35
+ /**
36
+ * The token vocabulary (§2) — one prefix, kebab-case, `-on` suffix keeps
37
+ * each tone's pair adjacent in sorted listings (platform-ruled naming).
38
+ * `--guuey-layout-pane-tone` is LIB-WRITTEN state: apps read it, never set
39
+ * it — overriding it silently breaks the category's defining behavior.
40
+ */
41
+ export const LAYOUT_TOKENS = {
42
+ toneUpper: "--guuey-layout-tone-upper",
43
+ toneUpperOn: "--guuey-layout-tone-upper-on",
44
+ toneLower: "--guuey-layout-tone-lower",
45
+ toneLowerOn: "--guuey-layout-tone-lower-on",
46
+ paneTone: "--guuey-layout-pane-tone",
47
+ toneTransition: "--guuey-layout-tone-transition",
48
+ } as const;
49
+
50
+ /**
51
+ * The follow fade — founder-certified on the ggui#633 calibration: the
52
+ * eased ~150ms color fade ("arrives fast, lands soft" at panel scale).
53
+ */
54
+ export const DEFAULT_TONE_TRANSITION_MS = 150;
package/src/machine.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The active-panel machine (guuey#403 §3) — pure data-in/data-out, no React.
3
+ *
4
+ * ONE law (platform's words): **layout follows attention; transport owns
5
+ * the stream.** The tone tracks the user's locus of action — submit flips
6
+ * it ON SUBMIT (not first token, not completion), navigation flips it back,
7
+ * and stream end changes nothing.
8
+ *
9
+ * State beyond the panel itself (the ggui#633 calibration additions):
10
+ *
11
+ * - `streaming` — a turn is in flight. Read by ONE rule only: closing an
12
+ * agent view returns to the menu UNLESS still streaming (the console's
13
+ * shipped behavior).
14
+ * - `pending` — the founder's (d) requirement: on flip to the agent tone
15
+ * with no view mounted yet, the pane must NEVER hold prior page content
16
+ * on the agent ground — it presents the working state immediately.
17
+ * `pending` arms on submit and clears on view-mount, on settle (the
18
+ * answer is in the log even when no view came), and on return-to-menu.
19
+ *
20
+ * The machine is consumed through `useAgentMode()` / the Provider; this
21
+ * module is exported for tests and for non-React hosts that want the exact
22
+ * transition table.
23
+ */
24
+
25
+ export type ActivePanel = "app" | "agent";
26
+
27
+ export interface AgentModeState {
28
+ activePanel: ActivePanel;
29
+ /** A turn is in flight (submit → settled). */
30
+ streaming: boolean;
31
+ /** Working-state window: submitted, nothing presented yet (founder (d)). */
32
+ pending: boolean;
33
+ }
34
+
35
+ export const INITIAL_AGENT_MODE_STATE: AgentModeState = {
36
+ // A surface opens as the app; the agent earns the room by being addressed.
37
+ activePanel: "app",
38
+ streaming: false,
39
+ pending: false,
40
+ };
41
+
42
+ export type AgentModeInput =
43
+ /** App-side chrome interaction (NavLink click, panel focus) OR a route change. */
44
+ | { type: "menuInteraction" }
45
+ /** The user sent a message — the agent bridge fired. */
46
+ | { type: "agentSubmit" }
47
+ /** The turn reached `ready`. NO panel transition (platform-ruled: no bounce-back). */
48
+ | { type: "agentSettled" }
49
+ /** A live view mounted — the working state has been replaced by content. */
50
+ | { type: "agentViewMounted" }
51
+ /** The user closed an agent view (canvas "back" affordance). */
52
+ | { type: "agentViewClosed" };
53
+
54
+ /**
55
+ * The transition table (§3, verbatim rules). Pure; last input wins — the
56
+ * transition duration is the only debounce (no timers, no queue).
57
+ */
58
+ export function agentModeReduce(state: AgentModeState, input: AgentModeInput): AgentModeState {
59
+ switch (input.type) {
60
+ case "menuInteraction":
61
+ // Re-follows the user WITHOUT touching the stream (mid-stream click
62
+ // included — interruption semantics live in transport, never here).
63
+ return { ...state, activePanel: "app", pending: false };
64
+ case "agentSubmit":
65
+ return { activePanel: "agent", streaming: true, pending: true };
66
+ case "agentSettled":
67
+ // Stream end changes nothing about the panel — the tone tracks the
68
+ // user's locus of action, not the agent's state.
69
+ return { ...state, streaming: false, pending: false };
70
+ case "agentViewMounted":
71
+ return { ...state, pending: false };
72
+ case "agentViewClosed":
73
+ // Closing a view returns to the menu UNLESS still streaming.
74
+ return state.streaming ? state : { ...state, activePanel: "app" };
75
+ }
76
+ }