@marimo-team/islands 0.23.9-dev34 → 0.23.9-dev36

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 (41) hide show
  1. package/dist/{ConnectedDataExplorerComponent-MJy-Ll40.js → ConnectedDataExplorerComponent-BQBH2XAd.js} +4 -4
  2. package/dist/assets/__vite-browser-external-TaZstNaH.js +1 -0
  3. package/dist/assets/{worker-BoAkAmaG.js → worker-CZaLU0G8.js} +2 -2
  4. package/dist/{chat-ui-CpX2YcGy.js → chat-ui-IyGT4sju.js} +6 -6
  5. package/dist/{code-visibility-y3APpJ-N.js → code-visibility-DP2xSfeW.js} +525 -410
  6. package/dist/{formats-BIKFEOlR.js → formats-B7_JC7Ba.js} +1 -1
  7. package/dist/{glide-data-editor-DjQd6fKp.js → glide-data-editor-BmM4MCbn.js} +2 -2
  8. package/dist/{html-to-image-QL7QveRm.js → html-to-image-CGbhD84m.js} +5 -5
  9. package/dist/{input-Dh0iMVFM.js → input-Ld3tUgdF.js} +1 -1
  10. package/dist/main.js +18 -18
  11. package/dist/{mermaid-CAibas-0.js → mermaid-BrUZ2PpQ.js} +2 -2
  12. package/dist/{process-output-C657UH7t.js → process-output-B8Cqiywi.js} +1 -1
  13. package/dist/{reveal-component-Cbw9hzrS.js → reveal-component-B7RA3HR2.js} +5 -5
  14. package/dist/{spec-BKuFJIDz.js → spec-nqxKYdNH.js} +1 -1
  15. package/dist/{toDate-BeKbrOvs.js → toDate-DLCQY32Y.js} +1 -1
  16. package/dist/{useAsyncData-yp6n17kh.js → useAsyncData-3f5sSgzf.js} +1 -1
  17. package/dist/{useDeepCompareMemoize-DJvAHUIC.js → useDeepCompareMemoize-Cu37j2QD.js} +1 -1
  18. package/dist/{useLifecycle-CsYXf0Ln.js → useLifecycle-DVkMZA_I.js} +1 -1
  19. package/dist/{useTheme-CK_R9Mn8.js → useTheme-DNcgchnA.js} +11 -2
  20. package/dist/{vega-component-ikfBfkZO.js → vega-component-7odw1pLZ.js} +5 -5
  21. package/package.json +1 -1
  22. package/src/components/app-config/ai-config.tsx +74 -15
  23. package/src/components/chat/chat-panel.tsx +2 -2
  24. package/src/components/data-table/__tests__/header-items.test.tsx +220 -10
  25. package/src/components/data-table/column-header.tsx +17 -12
  26. package/src/components/data-table/header-items.tsx +40 -16
  27. package/src/components/editor/actions/useCellActionButton.tsx +3 -3
  28. package/src/components/editor/cell/code/cell-editor.tsx +7 -4
  29. package/src/components/editor/chrome/types.ts +13 -6
  30. package/src/components/editor/chrome/wrapper/app-chrome.tsx +6 -4
  31. package/src/components/editor/chrome/wrapper/footer-items/ai-status.tsx +10 -1
  32. package/src/components/editor/chrome/wrapper/sidebar.tsx +7 -5
  33. package/src/components/editor/errors/auto-fix.tsx +3 -3
  34. package/src/components/editor/navigation/__tests__/navigation.test.ts +15 -0
  35. package/src/components/editor/navigation/navigation.ts +5 -0
  36. package/src/components/editor/output/MarimoTracebackOutput.tsx +4 -3
  37. package/src/components/editor/renderers/cell-array.tsx +27 -24
  38. package/src/core/config/__tests__/config-schema.test.ts +2 -0
  39. package/src/core/config/config-schema.ts +1 -0
  40. package/src/core/config/config.ts +16 -0
  41. package/dist/assets/__vite-browser-external-BBEFRPue.js +0 -1
@@ -1,6 +1,11 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import type { Column, SortingState } from "@tanstack/react-table";
3
+ import type {
4
+ Column,
5
+ SortDirection,
6
+ SortingState,
7
+ Table,
8
+ } from "@tanstack/react-table";
4
9
  import { fireEvent, render, screen } from "@testing-library/react";
5
10
  import { describe, expect, it, vi } from "vitest";
6
11
  import {
@@ -8,7 +13,23 @@ import {
8
13
  DropdownMenuContent,
9
14
  DropdownMenuTrigger,
10
15
  } from "@/components/ui/dropdown-menu";
11
- import { HideColumn } from "../header-items";
16
+ import {
17
+ ColumnPinning,
18
+ ColumnWrapping,
19
+ CopyColumn,
20
+ DataType,
21
+ FormatOptions,
22
+ HideColumn,
23
+ Sorts,
24
+ } from "../header-items";
25
+
26
+ const renderInMenu = (node: React.ReactNode) =>
27
+ render(
28
+ <DropdownMenu open={true}>
29
+ <DropdownMenuTrigger />
30
+ <DropdownMenuContent>{node}</DropdownMenuContent>
31
+ </DropdownMenu>,
32
+ );
12
33
 
13
34
  describe("multi-column sorting logic", () => {
14
35
  // Extract the core sorting logic to test in isolation
@@ -167,14 +188,6 @@ describe("HideColumn", () => {
167
188
  toggleVisibility,
168
189
  }) as unknown as Column<unknown, unknown>;
169
190
 
170
- const renderInMenu = (node: React.ReactNode) =>
171
- render(
172
- <DropdownMenu open={true}>
173
- <DropdownMenuTrigger />
174
- <DropdownMenuContent>{node}</DropdownMenuContent>
175
- </DropdownMenu>,
176
- );
177
-
178
191
  it("renders 'Hide column' when canHide is true", () => {
179
192
  renderInMenu(<HideColumn column={makeColumn()} />);
180
193
  expect(screen.getByText("Hide column")).toBeInTheDocument();
@@ -192,3 +205,200 @@ describe("HideColumn", () => {
192
205
  expect(toggleVisibility).toHaveBeenCalledWith(false);
193
206
  });
194
207
  });
208
+
209
+ describe("DataType", () => {
210
+ const makeColumn = (dtype?: string) =>
211
+ ({
212
+ columnDef: { meta: dtype === undefined ? {} : { dtype } },
213
+ }) as unknown as Column<unknown, unknown>;
214
+
215
+ it("renders the dtype label when present", () => {
216
+ renderInMenu(<DataType column={makeColumn("int64")} />);
217
+ expect(screen.getByText("int64")).toBeInTheDocument();
218
+ });
219
+
220
+ it("returns null when dtype is absent", () => {
221
+ renderInMenu(<DataType column={makeColumn()} />);
222
+ expect(screen.queryByText("int64")).toBeNull();
223
+ });
224
+ });
225
+
226
+ describe("Sorts", () => {
227
+ const makeColumn = ({
228
+ canSort = true,
229
+ sorted = false,
230
+ sortIndex = 0,
231
+ }: {
232
+ canSort?: boolean;
233
+ sorted?: false | SortDirection;
234
+ sortIndex?: number;
235
+ } = {}) =>
236
+ ({
237
+ getCanSort: () => canSort,
238
+ getIsSorted: () => sorted,
239
+ getSortIndex: () => sortIndex,
240
+ clearSorting: vi.fn(),
241
+ toggleSorting: vi.fn(),
242
+ }) as unknown as Column<unknown, unknown>;
243
+
244
+ const makeTable = (sorting: SortingState) =>
245
+ ({
246
+ getState: () => ({ sorting }),
247
+ resetSorting: vi.fn(),
248
+ }) as unknown as Table<unknown>;
249
+
250
+ it("returns null when the column cannot sort", () => {
251
+ renderInMenu(<Sorts column={makeColumn({ canSort: false })} />);
252
+ expect(screen.queryByText("Asc")).toBeNull();
253
+ });
254
+
255
+ it("renders Asc and Desc items", () => {
256
+ renderInMenu(<Sorts column={makeColumn()} />);
257
+ expect(screen.getByText("Asc")).toBeInTheDocument();
258
+ expect(screen.getByText("Desc")).toBeInTheDocument();
259
+ });
260
+
261
+ it("offers single-column 'Clear sort' when sorted without multi-sort", () => {
262
+ renderInMenu(<Sorts column={makeColumn({ sorted: "asc" })} />);
263
+ expect(screen.getByText("Clear sort")).toBeInTheDocument();
264
+ });
265
+
266
+ it("offers 'Clear all sorts' when the table has multiple sorts", () => {
267
+ renderInMenu(
268
+ <Sorts
269
+ column={makeColumn({ sorted: "asc" })}
270
+ table={makeTable([
271
+ { id: "a", desc: false },
272
+ { id: "b", desc: true },
273
+ ])}
274
+ />,
275
+ );
276
+ expect(screen.getByText("Clear all sorts")).toBeInTheDocument();
277
+ });
278
+ });
279
+
280
+ describe("CopyColumn", () => {
281
+ const makeColumn = ({
282
+ canCopy = true,
283
+ id = "name",
284
+ }: {
285
+ canCopy?: boolean;
286
+ id?: string;
287
+ } = {}) =>
288
+ ({
289
+ id,
290
+ getCanCopy: () => canCopy,
291
+ }) as unknown as Column<unknown, unknown>;
292
+
293
+ it("renders 'Copy column name' when copyable", () => {
294
+ renderInMenu(<CopyColumn column={makeColumn()} />);
295
+ expect(screen.getByText("Copy column name")).toBeInTheDocument();
296
+ });
297
+
298
+ it("returns null when the column cannot be copied", () => {
299
+ renderInMenu(<CopyColumn column={makeColumn({ canCopy: false })} />);
300
+ expect(screen.queryByText("Copy column name")).toBeNull();
301
+ });
302
+ });
303
+
304
+ describe("ColumnPinning", () => {
305
+ const makeColumn = ({
306
+ canPin = true,
307
+ pinned = false,
308
+ }: {
309
+ canPin?: boolean;
310
+ pinned?: false | "left" | "right";
311
+ } = {}) =>
312
+ ({
313
+ getCanPin: () => canPin,
314
+ getIsPinned: () => pinned,
315
+ pin: vi.fn(),
316
+ }) as unknown as Column<unknown, unknown>;
317
+
318
+ it("returns null when the column cannot be pinned", () => {
319
+ renderInMenu(<ColumnPinning column={makeColumn({ canPin: false })} />);
320
+ expect(screen.queryByText("Freeze left")).toBeNull();
321
+ });
322
+
323
+ it("offers freeze options when unpinned", () => {
324
+ renderInMenu(<ColumnPinning column={makeColumn()} />);
325
+ expect(screen.getByText("Freeze left")).toBeInTheDocument();
326
+ expect(screen.getByText("Freeze right")).toBeInTheDocument();
327
+ });
328
+
329
+ it("offers 'Unfreeze' when pinned", () => {
330
+ renderInMenu(<ColumnPinning column={makeColumn({ pinned: "left" })} />);
331
+ expect(screen.getByText("Unfreeze")).toBeInTheDocument();
332
+ });
333
+ });
334
+
335
+ describe("ColumnWrapping", () => {
336
+ const makeColumn = ({
337
+ canWrap = true,
338
+ wrapping = "nowrap",
339
+ }: {
340
+ canWrap?: boolean;
341
+ wrapping?: "wrap" | "nowrap";
342
+ } = {}) =>
343
+ ({
344
+ getCanWrap: () => canWrap,
345
+ getColumnWrapping: () => wrapping,
346
+ toggleColumnWrapping: vi.fn(),
347
+ }) as unknown as Column<unknown, unknown>;
348
+
349
+ it("returns null when the column cannot wrap", () => {
350
+ renderInMenu(<ColumnWrapping column={makeColumn({ canWrap: false })} />);
351
+ expect(screen.queryByText("Wrap text")).toBeNull();
352
+ });
353
+
354
+ it("offers 'Wrap text' when not wrapping", () => {
355
+ renderInMenu(<ColumnWrapping column={makeColumn()} />);
356
+ expect(screen.getByText("Wrap text")).toBeInTheDocument();
357
+ });
358
+
359
+ it("offers 'No wrap text' when wrapping", () => {
360
+ renderInMenu(<ColumnWrapping column={makeColumn({ wrapping: "wrap" })} />);
361
+ expect(screen.getByText("No wrap text")).toBeInTheDocument();
362
+ });
363
+ });
364
+
365
+ describe("FormatOptions", () => {
366
+ const makeColumn = ({
367
+ dataType = "number",
368
+ canFormat = true,
369
+ }: {
370
+ dataType?: string;
371
+ canFormat?: boolean;
372
+ } = {}) =>
373
+ ({
374
+ columnDef: { meta: { dataType } },
375
+ getCanFormat: () => canFormat,
376
+ getColumnFormatting: () => undefined,
377
+ setColumnFormatting: vi.fn(),
378
+ }) as unknown as Column<unknown, unknown>;
379
+
380
+ it("renders the 'Format' submenu trigger for formattable columns", () => {
381
+ renderInMenu(<FormatOptions column={makeColumn()} locale="en-US" />);
382
+ expect(screen.getByText("Format")).toBeInTheDocument();
383
+ });
384
+
385
+ it("returns null when the column cannot be formatted", () => {
386
+ renderInMenu(
387
+ <FormatOptions
388
+ column={makeColumn({ canFormat: false })}
389
+ locale="en-US"
390
+ />,
391
+ );
392
+ expect(screen.queryByText("Format")).toBeNull();
393
+ });
394
+
395
+ it("returns null when the data type has no format options", () => {
396
+ renderInMenu(
397
+ <FormatOptions
398
+ column={makeColumn({ dataType: "unknown" })}
399
+ locale="en-US"
400
+ />,
401
+ );
402
+ expect(screen.queryByText("Format")).toBeNull();
403
+ });
404
+ });
@@ -17,14 +17,14 @@ import { useFilterEditor } from "./filter-editor-context";
17
17
  import { EDITABLE_FILTER_TYPES, isMembershipFilterType } from "./filters";
18
18
  import {
19
19
  ClearFilterMenuItem,
20
+ ColumnPinning,
21
+ ColumnWrapping,
22
+ CopyColumn,
23
+ DataType,
24
+ FormatOptions,
20
25
  HideColumn,
21
- renderColumnPinning,
22
- renderColumnWrapping,
23
- renderCopyColumn,
24
- renderDataType,
25
- renderFormatOptions,
26
26
  renderSortIcon,
27
- renderSorts,
27
+ Sorts,
28
28
  } from "./header-items";
29
29
 
30
30
  interface DataTableColumnHeaderProps<
@@ -36,6 +36,11 @@ interface DataTableColumnHeaderProps<
36
36
  subheader?: React.ReactNode;
37
37
  justify?: "left" | "center" | "right";
38
38
  calculateTopKRows?: CalculateTopKRows;
39
+ /**
40
+ * Optional: only used to surface multi-column sort actions ("Clear all
41
+ * sorts"). Omitted by call sites that define their header inside column
42
+ * definitions, where the table instance isn't yet available.
43
+ */
39
44
  table?: Table<TData>;
40
45
  }
41
46
 
@@ -119,12 +124,12 @@ export const DataTableColumnHeader = <TData, TValue>({
119
124
  </button>
120
125
  </DropdownMenuTrigger>
121
126
  <DropdownMenuContent align="start">
122
- {renderDataType(column)}
123
- {renderSorts(column, table)}
124
- {renderCopyColumn(column)}
125
- {renderColumnPinning(column)}
126
- {renderColumnWrapping(column)}
127
- {renderFormatOptions(column, locale)}
127
+ <DataType column={column} />
128
+ <Sorts column={column} table={table} />
129
+ <CopyColumn column={column} />
130
+ <ColumnPinning column={column} />
131
+ <ColumnWrapping column={column} />
132
+ <FormatOptions column={column} locale={locale} />
128
133
  <HideColumn column={column} />
129
134
  {canEditFilter && <DropdownMenuSeparator />}
130
135
  {canEditFilter && (
@@ -29,10 +29,13 @@ import { formattingExample } from "./column-formatting/feature";
29
29
  import { formatOptions } from "./column-formatting/types";
30
30
  import { NAMELESS_COLUMN_PREFIX } from "./columns";
31
31
 
32
- export function renderFormatOptions<TData, TValue>(
33
- column: Column<TData, TValue>,
34
- locale: string,
35
- ) {
32
+ export function FormatOptions<TData, TValue>({
33
+ column,
34
+ locale,
35
+ }: {
36
+ column: Column<TData, TValue>;
37
+ locale: string;
38
+ }) {
36
39
  const dataType: DataType | undefined = column.columnDef.meta?.dataType;
37
40
  const columnFormatOptions = dataType ? formatOptions[dataType] : [];
38
41
 
@@ -83,9 +86,11 @@ export function renderFormatOptions<TData, TValue>(
83
86
  );
84
87
  }
85
88
 
86
- export function renderColumnWrapping<TData, TValue>(
87
- column: Column<TData, TValue>,
88
- ) {
89
+ export function ColumnWrapping<TData, TValue>({
90
+ column,
91
+ }: {
92
+ column: Column<TData, TValue>;
93
+ }) {
89
94
  if (!column.getCanWrap?.() || !column.getColumnWrapping) {
90
95
  return null;
91
96
  }
@@ -108,9 +113,11 @@ export function renderColumnWrapping<TData, TValue>(
108
113
  );
109
114
  }
110
115
 
111
- export function renderColumnPinning<TData, TValue>(
112
- column: Column<TData, TValue>,
113
- ) {
116
+ export function ColumnPinning<TData, TValue>({
117
+ column,
118
+ }: {
119
+ column: Column<TData, TValue>;
120
+ }) {
114
121
  if (!column.getCanPin?.() || !column.getIsPinned) {
115
122
  return null;
116
123
  }
@@ -157,7 +164,11 @@ export function HideColumn<TData, TValue>({
157
164
  );
158
165
  }
159
166
 
160
- export function renderCopyColumn<TData, TValue>(column: Column<TData, TValue>) {
167
+ export function CopyColumn<TData, TValue>({
168
+ column,
169
+ }: {
170
+ column: Column<TData, TValue>;
171
+ }) {
161
172
  if (!column.getCanCopy?.()) {
162
173
  return null;
163
174
  }
@@ -177,10 +188,19 @@ export function renderCopyColumn<TData, TValue>(column: Column<TData, TValue>) {
177
188
  const AscIcon = ArrowUpNarrowWideIcon;
178
189
  const DescIcon = ArrowDownWideNarrowIcon;
179
190
 
180
- export function renderSorts<TData, TValue>(
181
- column: Column<TData, TValue>,
182
- table?: Table<TData>,
183
- ) {
191
+ /**
192
+ * `table` is optional: it is only needed to detect multi-column sorting and
193
+ * offer "Clear all sorts". Call sites that build their header inside column
194
+ * definitions (where the table instance isn't yet in scope) omit it and fall
195
+ * back to single-column "Clear sort".
196
+ */
197
+ export function Sorts<TData, TValue>({
198
+ column,
199
+ table,
200
+ }: {
201
+ column: Column<TData, TValue>;
202
+ table?: Table<TData>;
203
+ }) {
184
204
  if (!column.getCanSort()) {
185
205
  return null;
186
206
  }
@@ -271,7 +291,11 @@ export function renderSortIcon<TData, TValue>(column: Column<TData, TValue>) {
271
291
  return <Icon className="h-3 w-3" />;
272
292
  }
273
293
 
274
- export function renderDataType<TData, TValue>(column: Column<TData, TValue>) {
294
+ export function DataType<TData, TValue>({
295
+ column,
296
+ }: {
297
+ column: Column<TData, TValue>;
298
+ }) {
275
299
  const dtype: string | undefined = column.columnDef.meta?.dtype;
276
300
  if (!dtype) {
277
301
  return null;
@@ -48,7 +48,7 @@ import {
48
48
  import { switchLanguage } from "@/core/codemirror/language/extension";
49
49
  import { MARKDOWN_INITIAL_HIDE_CODE } from "@/core/codemirror/language/languages/markdown";
50
50
  import {
51
- aiEnabledAtom,
51
+ aiFeaturesEnabledAtom,
52
52
  appWidthAtom,
53
53
  autoInstantiateAtom,
54
54
  } from "@/core/config/config";
@@ -100,7 +100,7 @@ export function useCellActionButtons({ cell, closePopover }: Props) {
100
100
  const deleteCell = useDeleteCellCallback();
101
101
  const { openModal } = useImperativeModal();
102
102
  const setAiCompletionCell = useSetAtom(aiCompletionCellAtom);
103
- const aiEnabled = useAtomValue(aiEnabledAtom);
103
+ const aiFeaturesEnabled = useAtomValue(aiFeaturesEnabledAtom);
104
104
  const autoInstantiate = useAtomValue(autoInstantiateAtom);
105
105
  const kioskMode = useAtomValue(kioskModeAtom);
106
106
  const appWidth = useAtomValue(appWidthAtom);
@@ -162,7 +162,7 @@ export function useCellActionButtons({ cell, closePopover }: Props) {
162
162
  {
163
163
  icon: <SparklesIcon size={13} strokeWidth={1.5} />,
164
164
  label: "Refactor with AI",
165
- hidden: !aiEnabled,
165
+ hidden: !aiFeaturesEnabled,
166
166
  handle: () => {
167
167
  setAiCompletionCell((current) =>
168
168
  current?.cellId === cellId ? null : { cellId },
@@ -26,7 +26,7 @@ import {
26
26
  connectedDocAtom,
27
27
  realTimeCollaboration,
28
28
  } from "@/core/codemirror/rtc/extension";
29
- import { autoInstantiateAtom, isAiEnabled } from "@/core/config/config";
29
+ import { autoInstantiateAtom, isAiFeatureEnabled } from "@/core/config/config";
30
30
  import type { UserConfig } from "@/core/config/config-schema";
31
31
  import { OverridingHotkeyProvider } from "@/core/hotkeys/hotkeys";
32
32
  import { connectionAtom } from "@/core/network/connection";
@@ -173,13 +173,13 @@ const CellEditorInternal = ({
173
173
  });
174
174
  });
175
175
 
176
- const aiEnabled = isAiEnabled(userConfig);
176
+ const aiFeaturesEnabled = isAiFeatureEnabled(userConfig);
177
177
 
178
178
  const extensions = useMemo(() => {
179
179
  const extensions = setupCodeMirror({
180
180
  cellId,
181
181
  showPlaceholder,
182
- enableAI: aiEnabled,
182
+ enableAI: aiFeaturesEnabled,
183
183
  cellActions: {
184
184
  ...cellActions,
185
185
  afterToggleMarkdown,
@@ -201,6 +201,9 @@ const CellEditorInternal = ({
201
201
  splitCell,
202
202
  toggleHideCode,
203
203
  aiCellCompletion: () => {
204
+ if (!aiFeaturesEnabled) {
205
+ return false;
206
+ }
204
207
  let closed = false;
205
208
  setAiCompletionCell((v) => {
206
209
  // Toggle close
@@ -271,7 +274,7 @@ const CellEditorInternal = ({
271
274
  userConfig.display,
272
275
  userConfig.diagnostics,
273
276
  userConfig.ai?.inline_tooltip,
274
- aiEnabled,
277
+ aiFeaturesEnabled,
275
278
  theme,
276
279
  showPlaceholder,
277
280
  cellActions,
@@ -201,16 +201,23 @@ export const PANEL_MAP = new Map<PanelType, PanelDescriptor>(
201
201
  );
202
202
 
203
203
  /**
204
- * Check if a panel should be hidden based on its `hidden` property
205
- * and `requiredCapability`.
204
+ * Check if a panel should be hidden based on its descriptor and runtime state.
206
205
  */
207
- export function isPanelHidden(
208
- panel: PanelDescriptor,
209
- capabilities: Capabilities,
210
- ): boolean {
206
+ export function isPanelHidden({
207
+ panel,
208
+ capabilities,
209
+ aiEnabled,
210
+ }: {
211
+ panel: PanelDescriptor;
212
+ capabilities: Capabilities;
213
+ aiEnabled: boolean;
214
+ }): boolean {
211
215
  if (panel.hidden) {
212
216
  return true;
213
217
  }
218
+ if (panel.type === "ai" && !aiEnabled) {
219
+ return true;
220
+ }
214
221
  if (panel.requiredCapability && !capabilities[panel.requiredCapability]) {
215
222
  return true;
216
223
  }
@@ -27,6 +27,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
27
27
  import { LazyActivity } from "@/components/utils/lazy-mount";
28
28
  import { cellErrorCount } from "@/core/cells/cells";
29
29
  import { capabilitiesAtom } from "@/core/config/capabilities";
30
+ import { aiEnabledAtom } from "@/core/config/config";
30
31
  import { getFeatureFlag } from "@/core/config/feature-flag";
31
32
  import { cn } from "@/utils/cn";
32
33
  import { ErrorBoundary } from "../../boundary/ErrorBoundary";
@@ -93,18 +94,19 @@ export const AppChrome: React.FC<PropsWithChildren> = ({ children }) => {
93
94
  const [panelLayout, setPanelLayout] = useAtom(panelLayoutAtom);
94
95
  // Subscribe to capabilities to re-render when they change (e.g., terminal capability)
95
96
  const capabilities = useAtomValue(capabilitiesAtom);
97
+ const aiEnabled = useAtomValue(aiEnabledAtom);
96
98
 
97
99
  // Convert current developer panel items to PanelDescriptors
98
100
  // Filter out hidden panels (e.g., terminal when capability is not available)
99
101
  const devPanelItems = useMemo(() => {
100
102
  return panelLayout.developerPanel.flatMap((id) => {
101
103
  const panel = PANEL_MAP.get(id);
102
- if (!panel || isPanelHidden(panel, capabilities)) {
104
+ if (!panel || isPanelHidden({ panel, capabilities, aiEnabled })) {
103
105
  return [];
104
106
  }
105
107
  return [panel];
106
108
  });
107
- }, [panelLayout.developerPanel, capabilities]);
109
+ }, [panelLayout.developerPanel, capabilities, aiEnabled]);
108
110
 
109
111
  const handleSetDevPanelItems = (items: PanelDescriptor[]) => {
110
112
  setPanelLayout((prev) => ({
@@ -141,7 +143,7 @@ export const AppChrome: React.FC<PropsWithChildren> = ({ children }) => {
141
143
  const availableDevPanels = useMemo(() => {
142
144
  const sidebarIds = new Set(panelLayout.sidebar);
143
145
  return PANELS.filter((p) => {
144
- if (isPanelHidden(p, capabilities)) {
146
+ if (isPanelHidden({ panel: p, capabilities, aiEnabled })) {
145
147
  return false;
146
148
  }
147
149
  // Exclude panels that are in the sidebar
@@ -150,7 +152,7 @@ export const AppChrome: React.FC<PropsWithChildren> = ({ children }) => {
150
152
  }
151
153
  return true;
152
154
  });
153
- }, [panelLayout.sidebar, capabilities]);
155
+ }, [panelLayout.sidebar, capabilities, aiEnabled]);
154
156
 
155
157
  const emitResizeEvent = useEvent(() => {
156
158
  // HACK: Unfortunately, we have to do this twice to make sure the
@@ -4,18 +4,27 @@ import { useAtomValue } from "jotai";
4
4
  import { SparklesIcon } from "lucide-react";
5
5
  import React from "react";
6
6
  import { useOpenSettingsToTab } from "@/components/app-config/state";
7
- import { aiAtom, aiEnabledAtom } from "@/core/config/config";
7
+ import {
8
+ aiAtom,
9
+ aiEnabledAtom,
10
+ aiModelConfiguredAtom,
11
+ } from "@/core/config/config";
8
12
  import { DEFAULT_AI_MODEL } from "@/core/config/config-schema";
9
13
  import { FooterItem } from "../footer-item";
10
14
 
11
15
  export const AIStatusIcon: React.FC = () => {
12
16
  const ai = useAtomValue(aiAtom);
13
17
  const aiEnabled = useAtomValue(aiEnabledAtom);
18
+ const aiModelConfigured = useAtomValue(aiModelConfiguredAtom);
14
19
  const chatModel = ai?.models?.chat_model || DEFAULT_AI_MODEL;
15
20
  const editModel = ai?.models?.edit_model || chatModel;
16
21
  const { handleClick } = useOpenSettingsToTab();
17
22
 
18
23
  if (!aiEnabled) {
24
+ return null;
25
+ }
26
+
27
+ if (!aiModelConfigured) {
19
28
  return (
20
29
  <FooterItem
21
30
  tooltip="Assist is disabled"
@@ -12,6 +12,7 @@ import {
12
12
  notebookQueuedOrRunningCountAtom,
13
13
  } from "@/core/cells/cells";
14
14
  import { capabilitiesAtom } from "@/core/config/capabilities";
15
+ import { aiEnabledAtom } from "@/core/config/config";
15
16
  import { cn } from "@/utils/cn";
16
17
  import { FeedbackButton } from "../components/feedback-button";
17
18
  import { panelLayoutAtom, useChromeActions, useChromeState } from "../state";
@@ -30,6 +31,7 @@ export const Sidebar: React.FC = () => {
30
31
  const [panelLayout, setPanelLayout] = useAtom(panelLayoutAtom);
31
32
  // Subscribe to capabilities to re-render when they change
32
33
  const capabilities = useAtomValue(capabilitiesAtom);
34
+ const aiEnabled = useAtomValue(aiEnabledAtom);
33
35
 
34
36
  const renderIcon = ({ Icon }: PanelDescriptor, className?: string) => {
35
37
  return <Icon className={cn("h-5 w-5", className)} />;
@@ -40,7 +42,7 @@ export const Sidebar: React.FC = () => {
40
42
  const availableSidebarPanels = useMemo(() => {
41
43
  const devPanelIds = new Set(panelLayout.developerPanel);
42
44
  return PANELS.filter((p) => {
43
- if (isPanelHidden(p, capabilities)) {
45
+ if (isPanelHidden({ panel: p, capabilities, aiEnabled })) {
44
46
  return false;
45
47
  }
46
48
  // Exclude panels that are in the developer panel
@@ -49,19 +51,19 @@ export const Sidebar: React.FC = () => {
49
51
  }
50
52
  return true;
51
53
  });
52
- }, [panelLayout.developerPanel, capabilities]);
54
+ }, [panelLayout.developerPanel, capabilities, aiEnabled]);
53
55
 
54
56
  // Convert current sidebar items to PanelDescriptors
55
57
  // Filter out hidden panels (e.g., when capability is not available)
56
58
  const sidebarItems = useMemo(() => {
57
59
  return panelLayout.sidebar.flatMap((id) => {
58
60
  const panel = PANEL_MAP.get(id);
59
- if (!panel || isPanelHidden(panel, capabilities)) {
61
+ if (!panel || isPanelHidden({ panel, capabilities, aiEnabled })) {
60
62
  return [];
61
63
  }
62
64
  return [panel];
63
65
  });
64
- }, [panelLayout.sidebar, capabilities]);
66
+ }, [panelLayout.sidebar, capabilities, aiEnabled]);
65
67
 
66
68
  const handleSetSidebarItems = (items: PanelDescriptor[]) => {
67
69
  setPanelLayout((prev) => ({
@@ -218,7 +220,7 @@ const SidebarItem: React.FC<
218
220
  // Render as div when not clickable (e.g., inside ReorderableList)
219
221
  // This avoids nested interactive elements which break react-aria's drag behavior
220
222
  const content = onClick ? (
221
- <button className={itemClassName} onClick={onClick}>
223
+ <button type="button" className={itemClassName} onClick={onClick}>
222
224
  {children}
223
225
  </button>
224
226
  ) : (
@@ -13,7 +13,7 @@ import { Tooltip } from "@/components/ui/tooltip";
13
13
  import { aiCompletionCellAtom } from "@/core/ai/state";
14
14
  import { notebookAtom, useCellActions } from "@/core/cells/cells";
15
15
  import type { CellId } from "@/core/cells/ids";
16
- import { aiEnabledAtom } from "@/core/config/config";
16
+ import { aiFeaturesEnabledAtom } from "@/core/config/config";
17
17
  import { getAutoFixes } from "@/core/errors/errors";
18
18
  import type { MarimoError } from "@/core/kernel/messages";
19
19
  import { cn } from "@/utils/cn";
@@ -30,9 +30,9 @@ export const AutoFixButton = ({
30
30
  }) => {
31
31
  const store = useStore();
32
32
  const { createNewCell } = useCellActions();
33
- const aiEnabled = useAtomValue(aiEnabledAtom);
33
+ const aiFeaturesEnabled = useAtomValue(aiFeaturesEnabledAtom);
34
34
  const autoFixes = errors.flatMap((error) =>
35
- getAutoFixes(error, { aiEnabled }),
35
+ getAutoFixes(error, { aiEnabled: aiFeaturesEnabled }),
36
36
  );
37
37
  const setAiCompletionCell = useSetAtom(aiCompletionCellAtom);
38
38
 
@@ -1207,6 +1207,21 @@ describe("useCellNavigationProps", () => {
1207
1207
  });
1208
1208
 
1209
1209
  describe("AI completion functionality", () => {
1210
+ beforeEach(() => {
1211
+ const config = defaultUserConfig();
1212
+ store.set(userConfigAtom, {
1213
+ ...config,
1214
+ ai: {
1215
+ ...config.ai,
1216
+ models: {
1217
+ displayed_models: [],
1218
+ custom_models: [],
1219
+ edit_model: "openai/gpt-4o",
1220
+ },
1221
+ },
1222
+ });
1223
+ });
1224
+
1210
1225
  it("should toggle AI completion when shortcut is pressed", () => {
1211
1226
  const { result } = renderWithProvider(() =>
1212
1227
  useCellNavigationProps(cellId1, optionsWithMockEditor),