@elabs-ai/components-ui 4.0.0 → 4.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 +10 -8
- package/dist/chunk-JKPPTL63.js +48 -0
- package/dist/chunk-JKPPTL63.js.map +1 -0
- package/dist/form.d.ts +28 -0
- package/dist/form.js +123 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +2485 -167
- package/dist/index.js +10716 -2706
- package/dist/index.js.map +1 -1
- package/package.json +21 -7
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +1 -1
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +4 -1
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.tsx +35 -2
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +1 -1
- package/src/components/attribution-panel/attributions.generated.ts +81 -77
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.tsx +1 -1
- package/src/components/bento-grid/bento-grid.test.tsx +1 -1
- package/src/components/bento-grid/bento-grid.tsx +1 -1
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +7 -2
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +1 -1
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.tsx +1 -1
- package/src/components/change-review/change-review.stories.tsx +145 -1
- package/src/components/change-review/change-review.test.tsx +143 -0
- package/src/components/change-review/change-review.tsx +185 -4
- package/src/components/checkbox/checkbox.tsx +1 -1
- package/src/components/color-picker/color-picker.tsx +6 -5
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +73 -0
- package/src/components/command/command.tsx +30 -7
- package/src/components/command/index.ts +1 -0
- package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
- package/src/components/command-trigger/command-trigger.test.tsx +18 -0
- package/src/components/command-trigger/command-trigger.tsx +70 -0
- package/src/components/command-trigger/index.ts +1 -0
- package/src/components/context-rail/context-rail.stories.tsx +285 -0
- package/src/components/context-rail/context-rail.test.tsx +263 -0
- package/src/components/context-rail/context-rail.tsx +678 -0
- package/src/components/context-rail/index.ts +1 -0
- package/src/components/copyable-value/copyable-value.tsx +1 -1
- package/src/components/dialog/dialog.tsx +2 -2
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/empty-state/empty-state.test.tsx +26 -0
- package/src/components/empty-state/empty-state.tsx +6 -1
- package/src/components/expand-dialog/expand-dialog.tsx +1 -2
- package/src/components/field/field-context.ts +59 -0
- package/src/components/field/field.stories.tsx +142 -0
- package/src/components/field/field.test.tsx +415 -0
- package/src/components/field/field.tsx +340 -0
- package/src/components/field/index.ts +9 -0
- package/src/components/field-row/field-row.stories.tsx +85 -1
- package/src/components/field-row/field-row.test.tsx +145 -0
- package/src/components/field-row/field-row.tsx +48 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +7 -2
- package/src/components/file-upload/file-upload.tsx +15 -4
- package/src/components/form/form.tsx +5 -2
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +2 -2
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +2 -2
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +13 -4
- package/src/components/input-otp/input-otp.tsx +5 -1
- package/src/components/keyboard-shortcuts/index.ts +6 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
- package/src/components/locale-provider/locale-provider.test.tsx +257 -1
- package/src/components/locale-provider/locale-provider.tsx +63 -8
- package/src/components/locale-provider/messages.ts +501 -2
- package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
- package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
- package/src/components/mention-input/mention-input.tsx +8 -38
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/metric-card/metric-card.stories.tsx +5 -3
- package/src/components/metric-card/metric-card.tsx +21 -2
- package/src/components/model-picker/model-picker.stories.tsx +63 -3
- package/src/components/model-picker/model-picker.test.tsx +63 -0
- package/src/components/model-picker/model-picker.tsx +62 -36
- package/src/components/nav-main/nav-main.tsx +1 -1
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.tsx +57 -5
- package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
- package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
- package/src/components/navigation-menu/navigation-menu.tsx +189 -12
- package/src/components/page-shell/page-shell.stories.tsx +163 -0
- package/src/components/page-shell/page-shell.test.tsx +229 -0
- package/src/components/page-shell/page-shell.tsx +108 -25
- package/src/components/pagination/pagination.test.tsx +90 -0
- package/src/components/pagination/pagination.tsx +9 -5
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +1 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.tsx +1 -1
- package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
- package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
- package/src/components/revision-timeline/revision-timeline.tsx +46 -3
- package/src/components/schema-form/from-json-schema.test.ts +390 -0
- package/src/components/schema-form/from-json-schema.ts +375 -0
- package/src/components/schema-form/index.ts +81 -0
- package/src/components/schema-form/schema-form-spec.ts +698 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +900 -0
- package/src/components/schema-form/schema-form.tsx +1545 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +12 -1
- package/src/components/select/select.tsx +6 -2
- package/src/components/sheet/sheet.tsx +1 -1
- package/src/components/side-dock/index.ts +1 -0
- package/src/components/side-dock/side-dock.stories.tsx +137 -0
- package/src/components/side-dock/side-dock.test.tsx +273 -0
- package/src/components/side-dock/side-dock.tsx +502 -0
- package/src/components/sidebar/index.ts +1 -0
- package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
- package/src/components/sidebar/sidebar.stories.tsx +421 -1
- package/src/components/sidebar/sidebar.test.tsx +36 -1
- package/src/components/sidebar/sidebar.tsx +245 -33
- package/src/components/skip-link/index.ts +1 -0
- package/src/components/skip-link/skip-link.stories.tsx +106 -0
- package/src/components/skip-link/skip-link.test.tsx +20 -0
- package/src/components/skip-link/skip-link.tsx +34 -0
- package/src/components/slider/slider.tsx +1 -1
- package/src/components/split-panel/index.ts +6 -1
- package/src/components/split-panel/split-panel.stories.tsx +18 -2
- package/src/components/split-panel/split-panel.test.tsx +20 -0
- package/src/components/split-panel/split-panel.tsx +28 -8
- package/src/components/state-panel/state-panel.stories.tsx +52 -0
- package/src/components/state-panel/state-panel.test.tsx +125 -0
- package/src/components/state-panel/state-panel.tsx +96 -17
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +1 -1
- package/src/components/table/table.stories.tsx +62 -0
- package/src/components/table/table.test.tsx +142 -1
- package/src/components/table/table.tsx +60 -1
- package/src/components/tabs/tabs.tsx +8 -2
- package/src/components/tag-input/tag-input.tsx +3 -2
- package/src/components/team-switcher/team-switcher.tsx +34 -5
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.tsx +6 -2
- package/src/components/theme-switcher/use-theme-transition.ts +2 -2
- package/src/components/timeline/timeline.stories.tsx +27 -0
- package/src/components/timeline/timeline.tsx +11 -1
- package/src/components/toggle/toggle.tsx +1 -1
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.tsx +14 -9
- package/src/components/typography/prose.tsx +1 -1
- package/src/components/typography/typography.stories.tsx +228 -0
- package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
- package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
- package/src/components/view-toolbar/view-toolbar.tsx +37 -4
- package/src/components/virtual-select/virtual-select.test.tsx +1 -1
- package/src/components/wizard/wizard.tsx +1 -1
- package/src/components/workspace-picker/index.ts +3 -0
- package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
- package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
- package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
- package/src/components/workspace-picker/workspace-picker.tsx +172 -0
- package/src/illustrations/empty-list-illustration.tsx +26 -0
- package/src/illustrations/error-illustration.tsx +60 -0
- package/src/illustrations/first-run-illustration.tsx +27 -0
- package/src/illustrations/illustration-base.tsx +108 -0
- package/src/illustrations/illustrations.stories.tsx +94 -0
- package/src/illustrations/illustrations.test.tsx +98 -0
- package/src/illustrations/index.ts +16 -0
- package/src/illustrations/no-access-illustration.tsx +45 -0
- package/src/illustrations/no-results-illustration.tsx +46 -0
- package/src/illustrations/offline-illustration.tsx +37 -0
- package/src/illustrations/success-illustration.tsx +40 -0
- package/src/index.ts +75 -2
- package/src/lib/agent-event-model.test.ts +12 -0
- package/src/lib/agent-event-model.ts +42 -0
- package/src/lib/approval-option.test.ts +17 -0
- package/src/lib/approval-option.ts +53 -0
- package/src/lib/check-result.ts +33 -0
- package/src/lib/diff-line.test.ts +36 -0
- package/src/lib/diff-line.ts +63 -0
- package/src/lib/diff-rows.test.ts +76 -0
- package/src/lib/diff-rows.ts +92 -0
- package/src/lib/format-duration.test.ts +22 -0
- package/src/lib/format-duration.ts +30 -0
- package/src/lib/has-renderable-content.test.ts +49 -0
- package/src/lib/has-renderable-content.ts +32 -0
- package/src/lib/operating-mode.test.ts +36 -0
- package/src/lib/operating-mode.ts +81 -0
- package/src/lib/optional-peer.ts +59 -0
- package/src/lib/session-launch.ts +37 -0
- package/src/lib/slash-command.test.ts +44 -0
- package/src/lib/slash-command.ts +45 -0
- package/src/lib/trigger-query.test.ts +94 -0
- package/src/lib/trigger-query.ts +88 -0
- package/src/lib/use-mobile.ts +12 -6
- package/src/motion.stories.tsx +6 -0
- package/src/templates-object-detail-hub.stories.tsx +2 -2
- package/src/templates-screen-states.stories.tsx +5 -5
- package/src/templates-settings.stories.tsx +1 -1
- package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
- package/src/blocks/sidebar-02/logo.tsx +0 -18
- package/src/blocks/sidebar-02/nav-main.tsx +0 -13
- package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
- package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
- package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
- package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
- package/src/blocks/sidebar-04/mail-context.tsx +0 -29
- package/src/blocks/sidebar-04/nav-user.tsx +0 -6
- package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
- package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
- package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
- package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One verification verdict about an action — a linter, a type check, a test run,
|
|
3
|
+
* a policy hook. Shared vocabulary across packages: `ChangeReview` (#112) shows the
|
|
4
|
+
* checks a proposed edit already passed, `AgentEvent` (#109) shows the checks a
|
|
5
|
+
* runtime hook ran around a tool call. One runtime concept, one shape.
|
|
6
|
+
*
|
|
7
|
+
* Presentational only (D5): this type records a verdict someone else computed.
|
|
8
|
+
* Nothing in brand-ui ever runs a check.
|
|
9
|
+
*
|
|
10
|
+
* Rendering contract, binding on every consumer: `ok` must reach the user through
|
|
11
|
+
* an ICON and ACCESSIBLE TEXT, never through colour alone — the greyscale test in
|
|
12
|
+
* `.claude/rules/accessibility.md`.
|
|
13
|
+
*/
|
|
14
|
+
export interface CheckResult {
|
|
15
|
+
/** What ran, verbatim as the runtime names it: "eslint", "tsc", "pre_tool_use". */
|
|
16
|
+
label: string;
|
|
17
|
+
/** Did it pass? The verdict; the only required field besides `label`. */
|
|
18
|
+
ok: boolean;
|
|
19
|
+
/** One line of explanation. Renders as a secondary line, collapsed when long. */
|
|
20
|
+
detail?: string;
|
|
21
|
+
/** Wall-clock duration in milliseconds. Format with `formatElapsed`. */
|
|
22
|
+
durationMs?: number;
|
|
23
|
+
/** When it ran relative to the action it gates. Groups results into sections. */
|
|
24
|
+
phase?: "before" | "after";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A count summary, when the individual verdicts are not available. */
|
|
28
|
+
export interface CheckSummary {
|
|
29
|
+
/** How many checks ran. */
|
|
30
|
+
ran: number;
|
|
31
|
+
/** How many of them passed. `passed <= ran`. */
|
|
32
|
+
passed: number;
|
|
33
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { diffLineAccessibleLabel, diffLineMarker, type DiffLineType } from "./diff-line";
|
|
3
|
+
|
|
4
|
+
describe("diffLineMarker", () => {
|
|
5
|
+
it.each([
|
|
6
|
+
["add", "+"],
|
|
7
|
+
["del", "−"],
|
|
8
|
+
["context", " "],
|
|
9
|
+
["hunk", ""],
|
|
10
|
+
["meta", ""],
|
|
11
|
+
] as const)("returns %s's marker as %s", (type, expected) => {
|
|
12
|
+
expect(diffLineMarker(type)).toBe(expected);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("uses a NO-BREAK SPACE (U+00A0) for context, not a plain space — must survive JSX/HTML whitespace collapsing", () => {
|
|
16
|
+
expect(diffLineMarker("context")).toBe(" ");
|
|
17
|
+
expect(diffLineMarker("context").charCodeAt(0)).toBe(0xa0);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("diffLineAccessibleLabel", () => {
|
|
22
|
+
it("returns the added-line key for an add row", () => {
|
|
23
|
+
expect(diffLineAccessibleLabel("add")).toBe("ai.diffView.addedLine");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns the removed-line key for a del row", () => {
|
|
27
|
+
expect(diffLineAccessibleLabel("del")).toBe("ai.diffView.removedLine");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it.each(["context", "hunk", "meta"] as DiffLineType[])(
|
|
31
|
+
"returns undefined for %s (no polarity)",
|
|
32
|
+
(type) => {
|
|
33
|
+
expect(diffLineAccessibleLabel(type)).toBeUndefined();
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unified-diff line model behind `DiffView` (`@elabs-ai/components-ai`,
|
|
3
|
+
* #102) and the terminal CLI look-alike family's own diff hunk (issue #117).
|
|
4
|
+
*
|
|
5
|
+
* Promoted here (not duplicated) because `@elabs-ai/components-ai` and
|
|
6
|
+
* `@elabs-ai/components-terminal` are layer-2 DAG SIBLINGS and may not import
|
|
7
|
+
* each other — `ui` is upstream of both
|
|
8
|
+
* (docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). This
|
|
9
|
+
* module is the shiki-free half: the line shape, the marker glyph and the
|
|
10
|
+
* screen-reader polarity label. Intra-line syntax highlighting (Shiki) and
|
|
11
|
+
* the diff's presentational chrome stay in `@elabs-ai/components-ai`'s
|
|
12
|
+
* `diff-view.tsx` — `ui` does not depend on Shiki and never will for one
|
|
13
|
+
* component (see the decision doc § 3).
|
|
14
|
+
*
|
|
15
|
+
* The `DiffLine` model and the polarity-prefix technique are adapted from
|
|
16
|
+
* `theswerd/brainless` (MIT) — see `ATTRIBUTION.md`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** What role a line plays in the diff. */
|
|
20
|
+
export type DiffLineType = "add" | "del" | "context" | "hunk" | "meta";
|
|
21
|
+
|
|
22
|
+
/** One line of a unified diff. Absent gutter numbers are load-bearing (see below). */
|
|
23
|
+
export interface DiffLine {
|
|
24
|
+
type: DiffLineType;
|
|
25
|
+
/** Line number in the original file. Absent on `add` — there is no old line. */
|
|
26
|
+
oldNumber?: number;
|
|
27
|
+
/** Line number in the new file. Absent on `del` — there is no new line. */
|
|
28
|
+
newNumber?: number;
|
|
29
|
+
/** The line's text, without its leading `+` / `-` / ` ` marker. */
|
|
30
|
+
text: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The single-character marker glyph for a real source line (`add`/`del`/
|
|
35
|
+
* `context`). `hunk`/`meta` lines are diff headers, not source, and render
|
|
36
|
+
* through a full-width row instead — this returns `""` for them.
|
|
37
|
+
*/
|
|
38
|
+
export function diffLineMarker(type: DiffLineType): string {
|
|
39
|
+
switch (type) {
|
|
40
|
+
case "add":
|
|
41
|
+
return "+";
|
|
42
|
+
case "del":
|
|
43
|
+
return "−";
|
|
44
|
+
case "context":
|
|
45
|
+
return " ";
|
|
46
|
+
default:
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The `ui` locale-message KEY (not the translated text) for the `sr-only`
|
|
53
|
+
* screen-reader polarity word an `add`/`del` row carries ALONGSIDE its marker
|
|
54
|
+
* glyph and its fill tint — colour is never the only channel (WCAG 1.4.1).
|
|
55
|
+
* `undefined` for `context`/`hunk`/`meta`, which carry no polarity.
|
|
56
|
+
*/
|
|
57
|
+
export function diffLineAccessibleLabel(
|
|
58
|
+
type: DiffLineType,
|
|
59
|
+
): "ai.diffView.addedLine" | "ai.diffView.removedLine" | undefined {
|
|
60
|
+
if (type === "add") return "ai.diffView.addedLine";
|
|
61
|
+
if (type === "del") return "ai.diffView.removedLine";
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { act, renderHook } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { DiffLine } from "./diff-line";
|
|
4
|
+
import { collapseDiffRows, useDiffRows } from "./diff-rows";
|
|
5
|
+
|
|
6
|
+
function contextLine(index: number): DiffLine {
|
|
7
|
+
return { type: "context", oldNumber: index, newNumber: index, text: `line ${index}` };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("collapseDiffRows", () => {
|
|
11
|
+
it("renders every line when contextLines is undefined", () => {
|
|
12
|
+
const lines = Array.from({ length: 5 }, (_, i) => contextLine(i));
|
|
13
|
+
const rows = collapseDiffRows(lines, undefined);
|
|
14
|
+
expect(rows).toHaveLength(5);
|
|
15
|
+
expect(rows.every((row) => row.kind === "line")).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("does not collapse a run at or below the threshold", () => {
|
|
19
|
+
const lines = Array.from({ length: 4 }, (_, i) => contextLine(i));
|
|
20
|
+
const rows = collapseDiffRows(lines, 4);
|
|
21
|
+
expect(rows).toHaveLength(4);
|
|
22
|
+
expect(rows.every((row) => row.kind === "line")).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("collapses a run longer than contextLines, keeping top and bottom context", () => {
|
|
26
|
+
const lines = Array.from({ length: 10 }, (_, i) => contextLine(i));
|
|
27
|
+
const rows = collapseDiffRows(lines, 4);
|
|
28
|
+
// top=2, bottom=2, collapsed hiddenCount = 10 - 2 - 2 = 6. `runStart` is the
|
|
29
|
+
// run's OWN start index (0 — the whole 10-line run is one context block),
|
|
30
|
+
// not the index of the first hidden line — it is the key `expand()` takes.
|
|
31
|
+
expect(rows.map((row) => row.kind)).toEqual(["line", "line", "collapsed", "line", "line"]);
|
|
32
|
+
const collapsed = rows.find((row) => row.kind === "collapsed");
|
|
33
|
+
expect(collapsed).toMatchObject({ kind: "collapsed", runStart: 0, hiddenCount: 6 });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("renders a run in full when its start index is in `expanded`", () => {
|
|
37
|
+
const lines = Array.from({ length: 10 }, (_, i) => contextLine(i));
|
|
38
|
+
const rows = collapseDiffRows(lines, 4, new Set([0]));
|
|
39
|
+
expect(rows).toHaveLength(10);
|
|
40
|
+
expect(rows.every((row) => row.kind === "line")).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does not collapse non-context lines flanking a run", () => {
|
|
44
|
+
const lines: DiffLine[] = [
|
|
45
|
+
{ type: "add", newNumber: 1, text: "added" },
|
|
46
|
+
...Array.from({ length: 8 }, (_, i) => contextLine(i)),
|
|
47
|
+
{ type: "del", oldNumber: 99, text: "removed" },
|
|
48
|
+
];
|
|
49
|
+
const rows = collapseDiffRows(lines, 4);
|
|
50
|
+
expect(rows[0]).toMatchObject({ kind: "line", index: 0 });
|
|
51
|
+
expect(rows.at(-1)).toMatchObject({ kind: "line", index: 9 });
|
|
52
|
+
expect(rows.some((row) => row.kind === "collapsed")).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("useDiffRows", () => {
|
|
57
|
+
it("starts with no run expanded", () => {
|
|
58
|
+
const lines = Array.from({ length: 10 }, (_, i) => contextLine(i));
|
|
59
|
+
const { result } = renderHook(() => useDiffRows(lines, 4));
|
|
60
|
+
expect(result.current.rows.some((row) => row.kind === "collapsed")).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("expand() reveals the collapsed run's lines", () => {
|
|
64
|
+
const lines = Array.from({ length: 10 }, (_, i) => contextLine(i));
|
|
65
|
+
const { result } = renderHook(() => useDiffRows(lines, 4));
|
|
66
|
+
|
|
67
|
+
const collapsed = result.current.rows.find((row) => row.kind === "collapsed");
|
|
68
|
+
expect(collapsed).toBeDefined();
|
|
69
|
+
const runStart = (collapsed as { runStart: number }).runStart;
|
|
70
|
+
|
|
71
|
+
act(() => result.current.expand(runStart));
|
|
72
|
+
|
|
73
|
+
expect(result.current.rows.every((row) => row.kind === "line")).toBe(true);
|
|
74
|
+
expect(result.current.rows).toHaveLength(10);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context-run-collapsing hunk-windowing algorithm behind `DiffView`
|
|
3
|
+
* (`@elabs-ai/components-ai`, #102, where it shipped as the private
|
|
4
|
+
* `useVisibleRows`) and the terminal CLI look-alike family's own diff hunk
|
|
5
|
+
* (issue #117).
|
|
6
|
+
*
|
|
7
|
+
* Promoted here (not duplicated) because `@elabs-ai/components-ai` and
|
|
8
|
+
* `@elabs-ai/components-terminal` are layer-2 DAG SIBLINGS and may not import
|
|
9
|
+
* each other — `ui` is upstream of both
|
|
10
|
+
* (docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). The
|
|
11
|
+
* windowing algorithm is shiki-free and DOM-free; `ui` already hosts hooks
|
|
12
|
+
* (`use-mobile`, `use-copy-to-clipboard`), so a hook alongside a pure
|
|
13
|
+
* function is an ordinary shape here, not a new category.
|
|
14
|
+
*/
|
|
15
|
+
import { useCallback, useMemo, useState } from "react";
|
|
16
|
+
import type { DiffLine } from "./diff-line";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One rendered row: either a real diff line, or a collapsed run of
|
|
20
|
+
* consecutive `context` lines longer than the caller's `contextLines`.
|
|
21
|
+
*/
|
|
22
|
+
export type DiffRow =
|
|
23
|
+
| { kind: "line"; line: DiffLine; index: number }
|
|
24
|
+
| { kind: "collapsed"; runStart: number; hiddenCount: number };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Collapse long runs of consecutive `context` lines behind a single
|
|
28
|
+
* `"collapsed"` row, keeping `Math.ceil(contextLines / 2)` lines of context
|
|
29
|
+
* at the top of the run and `Math.floor(contextLines / 2)` at the bottom —
|
|
30
|
+
* so a reader always sees context on both sides of a hidden gap. A run
|
|
31
|
+
* already in `expanded` (by its start index) renders in full instead.
|
|
32
|
+
*
|
|
33
|
+
* Pure: no state, no effect. `useDiffRows` below is the stateful wrapper
|
|
34
|
+
* that owns the `expanded` set for a component.
|
|
35
|
+
*/
|
|
36
|
+
export function collapseDiffRows(
|
|
37
|
+
lines: DiffLine[],
|
|
38
|
+
contextLines: number | undefined,
|
|
39
|
+
expanded: ReadonlySet<number> = new Set(),
|
|
40
|
+
): DiffRow[] {
|
|
41
|
+
if (!contextLines || contextLines <= 0) {
|
|
42
|
+
return lines.map((line, index) => ({ kind: "line", line, index }));
|
|
43
|
+
}
|
|
44
|
+
const out: DiffRow[] = [];
|
|
45
|
+
let i = 0;
|
|
46
|
+
while (i < lines.length) {
|
|
47
|
+
if (lines[i]?.type !== "context") {
|
|
48
|
+
out.push({ kind: "line", line: lines[i]!, index: i });
|
|
49
|
+
i++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const runStart = i;
|
|
53
|
+
let j = i;
|
|
54
|
+
while (j < lines.length && lines[j]?.type === "context") j++;
|
|
55
|
+
const runLength = j - runStart;
|
|
56
|
+
if (runLength <= contextLines || expanded.has(runStart)) {
|
|
57
|
+
for (let k = runStart; k < j; k++) out.push({ kind: "line", line: lines[k]!, index: k });
|
|
58
|
+
} else {
|
|
59
|
+
const top = Math.ceil(contextLines / 2);
|
|
60
|
+
const bottom = Math.floor(contextLines / 2);
|
|
61
|
+
for (let k = runStart; k < runStart + top; k++)
|
|
62
|
+
out.push({ kind: "line", line: lines[k]!, index: k });
|
|
63
|
+
out.push({ kind: "collapsed", runStart, hiddenCount: runLength - top - bottom });
|
|
64
|
+
for (let k = j - bottom; k < j; k++) out.push({ kind: "line", line: lines[k]!, index: k });
|
|
65
|
+
}
|
|
66
|
+
i = j;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Stateful wrapper around `collapseDiffRows`: owns the set of run-start
|
|
73
|
+
* indices a caller has expanded via the "show more" control, and returns the
|
|
74
|
+
* current row list alongside an `expand` action.
|
|
75
|
+
*/
|
|
76
|
+
export function useDiffRows(
|
|
77
|
+
lines: DiffLine[],
|
|
78
|
+
contextLines: number | undefined,
|
|
79
|
+
): { rows: DiffRow[]; expand: (runStart: number) => void } {
|
|
80
|
+
const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set());
|
|
81
|
+
|
|
82
|
+
const rows = useMemo(
|
|
83
|
+
() => collapseDiffRows(lines, contextLines, expanded),
|
|
84
|
+
[lines, contextLines, expanded],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const expand = useCallback((runStart: number) => {
|
|
88
|
+
setExpanded((prev) => new Set(prev).add(runStart));
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
return { rows, expand };
|
|
92
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatElapsed } from "./format-duration";
|
|
3
|
+
|
|
4
|
+
describe("formatElapsed", () => {
|
|
5
|
+
it.each([
|
|
6
|
+
// [elapsedMs, expected]
|
|
7
|
+
[0, "0ms"],
|
|
8
|
+
[420, "420ms"],
|
|
9
|
+
[999, "999ms"],
|
|
10
|
+
[1000, "1.0s"],
|
|
11
|
+
[8000, "8.0s"],
|
|
12
|
+
[9949, "9.9s"],
|
|
13
|
+
[10000, "10s"],
|
|
14
|
+
[42000, "42s"],
|
|
15
|
+
[59000, "59s"],
|
|
16
|
+
[60000, "1m00s"],
|
|
17
|
+
[77000, "1m17s"],
|
|
18
|
+
[605000, "10m05s"],
|
|
19
|
+
] as const)("formats %dms as %s", (elapsedMs, expected) => {
|
|
20
|
+
expect(formatElapsed(elapsedMs)).toBe(expected);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const MS_PER_SECOND = 1000;
|
|
2
|
+
const SECONDS_PER_MINUTE = 60;
|
|
3
|
+
const TEN_SECONDS = 10;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Human-readable elapsed time — one formatter for every "how long did this take"
|
|
7
|
+
* surface in the system: `TurnStatus`'s running turn (#105), `ChangeReview`'s check
|
|
8
|
+
* rows (#112), `AgentEvent`'s hook durations (#109).
|
|
9
|
+
*
|
|
10
|
+
* Exported so a consumer's own elapsed-time surfaces agree with ours.
|
|
11
|
+
* Adapted from `brainless`'s turn-status elapsed formatter (MIT, see `ATTRIBUTION.md`).
|
|
12
|
+
*
|
|
13
|
+
* Rungs: sub-second → "420ms" · under ten seconds → "8.0s" · under a minute → "45s" ·
|
|
14
|
+
* beyond → "2m05s".
|
|
15
|
+
*/
|
|
16
|
+
export function formatElapsed(elapsedMs: number): string {
|
|
17
|
+
if (elapsedMs < MS_PER_SECOND) {
|
|
18
|
+
return `${Math.round(elapsedMs)}ms`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const totalSeconds = elapsedMs / MS_PER_SECOND;
|
|
22
|
+
|
|
23
|
+
if (totalSeconds < SECONDS_PER_MINUTE) {
|
|
24
|
+
return `${totalSeconds.toFixed(totalSeconds < TEN_SECONDS ? 1 : 0)}s`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
|
|
28
|
+
const seconds = Math.round(totalSeconds % SECONDS_PER_MINUTE);
|
|
29
|
+
return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
|
30
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { hasRenderableContent } from "./has-renderable-content";
|
|
4
|
+
|
|
5
|
+
describe("hasRenderableContent", () => {
|
|
6
|
+
it("is false for every scalar falsy value", () => {
|
|
7
|
+
expect(hasRenderableContent(undefined)).toBe(false);
|
|
8
|
+
expect(hasRenderableContent(null)).toBe(false);
|
|
9
|
+
expect(hasRenderableContent(false)).toBe(false);
|
|
10
|
+
expect(hasRenderableContent("")).toBe(false);
|
|
11
|
+
expect(hasRenderableContent(0)).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("is true for a non-empty string or a truthy number", () => {
|
|
15
|
+
expect(hasRenderableContent("Required.")).toBe(true);
|
|
16
|
+
expect(hasRenderableContent(1)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("is false for an empty array — arrays are always truthy, which is exactly the bug this helper exists to avoid", () => {
|
|
20
|
+
expect(hasRenderableContent([])).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("is false for an array of only falsy children", () => {
|
|
24
|
+
expect(hasRenderableContent([false, null, undefined])).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("is false for an array of only empty strings — Children.toArray alone keeps '' and would still report content", () => {
|
|
28
|
+
expect(hasRenderableContent(["", ""])).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("is true for an array containing at least one renderable child", () => {
|
|
32
|
+
expect(hasRenderableContent([false, "Required."])).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("is true for a single React element", () => {
|
|
36
|
+
expect(hasRenderableContent(createElement("span", null, "hi"))).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// [KNOWN LIMIT] — an element naming a component that will render `null` is
|
|
40
|
+
// still an inert, truthy descriptor at this point; whether it renders
|
|
41
|
+
// anything is not decidable before React actually renders it. Documented
|
|
42
|
+
// limit, not a bug — see the JSDoc above `hasRenderableContent`.
|
|
43
|
+
it("[KNOWN LIMIT] is true for an element whose component will render null — not knowable before render", () => {
|
|
44
|
+
function RendersNull() {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
expect(hasRenderableContent(createElement(RendersNull))).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Children, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether `node` has any renderable content. `{condition && "message"}` is
|
|
5
|
+
* the ordinary React idiom for optional content, and `condition === false`
|
|
6
|
+
* must render nothing — a `false`/`0`/`""`/`null`/`undefined` value is "no
|
|
7
|
+
* content", not an empty paragraph/alert. `Boolean(node)` alone is not
|
|
8
|
+
* enough: an array is always truthy, so `{errors.map(...)}` on an empty list
|
|
9
|
+
* (or `[a && "x", b && "y"]` with both false) would still count as content.
|
|
10
|
+
* `Children.toArray` drops `null`/`undefined`/booleans from an array but
|
|
11
|
+
* KEEPS `""` (and `0`), so an array like `["", ""]` would still slip through
|
|
12
|
+
* as "content" without the trailing `.filter(Boolean)` — filtering after
|
|
13
|
+
* `toArray` is what makes this match the scalar check for every array shape,
|
|
14
|
+
* not just the ones `toArray` already prunes.
|
|
15
|
+
*
|
|
16
|
+
* Shared by `FieldDescription`/`FieldError` (`../components/field/field.tsx`)
|
|
17
|
+
* and `FieldRow` (`../components/field-row/field-row.tsx`) — extracted so a
|
|
18
|
+
* fix to the predicate reaches every caller instead of drifting between
|
|
19
|
+
* hand-duplicated copies, which is exactly how `FieldRow` ended up with the
|
|
20
|
+
* weaker, bare-truthiness version of this check for two rounds of `Field*`
|
|
21
|
+
* fixes (#93).
|
|
22
|
+
*
|
|
23
|
+
* **Known limit:** a `node` that is itself a component returning `null` (or
|
|
24
|
+
* an empty fragment) is not knowable from the element before render — a
|
|
25
|
+
* React element is always truthy, whatever the component it names will
|
|
26
|
+
* return, so this function reports `true` for it. That limit is documented
|
|
27
|
+
* on `FieldDescription`/`FieldError`/`FieldRow`'s own JSDoc; it is not, and
|
|
28
|
+
* cannot be, fixed here.
|
|
29
|
+
*/
|
|
30
|
+
export function hasRenderableContent(node: ReactNode): boolean {
|
|
31
|
+
return Boolean(node) && Children.toArray(node).filter(Boolean).length > 0;
|
|
32
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { effortRungForIndex } from "./operating-mode";
|
|
3
|
+
|
|
4
|
+
describe("effortRungForIndex", () => {
|
|
5
|
+
it("returns the smallest rung for a single-level scale", () => {
|
|
6
|
+
expect(effortRungForIndex(0, 1)).toBe("size-2.5");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns the smallest rung for the first of several levels", () => {
|
|
10
|
+
expect(effortRungForIndex(0, 4)).toBe("size-2.5");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns the largest rung for the last of several levels", () => {
|
|
14
|
+
expect(effortRungForIndex(3, 4)).toBe("size-6");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("grows monotonically across the scale", () => {
|
|
18
|
+
const RUNGS = [
|
|
19
|
+
"size-2.5",
|
|
20
|
+
"size-3",
|
|
21
|
+
"size-3.5",
|
|
22
|
+
"size-4",
|
|
23
|
+
"size-4.5",
|
|
24
|
+
"size-5",
|
|
25
|
+
"size-5.5",
|
|
26
|
+
"size-6",
|
|
27
|
+
];
|
|
28
|
+
const count = 5;
|
|
29
|
+
const indices = Array.from({ length: count }, (_, i) =>
|
|
30
|
+
RUNGS.indexOf(effortRungForIndex(i, count)),
|
|
31
|
+
);
|
|
32
|
+
for (let i = 1; i < indices.length; i++) {
|
|
33
|
+
expect(indices[i]).toBeGreaterThanOrEqual(indices[i - 1]!);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operating-mode and reasoning-effort vocabulary behind
|
|
3
|
+
* `PromptInputMode`/`PromptInputEffort` (`@elabs-ai/components-ai`, #104/#107)
|
|
4
|
+
* and the terminal CLI look-alike family's own composer chips (issue #117).
|
|
5
|
+
*
|
|
6
|
+
* Promoted here (not duplicated) because `@elabs-ai/components-ai` and
|
|
7
|
+
* `@elabs-ai/components-terminal` are layer-2 DAG SIBLINGS and may not import
|
|
8
|
+
* each other — `ui` is upstream of both
|
|
9
|
+
* (docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). Both
|
|
10
|
+
* types are caller-supplied vocabulary objects — never a vendor union — which
|
|
11
|
+
* #117 requires: no vendor mode/effort union may appear in a public type.
|
|
12
|
+
*/
|
|
13
|
+
import type { ReactNode } from "react";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One app-defined operating mode — how autonomously the agent may act while
|
|
17
|
+
* this mode is active. `brand-ui` ships no vocabulary: `id`/`label` are
|
|
18
|
+
* entirely the consumer's (e.g. `"auto"`/`"Auto"`, `"plan"`/`"Plan first"`,
|
|
19
|
+
* or a completely different product's own terms).
|
|
20
|
+
*/
|
|
21
|
+
export interface OperatingMode {
|
|
22
|
+
/** Stable identifier — round-trips through `value`/`onValueChange`. */
|
|
23
|
+
id: string;
|
|
24
|
+
/** Visible + accessible name. */
|
|
25
|
+
label: string;
|
|
26
|
+
/** One line of guidance shown under the label in the menu. */
|
|
27
|
+
description?: string;
|
|
28
|
+
/** A shortcut hint rendered as a `Kbd` chip next to the label (e.g. `"⇧ Tab"`). */
|
|
29
|
+
keyHint?: string;
|
|
30
|
+
/** Leading glyph. Decorative — the mode is identified by `label`, not the icon. */
|
|
31
|
+
icon?: ReactNode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One rung of an app-defined reasoning-effort scale. `brand-ui` ships no
|
|
36
|
+
* vocabulary: `id`/`label` are entirely the consumer's (e.g.
|
|
37
|
+
* `"low"`/`"Low"` … `"max"`/`"Max"`, or a completely different product's own
|
|
38
|
+
* terms and however many rungs it wants).
|
|
39
|
+
*/
|
|
40
|
+
export interface EffortLevel {
|
|
41
|
+
/** Stable identifier — round-trips through `value`/`onValueChange`. */
|
|
42
|
+
id: string;
|
|
43
|
+
/** Visible + accessible name for this rung. */
|
|
44
|
+
label: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Growing-square size rungs (Tailwind's own spacing scale — `size-2.5` through
|
|
49
|
+
* `size-6`), indexed by ordinal position so a caller never invents a raw
|
|
50
|
+
* pixel value. All in ONE `tailwind-merge` class group (`size`), so
|
|
51
|
+
* overriding a base size class is a guaranteed, order-independent swap
|
|
52
|
+
* rather than a `w-*`/`h-*` vs `size-*` conflict tailwind-merge can't see.
|
|
53
|
+
*/
|
|
54
|
+
const EFFORT_SIZE_RUNGS = [
|
|
55
|
+
"size-2.5",
|
|
56
|
+
"size-3",
|
|
57
|
+
"size-3.5",
|
|
58
|
+
"size-4",
|
|
59
|
+
"size-4.5",
|
|
60
|
+
"size-5",
|
|
61
|
+
"size-5.5",
|
|
62
|
+
"size-6",
|
|
63
|
+
] as const;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The size class for rung `index` of `count` evenly-spaced rungs — the
|
|
67
|
+
* growing-square ramp `PromptInputEffort` (and the terminal composer's own
|
|
68
|
+
* effort chips) render, low to high.
|
|
69
|
+
*/
|
|
70
|
+
export function effortRungForIndex(
|
|
71
|
+
index: number,
|
|
72
|
+
count: number,
|
|
73
|
+
): (typeof EFFORT_SIZE_RUNGS)[number] {
|
|
74
|
+
if (count <= 1) return EFFORT_SIZE_RUNGS[0];
|
|
75
|
+
const position = index / (count - 1);
|
|
76
|
+
const rungIndex = Math.min(
|
|
77
|
+
EFFORT_SIZE_RUNGS.length - 1,
|
|
78
|
+
Math.max(0, Math.round(position * (EFFORT_SIZE_RUNGS.length - 1))),
|
|
79
|
+
);
|
|
80
|
+
return EFFORT_SIZE_RUNGS[rungIndex] ?? EFFORT_SIZE_RUNGS[0];
|
|
81
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect a dynamic `import()` of an OPTIONAL peer that is not installed
|
|
3
|
+
* (issue #33 — mermaid / xterm / Rive / media-chrome are optional peers of
|
|
4
|
+
* `@elabs-ai/components-ai`, reached only through its lazy boundaries:
|
|
5
|
+
* `_lazy-mermaid.ts`, `_interactive-terminal-xterm.ts`, `_persona-rive.tsx` and
|
|
6
|
+
* `_audio-player-media-chrome.tsx`).
|
|
7
|
+
*
|
|
8
|
+
* Bundlers and runtimes report this differently (`ERR_MODULE_NOT_FOUND`,
|
|
9
|
+
* `Cannot find module`, `Failed to resolve module specifier`, Vite/browser's
|
|
10
|
+
* `Failed to fetch dynamically imported module`), so match on the SHAPES
|
|
11
|
+
* rather than one runtime's wording. A false positive here is cheap — the
|
|
12
|
+
* developer is told to install a package instead of seeing a raw
|
|
13
|
+
* module-resolution stack trace — and a false negative is the failure mode
|
|
14
|
+
* this exists to avoid.
|
|
15
|
+
*
|
|
16
|
+
* **Canonical home (promoted here per
|
|
17
|
+
* `docs/decisions/2026-09-01-brainless-adoption-architecture.md` § 2).** `ui` is
|
|
18
|
+
* upstream of every layer-2 package (`ai`, `terminal`, …) in the one-way
|
|
19
|
+
* package graph (`tokens` → `ui`/`icons` →
|
|
20
|
+
* `data`/`ai`/`flow`/`maps`/`charts`/`marketing`/`editor`/`viewer`), so this is
|
|
21
|
+
* an ordinary downward import for all of them — unlike the sibling-to-sibling
|
|
22
|
+
* case (e.g. `ai` ↔ `viewer`), which the graph forbids and which is why a
|
|
23
|
+
* safety-critical predicate like this one must never be copy-pasted sideways
|
|
24
|
+
* again. `packages/ai/src/_optional-peer.ts` still exists as a private,
|
|
25
|
+
* underived-prefixed module for one merge window (issue #116 deletes it and
|
|
26
|
+
* rewires its three importers onto this one); nothing imports this copy yet.
|
|
27
|
+
*
|
|
28
|
+
* It does NOT mirror `@elabs-ai/components-viewer`'s PRIMARY mechanism: the
|
|
29
|
+
* viewer's adapters throw a typed `ViewerError` carrying `code`/`packages`
|
|
30
|
+
* fields, and only fall back to string-matching for an error that didn't
|
|
31
|
+
* originate as one. This module has no typed-error primary path — every lazy
|
|
32
|
+
* boundary that uses it classifies purely by matching the caught error's
|
|
33
|
+
* message shape. That difference is why a shape mismatch (a resolved module
|
|
34
|
+
* with the wrong exports, rather than a rejected import) can slip past this
|
|
35
|
+
* detector silently instead of being caught by construction — see
|
|
36
|
+
* `_lazy-mermaid.ts`'s `loadEngine()` guard, added for exactly that gap
|
|
37
|
+
* (issue #33 review).
|
|
38
|
+
*/
|
|
39
|
+
const MODULE_NOT_FOUND_PATTERN =
|
|
40
|
+
/cannot find module|failed to resolve|dynamically imported module|module not found/i;
|
|
41
|
+
|
|
42
|
+
/** True when a raw error MESSAGE names a module the runtime could not load. */
|
|
43
|
+
export function isModuleNotFoundMessage(message: string): boolean {
|
|
44
|
+
return MODULE_NOT_FOUND_PATTERN.test(message);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* True when `value` is (or looks like) a failed dynamic `import()` of a
|
|
49
|
+
* missing optional peer. Accepts anything a `.catch()` or an error boundary
|
|
50
|
+
* might see: an `Error` instance (checked by `code` first, then message), or
|
|
51
|
+
* `undefined`/other for anything that clearly is not one.
|
|
52
|
+
*/
|
|
53
|
+
export function isOptionalPeerMissing(value: unknown): boolean {
|
|
54
|
+
if (!(value instanceof Error)) return false;
|
|
55
|
+
if ("code" in value && (value as { code?: unknown }).code === "ERR_MODULE_NOT_FOUND") {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
return isModuleNotFoundMessage(value.message);
|
|
59
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session-launch vocabulary behind `SessionHeader`
|
|
3
|
+
* (`@elabs-ai/components-ai`, #110) and the terminal CLI look-alike family's
|
|
4
|
+
* own launch banner (issue #117, T7 — the "Empty" and "First-run" states).
|
|
5
|
+
*
|
|
6
|
+
* Promoted here (not duplicated) because `@elabs-ai/components-ai` and
|
|
7
|
+
* `@elabs-ai/components-terminal` are layer-2 DAG SIBLINGS and may not import
|
|
8
|
+
* each other — `ui` is upstream of both
|
|
9
|
+
* (docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). Pure
|
|
10
|
+
* item types, no behaviour.
|
|
11
|
+
*/
|
|
12
|
+
import type { ReactNode } from "react";
|
|
13
|
+
|
|
14
|
+
export interface SessionCapability {
|
|
15
|
+
/** Short capability name, e.g. "Web search". */
|
|
16
|
+
label: string;
|
|
17
|
+
/** One-line detail rendered under the label. */
|
|
18
|
+
description?: string;
|
|
19
|
+
/** Decorative leading glyph — a Lucide icon element. */
|
|
20
|
+
icon?: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SessionWhatsNewItem {
|
|
24
|
+
/** What changed, e.g. "Faster file search". */
|
|
25
|
+
label: string;
|
|
26
|
+
/** Optional link to release notes / a changelog entry. */
|
|
27
|
+
href?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SessionQuickAction {
|
|
31
|
+
/** Visible label, e.g. "New chat". */
|
|
32
|
+
label: string;
|
|
33
|
+
/** Shortcut hint, rendered with the `Kbd` primitive (e.g. "⌘N"). */
|
|
34
|
+
keyHint?: string;
|
|
35
|
+
/** Invoked when the action is chosen. */
|
|
36
|
+
onSelect?: () => void;
|
|
37
|
+
}
|