@elabs-ai/components-ui 4.0.0 → 4.2.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-FCH4ZN6G.js +57 -0
- package/dist/chunk-FCH4ZN6G.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 +2597 -188
- package/dist/index.js +12855 -3578
- package/dist/index.js.map +1 -1
- package/dist/lib/cn.js +10 -1
- package/dist/lib/cn.js.map +1 -1
- package/package.json +21 -7
- package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
- package/src/__contract__/badge.contract.test.tsx +49 -0
- package/src/__contract__/bounded-number.contract.test.tsx +49 -0
- package/src/__contract__/button.contract.test.tsx +49 -0
- package/src/__contract__/color-picker.contract.test.tsx +49 -0
- package/src/__contract__/combobox.contract.test.tsx +49 -0
- package/src/__contract__/command-trigger.contract.test.tsx +49 -0
- package/src/__contract__/copyable-value.contract.test.tsx +49 -0
- package/src/__contract__/date-picker.contract.test.tsx +49 -0
- package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
- package/src/__contract__/empty-state.contract.test.tsx +49 -0
- package/src/__contract__/error-state.contract.test.tsx +49 -0
- package/src/__contract__/icon-button.contract.test.tsx +49 -0
- package/src/__contract__/input.contract.test.tsx +49 -0
- package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
- package/src/__contract__/list-editor.contract.test.tsx +49 -0
- package/src/__contract__/loading-state.contract.test.tsx +49 -0
- package/src/__contract__/metric-card.contract.test.tsx +49 -0
- package/src/__contract__/model-picker.contract.test.tsx +49 -0
- package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
- package/src/__contract__/number-input.contract.test.tsx +49 -0
- package/src/__contract__/rating.contract.test.tsx +49 -0
- package/src/__contract__/schema-form.contract.test.tsx +49 -0
- package/src/__contract__/section-header.contract.test.tsx +49 -0
- package/src/__contract__/segmented-field.contract.test.tsx +49 -0
- package/src/__contract__/slider-number.contract.test.tsx +49 -0
- package/src/__contract__/status-badge.contract.test.tsx +49 -0
- package/src/__contract__/tag-input.contract.test.tsx +49 -0
- package/src/__contract__/text.contract.test.tsx +49 -0
- package/src/__contract__/textarea.contract.test.tsx +49 -0
- package/src/__contract__/timeline.contract.test.tsx +49 -0
- package/src/__contract__/toggle.contract.test.tsx +49 -0
- package/src/__contract__/transfer.contract.test.tsx +49 -0
- package/src/__contract__/tree-select.contract.test.tsx +49 -0
- package/src/__contract__/tree.contract.test.tsx +49 -0
- package/src/__contract__/virtual-select.contract.test.tsx +49 -0
- package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +13 -4
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +15 -4
- package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
- package/src/components/alert-dialog/alert-dialog.tsx +28 -6
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.test.tsx +99 -0
- package/src/components/app-shell/app-shell.tsx +56 -4
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +3 -1
- package/src/components/attribution-panel/attributions.generated.ts +83 -79
- package/src/components/avatar/avatar.tsx +4 -1
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.test.tsx +8 -0
- package/src/components/badge/badge.tsx +15 -5
- package/src/components/bento-grid/bento-grid.test.tsx +24 -3
- package/src/components/bento-grid/bento-grid.tsx +11 -1
- package/src/components/bounded-number/bounded-number.test.tsx +1 -1
- package/src/components/bounded-number/bounded-number.tsx +78 -75
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +10 -4
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +18 -12
- package/src/components/button-group/button-group.tsx +1 -1
- package/src/components/calendar/calendar.tsx +3 -2
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.test.tsx +24 -2
- package/src/components/card/card.tsx +48 -7
- package/src/components/carousel/carousel.test.tsx +169 -0
- package/src/components/carousel/carousel.tsx +93 -32
- 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 +214 -24
- package/src/components/checkbox/checkbox.tsx +2 -1
- package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
- package/src/components/color-picker/color-picker.tsx +22 -15
- package/src/components/combobox/combobox.stories.tsx +10 -0
- package/src/components/combobox/combobox.test.tsx +54 -0
- package/src/components/combobox/combobox.tsx +25 -11
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +88 -0
- package/src/components/command/command.tsx +38 -13
- 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-menu/context-menu.tsx +16 -7
- 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.stories.tsx +10 -0
- package/src/components/copyable-value/copyable-value.test.tsx +28 -1
- package/src/components/copyable-value/copyable-value.tsx +13 -2
- package/src/components/date-picker/date-picker.stories.tsx +9 -0
- package/src/components/date-picker/date-picker.test.tsx +52 -0
- package/src/components/date-picker/date-picker.tsx +13 -15
- package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
- package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
- package/src/components/date-range-picker/date-range-picker.tsx +39 -28
- package/src/components/descriptions/descriptions.tsx +1 -1
- package/src/components/dialog/dialog.test.tsx +8 -3
- package/src/components/dialog/dialog.tsx +14 -6
- package/src/components/drawer/drawer.tsx +23 -6
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
- 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 +342 -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 +50 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +90 -12
- package/src/components/file-upload/file-upload.tsx +164 -31
- package/src/components/form/form.tsx +8 -3
- package/src/components/hover-card/hover-card.tsx +1 -0
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +3 -3
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +8 -4
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +18 -9
- package/src/components/input-otp/input-otp.tsx +8 -2
- 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/label/label.tsx +1 -1
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/list-editor/list-editor.test.tsx +24 -0
- package/src/components/list-editor/list-editor.tsx +126 -52
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-keys.test.ts +134 -0
- 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 +822 -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.stories.tsx +3 -3
- package/src/components/mention-input/mention-input.test.tsx +51 -1
- package/src/components/mention-input/mention-input.tsx +66 -46
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/menubar/menubar.tsx +16 -6
- 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 +8 -4
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
- package/src/components/nav-notifications/nav-notifications.tsx +67 -9
- package/src/components/nav-user/nav-user.tsx +5 -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 +204 -15
- package/src/components/number-input/number-input.stories.tsx +7 -3
- package/src/components/number-input/number-input.test.tsx +47 -0
- package/src/components/number-input/number-input.tsx +65 -24
- 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 +109 -0
- package/src/components/pagination/pagination.tsx +27 -19
- package/src/components/popover/popover.tsx +1 -0
- package/src/components/progress/progress.tsx +2 -0
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +3 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.test.tsx +76 -0
- package/src/components/resizable/resizable.tsx +32 -6
- 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 +53 -5
- 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-store.ts +506 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +1021 -0
- package/src/components/schema-form/schema-form.tsx +1381 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +14 -3
- package/src/components/select/select.tsx +13 -7
- package/src/components/separator/separator.tsx +1 -0
- package/src/components/sheet/sheet.test.tsx +39 -0
- package/src/components/sheet/sheet.tsx +40 -9
- 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 +299 -0
- package/src/components/side-dock/side-dock.tsx +515 -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 +71 -2
- package/src/components/sidebar/sidebar.tsx +264 -38
- package/src/components/skeleton/skeleton.tsx +1 -0
- 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/slider-number/slider-number.stories.tsx +4 -1
- package/src/components/slider-number/slider-number.test.tsx +1 -1
- package/src/components/sonner/sonner.tsx +2 -0
- package/src/components/spinner/spinner.test.tsx +18 -0
- package/src/components/spinner/spinner.tsx +14 -4
- 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 +110 -24
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +3 -1
- package/src/components/table/table.stories.tsx +64 -0
- package/src/components/table/table.test.tsx +149 -1
- package/src/components/table/table.tsx +71 -2
- package/src/components/tabs/index.ts +10 -1
- package/src/components/tabs/tabs.stories.tsx +48 -0
- package/src/components/tabs/tabs.test.tsx +67 -0
- package/src/components/tabs/tabs.tsx +176 -72
- package/src/components/tag-input/tag-input.tsx +6 -3
- package/src/components/team-switcher/team-switcher.tsx +35 -6
- package/src/components/textarea/textarea.tsx +2 -2
- package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
- package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
- package/src/components/theme-switcher/theme-switcher.tsx +140 -12
- 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 +10 -3
- package/src/components/toggle/toggle.tsx +7 -2
- package/src/components/toggle-group/toggle-group.tsx +2 -0
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tooltip/tooltip.tsx +2 -1
- package/src/components/top-nav/top-nav.tsx +1 -1
- package/src/components/transfer/transfer.tsx +35 -30
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.test.tsx +41 -0
- package/src/components/tree/tree.tsx +91 -31
- package/src/components/tree/use-tree-keyboard.ts +37 -14
- package/src/components/tree-select/tree-select.stories.tsx +9 -0
- package/src/components/tree-select/tree-select.test.tsx +5 -0
- package/src/components/tree-select/tree-select.tsx +11 -14
- package/src/components/typography/prose.test.tsx +3 -2
- package/src/components/typography/prose.tsx +3 -3
- 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.stories.tsx +9 -0
- package/src/components/virtual-select/virtual-select.test.tsx +29 -3
- package/src/components/virtual-select/virtual-select.tsx +56 -18
- package/src/components/wizard/wizard.test.tsx +9 -0
- package/src/components/wizard/wizard.tsx +13 -5
- 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 +80 -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/cn.ts +9 -0
- package/src/lib/csv.ts +48 -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 +94 -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/merge-refs.ts +2 -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-controllable-state.ts +47 -0
- package/src/lib/use-mobile.test.tsx +47 -0
- package/src/lib/use-mobile.ts +34 -12
- 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,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,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The context-run-collapsing hunk-windowing algorithm behind `DiffView`
|
|
5
|
+
* (`@elabs-ai/components-ai`, #102, where it shipped as the private
|
|
6
|
+
* `useVisibleRows`) and the terminal CLI look-alike family's own diff hunk
|
|
7
|
+
* (issue #117).
|
|
8
|
+
*
|
|
9
|
+
* Promoted here (not duplicated) because `@elabs-ai/components-ai` and
|
|
10
|
+
* `@elabs-ai/components-terminal` are layer-2 DAG SIBLINGS and may not import
|
|
11
|
+
* each other — `ui` is upstream of both
|
|
12
|
+
* (docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). The
|
|
13
|
+
* windowing algorithm is shiki-free and DOM-free; `ui` already hosts hooks
|
|
14
|
+
* (`use-mobile`, `use-copy-to-clipboard`), so a hook alongside a pure
|
|
15
|
+
* function is an ordinary shape here, not a new category.
|
|
16
|
+
*/
|
|
17
|
+
import { useCallback, useMemo, useState } from "react";
|
|
18
|
+
import type { DiffLine } from "./diff-line";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One rendered row: either a real diff line, or a collapsed run of
|
|
22
|
+
* consecutive `context` lines longer than the caller's `contextLines`.
|
|
23
|
+
*/
|
|
24
|
+
export type DiffRow =
|
|
25
|
+
| { kind: "line"; line: DiffLine; index: number }
|
|
26
|
+
| { kind: "collapsed"; runStart: number; hiddenCount: number };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Collapse long runs of consecutive `context` lines behind a single
|
|
30
|
+
* `"collapsed"` row, keeping `Math.ceil(contextLines / 2)` lines of context
|
|
31
|
+
* at the top of the run and `Math.floor(contextLines / 2)` at the bottom —
|
|
32
|
+
* so a reader always sees context on both sides of a hidden gap. A run
|
|
33
|
+
* already in `expanded` (by its start index) renders in full instead.
|
|
34
|
+
*
|
|
35
|
+
* Pure: no state, no effect. `useDiffRows` below is the stateful wrapper
|
|
36
|
+
* that owns the `expanded` set for a component.
|
|
37
|
+
*/
|
|
38
|
+
export function collapseDiffRows(
|
|
39
|
+
lines: DiffLine[],
|
|
40
|
+
contextLines: number | undefined,
|
|
41
|
+
expanded: ReadonlySet<number> = new Set(),
|
|
42
|
+
): DiffRow[] {
|
|
43
|
+
if (!contextLines || contextLines <= 0) {
|
|
44
|
+
return lines.map((line, index) => ({ kind: "line", line, index }));
|
|
45
|
+
}
|
|
46
|
+
const out: DiffRow[] = [];
|
|
47
|
+
let i = 0;
|
|
48
|
+
while (i < lines.length) {
|
|
49
|
+
if (lines[i]?.type !== "context") {
|
|
50
|
+
out.push({ kind: "line", line: lines[i]!, index: i });
|
|
51
|
+
i++;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const runStart = i;
|
|
55
|
+
let j = i;
|
|
56
|
+
while (j < lines.length && lines[j]?.type === "context") j++;
|
|
57
|
+
const runLength = j - runStart;
|
|
58
|
+
if (runLength <= contextLines || expanded.has(runStart)) {
|
|
59
|
+
for (let k = runStart; k < j; k++) out.push({ kind: "line", line: lines[k]!, index: k });
|
|
60
|
+
} else {
|
|
61
|
+
const top = Math.ceil(contextLines / 2);
|
|
62
|
+
const bottom = Math.floor(contextLines / 2);
|
|
63
|
+
for (let k = runStart; k < runStart + top; k++)
|
|
64
|
+
out.push({ kind: "line", line: lines[k]!, index: k });
|
|
65
|
+
out.push({ kind: "collapsed", runStart, hiddenCount: runLength - top - bottom });
|
|
66
|
+
for (let k = j - bottom; k < j; k++) out.push({ kind: "line", line: lines[k]!, index: k });
|
|
67
|
+
}
|
|
68
|
+
i = j;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Stateful wrapper around `collapseDiffRows`: owns the set of run-start
|
|
75
|
+
* indices a caller has expanded via the "show more" control, and returns the
|
|
76
|
+
* current row list alongside an `expand` action.
|
|
77
|
+
*/
|
|
78
|
+
export function useDiffRows(
|
|
79
|
+
lines: DiffLine[],
|
|
80
|
+
contextLines: number | undefined,
|
|
81
|
+
): { rows: DiffRow[]; expand: (runStart: number) => void } {
|
|
82
|
+
const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set());
|
|
83
|
+
|
|
84
|
+
const rows = useMemo(
|
|
85
|
+
() => collapseDiffRows(lines, contextLines, expanded),
|
|
86
|
+
[lines, contextLines, expanded],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const expand = useCallback((runStart: number) => {
|
|
90
|
+
setExpanded((prev) => new Set(prev).add(runStart));
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
return { rows, expand };
|
|
94
|
+
}
|
|
@@ -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
|
+
}
|
package/src/lib/merge-refs.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { defaultSlashCommandFilter, stepIndex, type SlashCommand } from "./slash-command";
|
|
3
|
+
|
|
4
|
+
describe("defaultSlashCommandFilter", () => {
|
|
5
|
+
const command: SlashCommand = { name: "help", keywords: ["support", "docs"] };
|
|
6
|
+
|
|
7
|
+
it("matches everything on an empty query", () => {
|
|
8
|
+
expect(defaultSlashCommandFilter(command, "")).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("matches a case-insensitive prefix of the name", () => {
|
|
12
|
+
expect(defaultSlashCommandFilter(command, "HEL")).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("does not match a substring that is not a prefix", () => {
|
|
16
|
+
expect(defaultSlashCommandFilter(command, "elp")).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("falls back to a prefix match on a keyword", () => {
|
|
20
|
+
expect(defaultSlashCommandFilter(command, "doc")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("rejects a query matching neither the name nor a keyword", () => {
|
|
24
|
+
expect(defaultSlashCommandFilter(command, "deploy")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("stepIndex", () => {
|
|
29
|
+
it("returns -1 for an empty list", () => {
|
|
30
|
+
expect(stepIndex(0, 0, 1)).toBe(-1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("moves forward by one", () => {
|
|
34
|
+
expect(stepIndex(3, 0, 1)).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("wraps forward past the end", () => {
|
|
38
|
+
expect(stepIndex(3, 2, 1)).toBe(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("wraps backward past the start", () => {
|
|
42
|
+
expect(stepIndex(3, 0, -1)).toBe(2);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command model behind a `/`-triggered slash-command palette
|
|
3
|
+
* (`PromptInputSlash` in `@elabs-ai/components-ai`, and the terminal CLI
|
|
4
|
+
* look-alike family's own palette — 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, so this is the one legal shared home
|
|
9
|
+
* (docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4, the
|
|
10
|
+
* promotion rule: "a helper shared by two layer-2 siblings moves UP to `ui`,
|
|
11
|
+
* it is never duplicated sideways"). No DOM, no keyboard wiring, no rendering
|
|
12
|
+
* — the popover, the caret tracking and the row markup stay in each
|
|
13
|
+
* package's own presentational component.
|
|
14
|
+
*/
|
|
15
|
+
import type { ReactNode } from "react";
|
|
16
|
+
|
|
17
|
+
/** One entry a slash palette offers. Ships no vocabulary — entirely prop-driven. */
|
|
18
|
+
export interface SlashCommand {
|
|
19
|
+
/** Stable name, WITHOUT the leading "/" (e.g. `"help"`, not `"/help"`). */
|
|
20
|
+
name: string;
|
|
21
|
+
/** One line shown next to the name. */
|
|
22
|
+
description?: string;
|
|
23
|
+
/** Extra prefix-match aids beyond `name` (matched case-insensitively). */
|
|
24
|
+
keywords?: string[];
|
|
25
|
+
/** Leading glyph. Decorative — the row is identified by `name`. */
|
|
26
|
+
icon?: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Case-insensitive PREFIX match on `name`, falling back to a prefix match on
|
|
31
|
+
* any `keyword`. Prefix (not substring) is deliberate: `/hel` should surface
|
|
32
|
+
* `/help`, not e.g. a hypothetical `/channel`.
|
|
33
|
+
*/
|
|
34
|
+
export function defaultSlashCommandFilter(command: SlashCommand, query: string): boolean {
|
|
35
|
+
const q = query.toLowerCase();
|
|
36
|
+
if (q.length === 0) return true;
|
|
37
|
+
if (command.name.toLowerCase().startsWith(q)) return true;
|
|
38
|
+
return (command.keywords ?? []).some((keyword) => keyword.toLowerCase().startsWith(q));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Next index in `[0, count)`, wrapping at both ends. `-1` when `count` is 0. */
|
|
42
|
+
export function stepIndex(count: number, from: number, direction: 1 | -1): number {
|
|
43
|
+
if (count === 0) return -1;
|
|
44
|
+
return (((from + direction) % count) + count) % count;
|
|
45
|
+
}
|