@opencorestack/opengridx 3.4.0 → 3.5.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +6 -0
  3. package/dist/ai.cjs +2 -1
  4. package/dist/ai.cjs.map +1 -1
  5. package/dist/ai.d.ts +203 -3
  6. package/dist/ai.es.js +2 -1
  7. package/dist/ai.es.js.map +1 -1
  8. package/dist/index.d.ts +248 -4
  9. package/dist/opengridx.css +1 -1
  10. package/dist/opengridx.es.js +7104 -5726
  11. package/dist/opengridx.es.js.map +1 -1
  12. package/dist/opengridx.umd.js +16 -16
  13. package/dist/opengridx.umd.js.map +1 -1
  14. package/docs/API_REFERENCE.md +88 -7
  15. package/docs/README.md +3 -1
  16. package/docs/architecture/datagrid-orchestration.md +9 -4
  17. package/docs/components/filter-panel.md +1 -0
  18. package/docs/components/header.md +4 -1
  19. package/docs/components/toolbar.md +7 -0
  20. package/docs/customization/theming.md +3 -1
  21. package/docs/features/ai-toolkit.md +99 -5
  22. package/docs/features/cell-selection.md +8 -2
  23. package/docs/features/fill-handle.md +125 -0
  24. package/docs/features/filtering.md +3 -0
  25. package/docs/features/header-filters.md +138 -0
  26. package/docs/features/keyboard-navigation.md +20 -2
  27. package/docs/roadmap.md +2 -3
  28. package/lib/ai/agentTools.ts +160 -0
  29. package/lib/ai/index.ts +19 -2
  30. package/lib/ai/promptHandler.ts +109 -0
  31. package/lib/ai/types.ts +89 -25
  32. package/lib/ai/validate.ts +10 -0
  33. package/lib/components/AiAssistant/GridAiAssistantArea.tsx +26 -0
  34. package/lib/components/AiAssistant/GridAiAssistantPanel.css +171 -0
  35. package/lib/components/AiAssistant/GridAiAssistantPanel.tsx +145 -0
  36. package/lib/components/AiAssistant/useSpeechInput.ts +74 -0
  37. package/lib/components/Cell/Cell.css +28 -0
  38. package/lib/components/Cell/Cell.tsx +6 -2
  39. package/lib/components/DataGrid/DataGrid.tsx +76 -3
  40. package/lib/components/DataGrid/GridLiveRegion.tsx +5 -2
  41. package/lib/components/DataGrid/GridPinnedRows.tsx +5 -1
  42. package/lib/components/DataGrid/GridVirtualRows.tsx +5 -1
  43. package/lib/components/FilterPanel/FilterPanel.tsx +2 -9
  44. package/lib/components/Header/Header.tsx +44 -23
  45. package/lib/components/Header/headerColumnStyle.ts +28 -0
  46. package/lib/components/HeaderFilter/HeaderFilter.css +160 -0
  47. package/lib/components/HeaderFilter/HeaderFilterCell.tsx +370 -0
  48. package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +124 -0
  49. package/lib/components/HeaderFilter/HeaderFilterRow.tsx +157 -0
  50. package/lib/components/Toolbar/GridToolbar.tsx +51 -0
  51. package/lib/hooks/core/gridApiPlaceholder.ts +8 -0
  52. package/lib/hooks/core/gridToolbarHostContext.ts +5 -0
  53. package/lib/hooks/core/useGridApiMethods.ts +39 -0
  54. package/lib/hooks/core/useGridAriaRows.ts +5 -3
  55. package/lib/hooks/core/useGridCellSelection.ts +77 -11
  56. package/lib/hooks/core/useGridColumnsPanel.ts +24 -1
  57. package/lib/hooks/core/useGridControlledState.ts +27 -0
  58. package/lib/hooks/core/useGridFillHandle.ts +323 -0
  59. package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
  60. package/lib/hooks/core/useGridToolbarProps.ts +19 -0
  61. package/lib/hooks/features/useGridAiAssistant.ts +284 -0
  62. package/lib/hooks/features/useGridBatchEdit.ts +2 -2
  63. package/lib/index.ts +3 -0
  64. package/lib/styles/opengridx.css +2 -0
  65. package/lib/theme/DataGridThemeProvider.tsx +2 -0
  66. package/lib/theme/types.ts +2 -0
  67. package/lib/types/index.ts +219 -1
  68. package/lib/utils/aiAssistant.ts +205 -0
  69. package/lib/utils/aiBrand.ts +17 -0
  70. package/lib/utils/cellSelection.ts +25 -5
  71. package/lib/utils/editing/fill.ts +238 -0
  72. package/lib/utils/filtering/index.ts +14 -0
  73. package/lib/utils/focus/index.ts +9 -3
  74. package/lib/utils/headerFilters.ts +189 -0
  75. package/llms.txt +44 -4
  76. package/package.json +1 -1
@@ -0,0 +1,109 @@
1
+ // createGridAiPromptHandler: the `aiAssistant.onPrompt` function for an app's own model. It builds the
2
+ // schema once, calls the app's `callModel`, reads the reply (an object, JSON text, or text with a fenced
3
+ // JSON block) and returns the validated, branded result. The grid never sees the raw reply.
4
+ import type { GridAiPromptContext, GridAiPromptResult } from '../types';
5
+ import { brandGridAiValidated } from '../utils/aiBrand';
6
+ import { hasOwn, isPlainObject } from './columns';
7
+ import { getGridAiSchema } from './schema';
8
+ import type { GridAiColumn, GridAiPartOptions, GridAiPromptHandlerOptions, GridAiValidationError } from './types';
9
+ import { validateGridAiState } from './validate';
10
+
11
+ const FENCE = '```';
12
+
13
+ /** A DOMException named `AbortError`, as `fetch` rejects with. */
14
+ export function abortError(): Error {
15
+ return typeof DOMException === 'function'
16
+ ? new DOMException('The operation was aborted.', 'AbortError')
17
+ : Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' });
18
+ }
19
+
20
+ /**
21
+ * Splits a text reply into the JSON it holds and the text around it: the whole text when it starts
22
+ * with `{`, else the first fenced block (with no language or `json`). A plain loop, no regex.
23
+ */
24
+ export function readModelText(text: string): { json: string | null; message: string } {
25
+ const trimmed = text.trim();
26
+ if (trimmed.startsWith('{')) return { json: trimmed, message: '' };
27
+ const open = text.indexOf(FENCE);
28
+ const lineEnd = open < 0 ? -1 : text.indexOf('\n', open + FENCE.length);
29
+ const close = lineEnd < 0 ? -1 : text.indexOf(FENCE, lineEnd + 1);
30
+ const lang = close < 0 ? '' : text.slice(open + FENCE.length, lineEnd).trim().toLowerCase();
31
+ if (close < 0 || (lang !== '' && lang !== 'json')) return { json: null, message: trimmed };
32
+ return {
33
+ json: text.slice(lineEnd + 1, close),
34
+ message: `${text.slice(0, open)}${text.slice(close + FENCE.length)}`.trim(),
35
+ };
36
+ }
37
+
38
+ function errorText(error: unknown): string {
39
+ if (error instanceof Error && error.message) return error.message;
40
+ return typeof error === 'string' && error ? error : 'Unknown error';
41
+ }
42
+
43
+ /** A branded result with an empty state: nothing to apply, the errors explain why. */
44
+ function emptyResult(errors: GridAiValidationError[], message?: string): GridAiPromptResult {
45
+ const result: GridAiPromptResult = { state: brandGridAiValidated({}), errors };
46
+ if (message) result.message = message;
47
+ return brandGridAiValidated(result);
48
+ }
49
+
50
+ /** Validates a model reply into a branded result. Never throws. */
51
+ export function readModelReply(reply: unknown, columns: readonly GridAiColumn[], parts: GridAiPartOptions): GridAiPromptResult {
52
+ let json: unknown = reply;
53
+ let message = '';
54
+ if (typeof reply === 'string') {
55
+ const text = readModelText(reply);
56
+ if (text.json === null) return emptyResult([], text.message || undefined);
57
+ json = text.json;
58
+ message = text.message;
59
+ } else if (isPlainObject(reply) && hasOwn(reply, 'message') && typeof reply.message === 'string') {
60
+ // A reply such as { filterModel, message: 'Showing paid orders' }: the message is not state.
61
+ const { message: text, ...rest } = reply;
62
+ json = rest;
63
+ message = text as string;
64
+ }
65
+ const result: GridAiPromptResult = validateGridAiState(json, columns, parts);
66
+ if (message) result.message = message;
67
+ return result;
68
+ }
69
+
70
+ /**
71
+ * Builds `aiAssistant.onPrompt` for your own model. On each prompt it calls
72
+ * `callModel({ prompt, schema, currentState, history }, { signal })`, reads the reply (a state object,
73
+ * a JSON string, or a fenced JSON block inside text), validates it with `validateGridAiState` and
74
+ * returns the branded result. A `callModel` throw or rejection becomes
75
+ * `{ state: {}, errors: [{ path: '', message }] }`; an abort rejects with an `AbortError`.
76
+ * @since v3.5
77
+ */
78
+ export function createGridAiPromptHandler<C extends GridAiColumn>(
79
+ options: GridAiPromptHandlerOptions<C>,
80
+ ): (prompt: string, context: GridAiPromptContext) => Promise<GridAiPromptResult> {
81
+ const { columns, callModel, parts, schemaOptions = {} } = options;
82
+ const partOptions: GridAiPartOptions = { include: parts ?? schemaOptions.include, exclude: schemaOptions.exclude };
83
+ const schema = getGridAiSchema(columns, { ...schemaOptions, ...partOptions });
84
+
85
+ return async (prompt, context) => {
86
+ const signal = context?.signal ?? new AbortController().signal;
87
+ if (signal.aborted) throw abortError();
88
+ // Stop resolves the prompt at once, even when callModel ignores the signal.
89
+ const aborted = new Promise<never>((_, reject) => {
90
+ signal.addEventListener('abort', () => reject(abortError()), { once: true });
91
+ });
92
+ aborted.catch(() => undefined);
93
+ let reply: unknown;
94
+ try {
95
+ const request = {
96
+ prompt: String(prompt),
97
+ schema,
98
+ currentState: context?.currentState ?? {},
99
+ history: Array.isArray(context?.history) ? context.history : [],
100
+ };
101
+ reply = await Promise.race([callModel(request, { signal }), aborted]);
102
+ } catch (error) {
103
+ if (signal.aborted) throw abortError();
104
+ return emptyResult([{ path: '', message: `The model call failed: ${errorText(error)}` }]);
105
+ }
106
+ if (signal.aborted) throw abortError();
107
+ return readModelReply(reply, columns, partOptions);
108
+ };
109
+ }
package/lib/ai/types.ts CHANGED
@@ -1,10 +1,14 @@
1
- import type {
2
- GridAggregationModel,
3
- GridColumnVisibilityModel,
4
- GridFilterModel,
5
- GridPivotModel,
6
- GridRowGroupingModel,
7
- GridSortItem,
1
+ import type { GridAiPart, GridAiState, GridAiValidationError } from '../types';
2
+
3
+ // The state types live with the grid's own types (lib/types), so the grid's `aiAssistant` prop can
4
+ // use them without importing this entry point. They are type-only and re-exported from here.
5
+ export type {
6
+ GridAiHistoryEntry,
7
+ GridAiPart,
8
+ GridAiPromptContext,
9
+ GridAiPromptResult,
10
+ GridAiState,
11
+ GridAiValidationError,
8
12
  } from '../types';
9
13
 
10
14
  /**
@@ -30,9 +34,6 @@ export interface GridAiColumn {
30
34
  isSpacer?: boolean;
31
35
  }
32
36
 
33
- /** A part of the grid state the model may change. */
34
- export type GridAiPart = 'filter' | 'sort' | 'grouping' | 'aggregation' | 'pivot' | 'columnVisibility';
35
-
36
37
  /** Chooses the parts of the state; `exclude` wins over `include`. Default: every part. */
37
38
  export interface GridAiPartOptions {
38
39
  include?: readonly GridAiPart[];
@@ -66,23 +67,86 @@ export interface GridAiSchema extends GridAiJsonSchema {
66
67
  properties: Record<string, GridAiJsonSchema>;
67
68
  }
68
69
 
69
- /** The validated state: only parts, fields, operators and values the columns allow. */
70
- export interface GridAiState {
71
- filterModel?: GridFilterModel;
72
- sortModel?: GridSortItem[];
73
- rowGroupingModel?: GridRowGroupingModel;
74
- aggregationModel?: GridAggregationModel;
75
- pivotModel?: GridPivotModel;
76
- columnVisibilityModel?: GridColumnVisibilityModel;
70
+ /**
71
+ * The validated state and what was dropped. Since v3.5 the result and its `state` carry the brand
72
+ * `Symbol.for('opengridx.ai.validated')` (not enumerable), which the grid's `aiAssistant` checks.
73
+ */
74
+ export interface GridAiValidationResult {
75
+ state: GridAiState;
76
+ errors: GridAiValidationError[];
77
77
  }
78
78
 
79
- /** One thing the validator dropped or could not use. `path` points into the model output. */
80
- export interface GridAiValidationError {
81
- path: string;
82
- message: string;
79
+ /** What `callModel` receives: send it to your model. @since v3.5 */
80
+ export interface GridAiModelRequest {
81
+ prompt: string;
82
+ /** `getGridAiSchema(columns, …)`: use it as the structured-output format. */
83
+ schema: GridAiSchema;
84
+ currentState: GridAiState;
85
+ history: { prompt: string; applied: GridAiState }[];
83
86
  }
84
87
 
85
- export interface GridAiValidationResult {
86
- state: GridAiState;
87
- errors: GridAiValidationError[];
88
+ /** Options of `createGridAiPromptHandler`. @since v3.5 */
89
+ export interface GridAiPromptHandlerOptions<C extends GridAiColumn = GridAiColumn> {
90
+ columns: readonly C[];
91
+ /**
92
+ * Calls your model. Resolve with its reply: a state object, a JSON string, or text with a fenced JSON
93
+ * block. A string `message` key next to the state, or the text around the JSON, is shown as the reply.
94
+ */
95
+ callModel: (request: GridAiModelRequest, options: { signal: AbortSignal }) => Promise<unknown>;
96
+ /** The parts the model may change (schema and validation). Default: every part. */
97
+ parts?: readonly GridAiPart[];
98
+ /** Options for the schema; its `include` / `exclude` also limit validation. */
99
+ schemaOptions?: GridAiSchemaOptions;
100
+ }
101
+
102
+ /**
103
+ * The part of `GridApi` the agent tools use. A grid's `apiRef.current` fits it.
104
+ * @since v3.5
105
+ */
106
+ export interface GridAgentApi {
107
+ getGridAiState: () => GridAiState;
108
+ setFilterModel: (model: NonNullable<GridAiState['filterModel']>) => void;
109
+ setSortModel: (model: NonNullable<GridAiState['sortModel']>) => void;
110
+ setRowGroupingModel: (model: NonNullable<GridAiState['rowGroupingModel']>) => void;
111
+ setAggregationModel: (model: NonNullable<GridAiState['aggregationModel']>) => void;
112
+ setColumnVisibilityModel: (model: NonNullable<GridAiState['columnVisibilityModel']>) => void;
113
+ getAllFilteredRows: () => object[];
114
+ }
115
+
116
+ /** Options of `createGridAgentTools`. @since v3.5 */
117
+ export interface GridAgentToolsOptions {
118
+ /** Adds `get_row_summary`, the one tool that sends row values to the model. Default: false. */
119
+ allowRowAccess?: boolean;
120
+ /** Fields `get_row_summary` returns. Default: every column the tools were given. */
121
+ fields?: readonly string[];
122
+ /** Most rows `get_row_summary` returns. Default: 20. */
123
+ maxRows?: number;
124
+ }
125
+
126
+ /** What every tool's `execute` resolves with. It never throws or rejects. @since v3.5 */
127
+ export interface GridAgentToolResult {
128
+ ok: boolean;
129
+ /** The state the tool applied. */
130
+ applied?: GridAiState;
131
+ /** Why nothing was applied. */
132
+ errors?: GridAiValidationError[];
133
+ /** `get_grid_state`: the current models. */
134
+ state?: GridAiState;
135
+ /** `get_grid_state`: the columns the tools know. */
136
+ columns?: { field: string; headerName: string; type: string }[];
137
+ /** `get_row_summary`: the number of rows that pass the filter. */
138
+ rowCount?: number;
139
+ /** `get_row_summary`: the first rows, limited to the allowed fields. */
140
+ rows?: Record<string, unknown>[];
141
+ }
142
+
143
+ /**
144
+ * A tool in the shape most tool-calling APIs take: `name`, `description`, a JSON Schema for the input
145
+ * and `execute`. No SDK is involved. @since v3.5
146
+ */
147
+ export interface GridAgentTool {
148
+ name: string;
149
+ description: string;
150
+ inputSchema: GridAiJsonSchema;
151
+ execute: (input: unknown) => Promise<GridAgentToolResult>;
88
152
  }
@@ -14,6 +14,7 @@ import type {
14
14
  GridSortItem,
15
15
  } from '../types';
16
16
  import { toLocalDateString } from '../utils/values';
17
+ import { brandGridAiValidated } from '../utils/aiBrand';
17
18
  import { coerceBoolean, coerceValue } from './coerce';
18
19
  import {
19
20
  ALL_PARTS,
@@ -244,6 +245,9 @@ function readPart(ctx: Ctx, part: GridAiPart, raw: unknown, path: string, state:
244
245
  * parts, fields, operators and enum values are dropped, each with an error; values are coerced to
245
246
  * the column type (`"1,200"` → 1200, a date → `"YYYY-MM-DD"`); filter groups left empty are
246
247
  * removed. Never throws, and never returns a field the columns do not allow.
248
+ *
249
+ * The result and its `state` carry the brand `Symbol.for('opengridx.ai.validated')` (not enumerable):
250
+ * the grid's `aiAssistant` applies only a branded state. Spreading the result keeps `state` branded.
247
251
  * @since v3.4
248
252
  */
249
253
  export function validateGridAiState<C extends GridAiColumn>(
@@ -251,6 +255,12 @@ export function validateGridAiState<C extends GridAiColumn>(
251
255
  columns: readonly C[],
252
256
  options: GridAiValidateOptions = {},
253
257
  ): GridAiValidationResult {
258
+ const result = validate(json, columns, options);
259
+ brandGridAiValidated(result.state);
260
+ return brandGridAiValidated(result);
261
+ }
262
+
263
+ function validate(json: unknown, columns: readonly GridAiColumn[], options: GridAiValidateOptions): GridAiValidationResult {
254
264
  const ctx: Ctx = { errors: [], byField: new Map() };
255
265
  const state: GridAiState = {};
256
266
  try {
@@ -0,0 +1,26 @@
1
+ import type React from 'react';
2
+ import type { GridAiAssistantPanelProps, GridSlots } from '../../types';
3
+ import { GridAiAssistantPanel } from './GridAiAssistantPanel';
4
+
5
+ interface GridAiAssistantAreaProps {
6
+ panelProps: GridAiAssistantPanelProps;
7
+ slot: GridSlots['aiAssistantPanel'];
8
+ slotProps: Record<string, unknown> | undefined;
9
+ }
10
+
11
+ /**
12
+ * Anchors the `aiAssistant` panel (or `slots.aiAssistantPanel`) under the toolbar, over the grid.
13
+ * Escape anywhere in it closes the panel.
14
+ */
15
+ export function GridAiAssistantArea({ panelProps, slot: Slot, slotProps }: GridAiAssistantAreaProps) {
16
+ const handleKeyDown = (event: React.KeyboardEvent) => {
17
+ if (event.key !== 'Escape') return;
18
+ event.stopPropagation();
19
+ panelProps.close();
20
+ };
21
+ return (
22
+ <div className="ogx-ai-anchor" onKeyDown={handleKeyDown}>
23
+ {Slot ? <Slot {...panelProps} {...slotProps} /> : <GridAiAssistantPanel {...panelProps} />}
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,171 @@
1
+ /* aiAssistant panel (v3.5): anchored under the toolbar, over the grid body. */
2
+ .ogx-ai-anchor {
3
+ position: relative;
4
+ height: 0;
5
+ z-index: 20;
6
+ }
7
+ .ogx-ai-panel {
8
+ position: absolute;
9
+ top: 6px;
10
+ right: 8px;
11
+ width: min(480px, calc(100% - 16px));
12
+ max-height: var(--ogx-ai-panel-max-height, 420px);
13
+ overflow: auto;
14
+ box-sizing: border-box;
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: 10px;
18
+ padding: 12px;
19
+ background: var(--ogx-overlay-background, var(--ogx-grid-background));
20
+ color: var(--ogx-grid-row-text);
21
+ border: 1px solid var(--ogx-overlay-border, var(--ogx-grid-border-color));
22
+ border-radius: var(--ogx-border-radius-lg, 8px);
23
+ box-shadow: var(--ogx-shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.15));
24
+ font-size: var(--ogx-font-size-sm);
25
+ }
26
+ .ogx-ai-panel__header {
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: space-between;
30
+ }
31
+ .ogx-ai-panel__title {
32
+ display: inline-flex;
33
+ align-items: center;
34
+ gap: 6px;
35
+ font-weight: 600;
36
+ color: var(--ogx-color-primary);
37
+ }
38
+ .ogx-ai-panel__close {
39
+ min-width: 28px;
40
+ font-size: 18px;
41
+ line-height: 1;
42
+ }
43
+ .ogx-ai-panel__form {
44
+ display: flex;
45
+ gap: 6px;
46
+ align-items: center;
47
+ }
48
+ .ogx-ai-panel__input {
49
+ flex: 1 1 0;
50
+ min-width: 0;
51
+ }
52
+ .ogx-ai-panel__form > .ogx-button {
53
+ flex: 0 0 auto;
54
+ }
55
+ .ogx-ai-panel__mic {
56
+ display: inline-flex;
57
+ align-items: center;
58
+ justify-content: center;
59
+ }
60
+ .ogx-ai-panel__mic--on {
61
+ color: var(--ogx-color-error);
62
+ border-color: var(--ogx-color-error);
63
+ }
64
+ .ogx-ai-panel__suggestions {
65
+ display: flex;
66
+ flex-wrap: wrap;
67
+ gap: 6px;
68
+ }
69
+ .ogx-ai-panel__suggestion {
70
+ padding: 3px 10px;
71
+ border: 1px solid var(--ogx-border-color, var(--ogx-grid-border-color));
72
+ border-radius: var(--ogx-border-radius-round, 999px);
73
+ background: var(--ogx-grid-header-background);
74
+ color: inherit;
75
+ font: inherit;
76
+ cursor: pointer;
77
+ }
78
+ .ogx-ai-panel__suggestion:hover:not(:disabled) {
79
+ background: var(--ogx-grid-row-hover-background);
80
+ }
81
+ .ogx-ai-panel__suggestion:disabled {
82
+ opacity: 0.5;
83
+ cursor: default;
84
+ }
85
+ .ogx-ai-panel__suggestion:focus-visible,
86
+ .ogx-ai-chip__remove:focus-visible,
87
+ .ogx-ai-panel__history-item:focus-visible {
88
+ outline: 2px solid var(--ogx-color-primary);
89
+ outline-offset: 1px;
90
+ }
91
+ .ogx-ai-panel__status {
92
+ color: var(--ogx-color-gray-600, #4b5563);
93
+ }
94
+ .ogx-ai-panel__status--error {
95
+ color: var(--ogx-color-error);
96
+ }
97
+ .ogx-ai-panel__message {
98
+ margin: 0;
99
+ white-space: pre-wrap;
100
+ }
101
+ .ogx-ai-panel__changes {
102
+ display: flex;
103
+ align-items: flex-start;
104
+ gap: 8px;
105
+ }
106
+ .ogx-ai-panel__chips {
107
+ display: flex;
108
+ flex-wrap: wrap;
109
+ gap: 6px;
110
+ flex: 1 1 auto;
111
+ margin: 0;
112
+ padding: 0;
113
+ list-style: none;
114
+ }
115
+ .ogx-ai-chip {
116
+ display: inline-flex;
117
+ align-items: center;
118
+ gap: 4px;
119
+ padding: 2px 4px 2px 10px;
120
+ border-radius: var(--ogx-border-radius-round, 999px);
121
+ background: var(--ogx-color-primary-light, #e0e7ff);
122
+ color: var(--ogx-color-primary-dark, #1e3a8a);
123
+ }
124
+ .ogx-ai-chip__remove {
125
+ width: 20px;
126
+ height: 20px;
127
+ padding: 0;
128
+ border: none;
129
+ border-radius: 50%;
130
+ background: transparent;
131
+ color: inherit;
132
+ font-size: 14px;
133
+ line-height: 1;
134
+ cursor: pointer;
135
+ }
136
+ .ogx-ai-chip__remove:hover {
137
+ background: rgba(0, 0, 0, 0.08);
138
+ }
139
+ .ogx-ai-panel__errors {
140
+ margin: 0;
141
+ padding-left: 18px;
142
+ color: var(--ogx-color-warning, #b45309);
143
+ }
144
+ .ogx-ai-panel__history-title {
145
+ font-weight: 600;
146
+ opacity: 0.7;
147
+ margin-bottom: 2px;
148
+ }
149
+ .ogx-ai-panel__history ul {
150
+ margin: 0;
151
+ padding: 0;
152
+ list-style: none;
153
+ }
154
+ .ogx-ai-panel__history-item {
155
+ padding: 2px 0;
156
+ border: none;
157
+ background: transparent;
158
+ color: var(--ogx-color-primary);
159
+ font: inherit;
160
+ text-align: left;
161
+ cursor: pointer;
162
+ }
163
+ .ogx-toolbar__ai-btn {
164
+ width: auto;
165
+ gap: 4px;
166
+ padding: 0 10px;
167
+ font: inherit;
168
+ font-size: var(--ogx-font-size-sm);
169
+ font-weight: 600;
170
+ color: var(--ogx-color-primary);
171
+ }
@@ -0,0 +1,145 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import type { GridAiAssistantPanelProps } from '../../types';
3
+ import { Button } from '../ui/Button';
4
+ import { Input } from '../ui/Input';
5
+ import { useSpeechInput } from './useSpeechInput';
6
+
7
+ /** The sparkle of the Ask AI button and the panel title. */
8
+ export function AiSparkleIcon() {
9
+ return (
10
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" aria-hidden="true" focusable="false">
11
+ <path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z" />
12
+ <path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z" />
13
+ </svg>
14
+ );
15
+ }
16
+
17
+ function MicIcon() {
18
+ return (
19
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true" focusable="false">
20
+ <rect x="9" y="3" width="6" height="11" rx="3" />
21
+ <path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
22
+ </svg>
23
+ );
24
+ }
25
+
26
+ /**
27
+ * The built-in `aiAssistant` panel: prompt input (Enter sends), suggestions, microphone where the
28
+ * browser has speech recognition, Stop while a request runs, the reply, the applied changes as
29
+ * removable chips with Undo, what was ignored, and the session's earlier prompts.
30
+ */
31
+ export function GridAiAssistantPanel(props: GridAiAssistantPanelProps) {
32
+ const {
33
+ status, statusText, message, errors, chips, history, suggestions, placeholder, voiceAvailable, canUndo,
34
+ submit, stop, undo, removeChip, close,
35
+ } = props;
36
+ const [text, setText] = useState('');
37
+ const inputRef = useRef<HTMLInputElement>(null);
38
+ const speech = useSpeechInput(setText);
39
+ const running = status === 'running';
40
+
41
+ useEffect(() => {
42
+ if (inputRef.current) inputRef.current.focus();
43
+ }, []);
44
+
45
+ const send = (prompt: string) => {
46
+ setText(prompt);
47
+ submit(prompt);
48
+ };
49
+
50
+ // The control a click acts on goes away (Stop, a chip, Undo): focus stays in the panel.
51
+ const thenFocusInput = (action: () => void) => () => {
52
+ action();
53
+ if (inputRef.current) inputRef.current.focus();
54
+ };
55
+
56
+ const handleSubmit = (event: React.FormEvent) => {
57
+ event.preventDefault();
58
+ if (!running) submit(text);
59
+ };
60
+
61
+ return (
62
+ <div className="ogx-ai-panel" role="dialog" aria-label="Ask AI">
63
+ <div className="ogx-ai-panel__header">
64
+ <span className="ogx-ai-panel__title"><AiSparkleIcon />Ask AI</span>
65
+ <Button size="small" className="ogx-ai-panel__close" aria-label="Close" onClick={close}>×</Button>
66
+ </div>
67
+
68
+ <form className="ogx-ai-panel__form" onSubmit={handleSubmit}>
69
+ <Input
70
+ ref={inputRef}
71
+ className="ogx-ai-panel__input"
72
+ value={text}
73
+ placeholder={placeholder}
74
+ aria-label="Prompt"
75
+ autoComplete="off"
76
+ onChange={(event) => setText(event.target.value)}
77
+ />
78
+ {voiceAvailable && (
79
+ <Button
80
+ size="small"
81
+ variant="outlined"
82
+ className={`ogx-ai-panel__mic${speech.listening ? ' ogx-ai-panel__mic--on' : ''}`}
83
+ aria-label={speech.listening ? 'Stop dictation' : 'Dictate a prompt'}
84
+ aria-pressed={speech.listening}
85
+ onClick={speech.toggle}
86
+ >
87
+ <MicIcon />
88
+ </Button>
89
+ )}
90
+ {/* Separate keys: one reused <button> would turn into the submit button during the Stop click and submit again. */}
91
+ {running
92
+ ? <Button key="stop" size="small" variant="outlined" onClick={thenFocusInput(stop)}>Stop</Button>
93
+ : <Button key="ask" size="small" variant="contained" color="primary" type="submit" disabled={text.trim() === ''}>Ask</Button>}
94
+ </form>
95
+
96
+ {suggestions.length > 0 && (
97
+ <div className="ogx-ai-panel__suggestions" aria-label="Suggestions" role="group">
98
+ {suggestions.map((suggestion) => (
99
+ <button key={suggestion} type="button" className="ogx-ai-panel__suggestion" disabled={running} onClick={() => send(suggestion)}>
100
+ {suggestion}
101
+ </button>
102
+ ))}
103
+ </div>
104
+ )}
105
+
106
+ {statusText && <div className={`ogx-ai-panel__status ogx-ai-panel__status--${status}`}>{statusText}</div>}
107
+ {message && <p className="ogx-ai-panel__message">{message}</p>}
108
+
109
+ {(chips.length > 0 || canUndo) && (
110
+ <div className="ogx-ai-panel__changes">
111
+ <ul className="ogx-ai-panel__chips" aria-label="Applied changes">
112
+ {chips.map((chip) => (
113
+ <li key={chip.id} className="ogx-ai-chip" data-part={chip.part}>
114
+ <span className="ogx-ai-chip__label">{chip.label}</span>
115
+ <button type="button" className="ogx-ai-chip__remove" aria-label={`Remove ${chip.label}`} onClick={thenFocusInput(() => removeChip(chip.id))}>×</button>
116
+ </li>
117
+ ))}
118
+ </ul>
119
+ {canUndo && <Button size="small" variant="outlined" className="ogx-ai-panel__undo" onClick={thenFocusInput(undo)}>Undo</Button>}
120
+ </div>
121
+ )}
122
+
123
+ {errors.length > 0 && (
124
+ <ul className="ogx-ai-panel__errors" aria-label="Ignored">
125
+ {errors.map((error, i) => (
126
+ <li key={`${error.path}:${i}`}>{error.path ? `Ignored: ${error.message}` : error.message}</li>
127
+ ))}
128
+ </ul>
129
+ )}
130
+
131
+ {history.length > 0 && (
132
+ <div className="ogx-ai-panel__history">
133
+ <div className="ogx-ai-panel__history-title">Earlier</div>
134
+ <ul>
135
+ {history.map((entry, i) => (
136
+ <li key={`${i}:${entry.prompt}`}>
137
+ <button type="button" className="ogx-ai-panel__history-item" onClick={() => setText(entry.prompt)}>{entry.prompt}</button>
138
+ </li>
139
+ ))}
140
+ </ul>
141
+ </div>
142
+ )}
143
+ </div>
144
+ );
145
+ }