@gemboss/ui 0.1.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.
- package/README.md +208 -0
- package/catalog/index.html +423 -0
- package/gemboss-ui.css +2889 -0
- package/package.json +51 -0
- package/src/GemMark.tsx +22 -0
- package/src/account.tsx +88 -0
- package/src/app-shell.css +65 -0
- package/src/assistant/dock.ts +142 -0
- package/src/assistant/host.tsx +371 -0
- package/src/assistant/index.ts +6 -0
- package/src/assistant/provider.tsx +269 -0
- package/src/assistant/sessions.tsx +450 -0
- package/src/assistant.css +407 -0
- package/src/auth.css +606 -0
- package/src/auth.tsx +202 -0
- package/src/brand.tsx +31 -0
- package/src/gemboss-field.css +108 -0
- package/src/gemboss-tokens.css +164 -0
- package/src/icons.tsx +75 -0
- package/src/index.ts +9 -0
- package/src/shell.css +1069 -0
- package/src/shell.tsx +212 -0
- package/src/ui.css +403 -0
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gemboss/ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "GemBoss design system for every surface in the fleet: the admin shell (topbar + rail + drawer) as React components, and the admin stylesheet exported as one CSS file.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"*.css"
|
|
9
|
+
],
|
|
10
|
+
"main": "./src/index.ts",
|
|
11
|
+
"types": "./src/index.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.ts",
|
|
14
|
+
"./gemboss-ui.css": "./gemboss-ui.css",
|
|
15
|
+
"./gemboss-tokens.css": "./src/gemboss-tokens.css",
|
|
16
|
+
"./ui.css": "./src/ui.css",
|
|
17
|
+
"./gemboss-field.css": "./src/gemboss-field.css",
|
|
18
|
+
"./app-shell.css": "./src/app-shell.css",
|
|
19
|
+
"./shell.css": "./src/shell.css",
|
|
20
|
+
"./auth.css": "./src/auth.css",
|
|
21
|
+
"./assistant": "./src/assistant/index.ts",
|
|
22
|
+
"./assistant/dock": "./src/assistant/dock.ts",
|
|
23
|
+
"./assistant.css": "./src/assistant.css"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src/**/*.ts",
|
|
27
|
+
"src/**/*.tsx",
|
|
28
|
+
"src/*.css",
|
|
29
|
+
"!src/**/*.test.*",
|
|
30
|
+
"gemboss-ui.css",
|
|
31
|
+
"catalog/index.html",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build:css": "node scripts/build-css.mjs",
|
|
36
|
+
"prepack": "node scripts/build-css.mjs && node scripts/build-catalog.mjs",
|
|
37
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
38
|
+
"catalog": "node scripts/build-css.mjs && node scripts/build-catalog.mjs"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"react": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "https://github.com/chriskoh26/gemboss",
|
|
49
|
+
"directory": "packages/ui"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/GemMark.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The brilliant-cut gem: white outline on an ink tile, iris only in the table facet. The same
|
|
3
|
+
* geometry as public/gemboss-mark.svg and the admin sign-in; content-radar, gemwatcher and the
|
|
4
|
+
* community login each carried a hand copy of these three paths.
|
|
5
|
+
*/
|
|
6
|
+
import React from "react";
|
|
7
|
+
|
|
8
|
+
export function GemMark({ size = 32, tile = "var(--gb-mark-bg, #0b0b0c)" }: { size?: number; tile?: string }) {
|
|
9
|
+
return (
|
|
10
|
+
<svg viewBox="0 0 32 32" width={size} height={size} aria-hidden="true" focusable="false">
|
|
11
|
+
{/* `style`, not the `fill` attribute: var() in a presentation attribute is not read by every
|
|
12
|
+
browser, and the fallback is near-black anyway, so a lost override would go unnoticed. */}
|
|
13
|
+
<rect width="32" height="32" rx="7" style={{ fill: tile }} />
|
|
14
|
+
<g transform="translate(16,16) scale(0.24) translate(-50,-62)">
|
|
15
|
+
<polygon points="26,14 74,14 66,50 34,50" fill="#8a93ff" />
|
|
16
|
+
<path d="M26,14 74,14 96,50 50,110 4,50Z" fill="none" stroke="#f4f4f2" strokeWidth="5" strokeLinejoin="round" />
|
|
17
|
+
<path d="M4,50H96M26,14 34,50M74,14 66,50M34,50 50,110M66,50 50,110" fill="none" stroke="#f4f4f2"
|
|
18
|
+
strokeOpacity="0.72" strokeWidth="3" strokeLinejoin="round" />
|
|
19
|
+
</g>
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
}
|
package/src/account.tsx
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The account chip at the right of the topbar, and its menu — @gemboss/ui (28/09/2026).
|
|
5
|
+
*
|
|
6
|
+
* The admin's is `AccountChip` (apps/admin/src/components/shell/AccountChip.tsx): a 32px initials
|
|
7
|
+
* circle that opens a popover with name, email and role, then the menu items. Its CSS
|
|
8
|
+
* (`.gemboss-account-chip / -menu / -menu-item`, on `.gemboss-popover-surface`) now reaches other
|
|
9
|
+
* surfaces through gemboss-ui.css; this renders the same classes, so the look has one owner.
|
|
10
|
+
*
|
|
11
|
+
* A `<details>`, not state: it opens and closes with no JavaScript at all, so a surface that renders
|
|
12
|
+
* static HTML (GemFactory) gets a working menu from the markup alone. Where React does hydrate, the
|
|
13
|
+
* effect below adds what `<details>` lacks: close on a click outside, and on Escape.
|
|
14
|
+
*/
|
|
15
|
+
import React, { useEffect, useRef } from "react";
|
|
16
|
+
|
|
17
|
+
export function initialsOf(name: string): string {
|
|
18
|
+
const words = name.trim().split(/\s+/).filter(Boolean);
|
|
19
|
+
if (!words.length) return "?";
|
|
20
|
+
const letters = words.length === 1 ? words[0].slice(0, 2) : words[0][0] + words[words.length - 1][0];
|
|
21
|
+
return letters.toUpperCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function AccountMenu({ name, email, role, initials, label, children }: {
|
|
25
|
+
name: string;
|
|
26
|
+
email?: string;
|
|
27
|
+
role?: string;
|
|
28
|
+
/** Defaults to the first and last word's first letters ("Minh Anh" → "MA"). */
|
|
29
|
+
initials?: string;
|
|
30
|
+
/** Accessible name of the chip. Default "Account · <name>". */
|
|
31
|
+
label?: string;
|
|
32
|
+
/** The menu's items: `AccountMenuItem`s, or any element wearing `gemboss-account-menu-item`. */
|
|
33
|
+
children?: React.ReactNode;
|
|
34
|
+
}) {
|
|
35
|
+
const ref = useRef<HTMLDetailsElement>(null);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const el = ref.current;
|
|
38
|
+
if (!el) return undefined;
|
|
39
|
+
const onDoc = (e: MouseEvent) => { if (el.open && !el.contains(e.target as Node)) el.open = false; };
|
|
40
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && el.open) { el.open = false; el.querySelector("summary")?.focus(); } };
|
|
41
|
+
document.addEventListener("click", onDoc);
|
|
42
|
+
document.addEventListener("keydown", onKey);
|
|
43
|
+
return () => { document.removeEventListener("click", onDoc); document.removeEventListener("keydown", onKey); };
|
|
44
|
+
}, []);
|
|
45
|
+
return (
|
|
46
|
+
<details ref={ref} className="gb-account">
|
|
47
|
+
<summary className="gemboss-account-chip" aria-label={label ?? `Account · ${name}`} title={name}>
|
|
48
|
+
{initials ?? initialsOf(name)}
|
|
49
|
+
</summary>
|
|
50
|
+
<div role="menu" className="gemboss-popover-surface gemboss-account-menu">
|
|
51
|
+
<div className="gb-account-head">
|
|
52
|
+
<div className="gb-account-name">{name}</div>
|
|
53
|
+
{email ? <div className="gb-account-email">{email}</div> : null}
|
|
54
|
+
{role ? <div className="gb-account-role">{role}</div> : null}
|
|
55
|
+
</div>
|
|
56
|
+
{children}
|
|
57
|
+
</div>
|
|
58
|
+
</details>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One row of the account menu: a link (`href`), a button, or the submit button of a form — a POST
|
|
63
|
+
* sign-out works without JavaScript. `danger` for the destructive row (sign out). A button row closes
|
|
64
|
+
* the menu after its handler, as the admin's does; a link or a form leaves the page anyway. */
|
|
65
|
+
export function AccountMenuItem({ href, formAction, danger, children, onClick, ...rest }: {
|
|
66
|
+
href?: string;
|
|
67
|
+
/** Renders `<form method="post" action=…>` around a submit button. */
|
|
68
|
+
formAction?: string;
|
|
69
|
+
danger?: boolean;
|
|
70
|
+
children?: React.ReactNode;
|
|
71
|
+
onClick?: React.MouseEventHandler<HTMLElement>;
|
|
72
|
+
} & Omit<React.HTMLAttributes<HTMLElement>, "onClick"> & Pick<React.AnchorHTMLAttributes<HTMLAnchorElement>, "target" | "rel" | "download">) {
|
|
73
|
+
const className = danger ? "gemboss-account-menu-item danger" : "gemboss-account-menu-item";
|
|
74
|
+
// Every prop reaches whichever element renders — a link row used to drop `onClick`, `target`, `aria-*`.
|
|
75
|
+
if (href) return <a role="menuitem" className={className} href={href} onClick={onClick} {...rest}>{children}</a>;
|
|
76
|
+
if (formAction) {
|
|
77
|
+
return (
|
|
78
|
+
<form method="post" action={formAction} className="gb-account-form">
|
|
79
|
+
<button role="menuitem" type="submit" className={className} onClick={onClick} {...rest}>{children}</button>
|
|
80
|
+
</form>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const close: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
|
84
|
+
onClick?.(e);
|
|
85
|
+
e.currentTarget.closest("details")?.removeAttribute("open");
|
|
86
|
+
};
|
|
87
|
+
return <button role="menuitem" type="button" className={className} onClick={close} {...rest}>{children}</button>;
|
|
88
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/* Styles for the parts of the shell the PACKAGE owns — `AppShell`'s three topbar slots,
|
|
2
|
+
* `RailHeading`, `RailCount`. Everything else (rail, topbar, drawer, tokens, kit) is the admin's
|
|
3
|
+
* own CSS, exported by apps/admin/scripts/export-ui-css.mjs and concatenated ahead of this file into
|
|
4
|
+
* `gemboss-ui.css`. The admin does not load this file: it composes the shell itself and has no
|
|
5
|
+
* slots, headings or pills of this kind.
|
|
6
|
+
*
|
|
7
|
+
* Built only from --gemboss-* tokens, so a palette change in gemboss-tokens.css reaches it. */
|
|
8
|
+
|
|
9
|
+
/* The base font. The admin's comes from Polaris, which no other surface loads, so without this rule
|
|
10
|
+
* every surface on gemboss-ui.css rendered in the browser's default serif. Same elements Polaris
|
|
11
|
+
* sets (html, body, button), same stack (--gemboss-font-sans resolves to Polaris's
|
|
12
|
+
* --p-font-family-sans fallback). `:where()` keeps it at (0,0,0): any rule a surface writes for
|
|
13
|
+
* these elements wins, wherever it sits in the cascade. Re-theme with the token, not a body rule.
|
|
14
|
+
* The admin does not load this file, so its own rendering is untouched. */
|
|
15
|
+
:where(html, body, button) { font-family: var(--gemboss-font-sans); }
|
|
16
|
+
|
|
17
|
+
.gemboss-topbar-start { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
18
|
+
.gemboss-topbar-center { display: flex; justify-content: center; min-width: 0; }
|
|
19
|
+
.gemboss-topbar-end { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
|
|
20
|
+
@media (max-width: 760px) {
|
|
21
|
+
/* The topbar is one flex row here: the middle slot takes what is left. */
|
|
22
|
+
.gemboss-topbar-center { flex: 1 1 auto; justify-content: flex-start; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.gnav-heading {
|
|
26
|
+
padding: 10px 12px 4px;
|
|
27
|
+
font-size: var(--gemboss-fs-micro); font-weight: 600; line-height: 1.2;
|
|
28
|
+
letter-spacing: 0.08em; text-transform: uppercase;
|
|
29
|
+
color: var(--gemboss-text-muted);
|
|
30
|
+
}
|
|
31
|
+
.gnav-scroll > .gnav-heading:first-child { padding-top: 2px; }
|
|
32
|
+
|
|
33
|
+
.gnav-count {
|
|
34
|
+
margin-left: auto; flex: 0 0 auto; min-width: 20px; padding: 0 6px;
|
|
35
|
+
border-radius: var(--gemboss-radius-full);
|
|
36
|
+
background: var(--gemboss-hover); color: var(--gemboss-text-muted);
|
|
37
|
+
font-size: var(--gemboss-fs-micro); font-weight: 700; line-height: 18px;
|
|
38
|
+
text-align: center; font-variant-numeric: tabular-nums;
|
|
39
|
+
}
|
|
40
|
+
.gnav-item.active .gnav-count { background: var(--gemboss-primary); color: var(--gemboss-on-primary); }
|
|
41
|
+
|
|
42
|
+
/* ── BrandLockup (./brand.tsx) ─────────────────────────────────────────────────────────────────
|
|
43
|
+
* The admin's `.gemboss-brand-lockup` box (40px tall, 8px padding, hover wash) around the wordmark's
|
|
44
|
+
* own proportions. Not that class itself: its ≤460px rule swaps in a background image at an
|
|
45
|
+
* admin-only path, which every other host would 404. Ink is the logo file's #14151a. */
|
|
46
|
+
.gb-brand {
|
|
47
|
+
min-width: 0; height: 40px; margin: 0; padding: 0 8px;
|
|
48
|
+
border-radius: var(--gemboss-radius-md);
|
|
49
|
+
display: inline-flex; align-items: center; gap: 4.5px;
|
|
50
|
+
color: var(--gb-brand-ink, #14151a); text-decoration: none;
|
|
51
|
+
transition: background-color var(--gemboss-transition-base) ease;
|
|
52
|
+
}
|
|
53
|
+
.gb-brand:hover { background: var(--gemboss-hover); }
|
|
54
|
+
.gb-brand:focus-visible { outline: 2px solid var(--gemboss-primary); outline-offset: 2px; }
|
|
55
|
+
.gb-brand-gem { display: block; flex: 0 0 auto; }
|
|
56
|
+
.gb-brand-name {
|
|
57
|
+
font-family: Inter, var(--gemboss-font-sans); font-size: 13.3px; font-weight: 700;
|
|
58
|
+
letter-spacing: -0.13px; line-height: 1; white-space: nowrap;
|
|
59
|
+
overflow: hidden; text-overflow: ellipsis; min-width: 0;
|
|
60
|
+
}
|
|
61
|
+
/* The admin shows only its mark below 460px; so does this. */
|
|
62
|
+
@media (max-width: 460px) { .gb-brand-name { display: none; } }
|
|
63
|
+
|
|
64
|
+
/* ── AccountMenu (./account.tsx): its rules live in shell.css since 29/09/2026, because the admin
|
|
65
|
+
* renders AccountMenu too now and loads shell.css, not this file. */
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the assistant is PRESENTED, kept apart from the assistant itself.
|
|
3
|
+
*
|
|
4
|
+
* Moved from gemboss apps/admin/src/lib/assistant-dock.ts into @gemboss/ui (28/09/2026) so every
|
|
5
|
+
* surface that has an assistant — the admin, gemwatcher, content-radar — gets the same rules for when
|
|
6
|
+
* it shows, hides and collapses. The admin file re-exports this one.
|
|
7
|
+
*
|
|
8
|
+
* The bug this exists to kill: the assistant lived only at `/app/assistant`, so opening any
|
|
9
|
+
* other menu unmounted it — and `GemChat`'s unmount cleanup aborts the in-flight run. A seller
|
|
10
|
+
* who asked for a theme and then clicked Products came back to a dead turn with no answer and
|
|
11
|
+
* no error. Nothing in the UI said the run had been thrown away.
|
|
12
|
+
*
|
|
13
|
+
* The fix is to mount the assistant ONCE in the app shell and only change its geometry. This
|
|
14
|
+
* module owns the geometry decision as a pure reducer so the transitions are testable without
|
|
15
|
+
* a browser, and so the rule "a live run is never hidden" is stated in one place instead of
|
|
16
|
+
* being spread over three components' effects.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Where the assistant is drawn. `hidden` still means MOUNTED — the run keeps going. */
|
|
20
|
+
export type AssistantPresentation = "hidden" | "mini" | "dock" | "full";
|
|
21
|
+
|
|
22
|
+
export interface AssistantDockState {
|
|
23
|
+
presentation: AssistantPresentation;
|
|
24
|
+
/** Mounted at all? The chat bundle is only pulled in once the seller first opens the assistant. */
|
|
25
|
+
mounted: boolean;
|
|
26
|
+
/** A turn is running right now (reported by the chat itself). */
|
|
27
|
+
busy: boolean;
|
|
28
|
+
/** A run finished while the seller was looking elsewhere — worth a dot on the rail. */
|
|
29
|
+
unread: boolean;
|
|
30
|
+
/** Dock height in px (seller-resizable, persisted). */
|
|
31
|
+
dockHeight: number;
|
|
32
|
+
/** Distance from the window's RIGHT edge, in px — the popup slides sideways along the bottom.
|
|
33
|
+
* Anchored right, not left, so a narrower window moves it with the edge instead of off screen. */
|
|
34
|
+
dockOffsetRight: number;
|
|
35
|
+
/** Bumped every time the assistant is brought back on screen. Lets a mount that already exists
|
|
36
|
+
* pick up a question handed over from ⌘K, which a mount-time read would only ever see once. */
|
|
37
|
+
openNonce: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type AssistantDockEvent =
|
|
41
|
+
/** the /app/assistant route mounted */
|
|
42
|
+
| { type: "enter-full" }
|
|
43
|
+
/** the /app/assistant route unmounted — the seller opened another menu */
|
|
44
|
+
| { type: "leave-full" }
|
|
45
|
+
/** show the floating popup (restore from mini, or open it from anywhere) */
|
|
46
|
+
| { type: "dock" }
|
|
47
|
+
/** dock → the small status bar */
|
|
48
|
+
| { type: "minimize" }
|
|
49
|
+
/** the popup's × */
|
|
50
|
+
| { type: "dismiss" }
|
|
51
|
+
| { type: "busy"; busy: boolean }
|
|
52
|
+
| { type: "resize"; height: number; viewport: number }
|
|
53
|
+
| { type: "slide"; offsetRight: number; min: number; max: number };
|
|
54
|
+
|
|
55
|
+
/** Tall enough for the composer, a status line and two message rows. */
|
|
56
|
+
export const DOCK_MIN_HEIGHT = 280;
|
|
57
|
+
export const DOCK_DEFAULT_HEIGHT = 480;
|
|
58
|
+
/**
|
|
59
|
+
* Topbar (56) + a 12px gap: the tallest the popup may grow.
|
|
60
|
+
*
|
|
61
|
+
* Was 88 — a 32px band of dead page between the topbar and a fully-grown popup, which read as the
|
|
62
|
+
* popup stopping short rather than filling the room it was given. Chris asked for 10–15px; 12 keeps
|
|
63
|
+
* the topbar's shadow visibly separate from the popup's own top edge.
|
|
64
|
+
*/
|
|
65
|
+
export const DOCK_TOPBAR_HEIGHT = 56;
|
|
66
|
+
export const DOCK_TOPBAR_GAP = 12;
|
|
67
|
+
export const DOCK_VIEWPORT_RESERVE = DOCK_TOPBAR_HEIGHT + DOCK_TOPBAR_GAP;
|
|
68
|
+
/** Matches the CSS `right: 24px` the popup starts at. */
|
|
69
|
+
export const DOCK_DEFAULT_OFFSET_RIGHT = 24;
|
|
70
|
+
|
|
71
|
+
/** Keep the popup inside the usable area: never under the rail, never off the right edge. */
|
|
72
|
+
export function clampDockOffset(offsetRight: number, min: number, max: number): number {
|
|
73
|
+
if (max < min) return min;
|
|
74
|
+
return Math.min(Math.max(Math.round(offsetRight), min), max);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function clampDockHeight(height: number, viewport: number): number {
|
|
78
|
+
const max = Math.max(DOCK_MIN_HEIGHT, viewport - DOCK_VIEWPORT_RESERVE);
|
|
79
|
+
return Math.min(Math.max(Math.round(height), DOCK_MIN_HEIGHT), max);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const initialAssistantDockState: AssistantDockState = {
|
|
83
|
+
presentation: "hidden",
|
|
84
|
+
mounted: false,
|
|
85
|
+
busy: false,
|
|
86
|
+
unread: false,
|
|
87
|
+
dockHeight: DOCK_DEFAULT_HEIGHT,
|
|
88
|
+
dockOffsetRight: DOCK_DEFAULT_OFFSET_RIGHT,
|
|
89
|
+
openNonce: 0,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export function assistantDockReducer(state: AssistantDockState, event: AssistantDockEvent): AssistantDockState {
|
|
93
|
+
switch (event.type) {
|
|
94
|
+
case "enter-full":
|
|
95
|
+
return { ...state, presentation: "full", mounted: true, unread: false, openNonce: state.openNonce + 1 };
|
|
96
|
+
|
|
97
|
+
case "leave-full":
|
|
98
|
+
// Only the route that claimed full mode may release it. Without this guard a route
|
|
99
|
+
// unmounting AFTER the seller already shrank the popup by hand would yank it away again.
|
|
100
|
+
if (state.presentation !== "full") return state;
|
|
101
|
+
// A live run is never thrown off screen — that is the whole point of this file.
|
|
102
|
+
// Idle, it collapses to the status bar rather than vanishing: the popup is the only way to ask
|
|
103
|
+
// a question ABOUT the screen you are on, and with nothing on screen there was no way to open
|
|
104
|
+
// it anywhere but the assistant's own page — where there is no screen to ask about.
|
|
105
|
+
// `dismiss` on that bar is what hides it for good.
|
|
106
|
+
//
|
|
107
|
+
// There used to be a `pinned` flag here, and a 📌 in the header to set it. Two later changes
|
|
108
|
+
// ate its job: the bar already follows the seller, and the shell now reopens in whichever
|
|
109
|
+
// shape they last used. A control that no longer decides anything is worse than no control.
|
|
110
|
+
return { ...state, presentation: state.busy ? "dock" : "mini" };
|
|
111
|
+
|
|
112
|
+
case "dock":
|
|
113
|
+
return { ...state, presentation: "dock", mounted: true, unread: false, openNonce: state.openNonce + 1 };
|
|
114
|
+
|
|
115
|
+
case "minimize":
|
|
116
|
+
return { ...state, presentation: "mini" };
|
|
117
|
+
|
|
118
|
+
case "dismiss":
|
|
119
|
+
// × on a working assistant collapses to the status bar instead of hiding it: a seller who
|
|
120
|
+
// cannot see the run cannot tell it apart from one that died, which is the bug we are fixing.
|
|
121
|
+
// Stopping is a separate, explicit button in the composer.
|
|
122
|
+
// Pressing it AGAIN on that bar opens the popup rather than doing nothing twice — a button that
|
|
123
|
+
// visibly changes nothing is read as broken.
|
|
124
|
+
if (!state.busy) return { ...state, presentation: "hidden" };
|
|
125
|
+
return { ...state, presentation: state.presentation === "mini" ? "dock" : "mini" };
|
|
126
|
+
|
|
127
|
+
case "busy": {
|
|
128
|
+
if (event.busy === state.busy) return state;
|
|
129
|
+
const offscreen = state.presentation === "mini" || state.presentation === "hidden";
|
|
130
|
+
return { ...state, busy: event.busy, unread: !event.busy && offscreen ? true : state.unread };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
case "resize":
|
|
134
|
+
return { ...state, dockHeight: clampDockHeight(event.height, event.viewport) };
|
|
135
|
+
|
|
136
|
+
case "slide":
|
|
137
|
+
return { ...state, dockOffsetRight: clampDockOffset(event.offsetRight, event.min, event.max) };
|
|
138
|
+
|
|
139
|
+
default:
|
|
140
|
+
return state;
|
|
141
|
+
}
|
|
142
|
+
}
|