From 8a33c094b4ab52d3a93c3d3ba26cdf2eb5bba034 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:15:29 +0100 Subject: [PATCH 1/4] chore(www): add /evals to the sitemap (#49226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Requested by **Sean Oliver** · [Slack thread](https://supabase.slack.com/archives/C07P3AU3J2D/p1787036390117589?thread_ts=1787036390.117589&cid=C07P3AU3J2D)_ ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Chore. One entry added to the www sitemap generator. ## What is the current behavior? `https://supabase.com/evals` is missing from `sitemap_www.xml`, so search crawlers are never told the page exists. `robots.txt` doesn't block it, they just have no way to find it from the sitemap. The reason is that `/evals` is served by a separate Vercel project and only reaches supabase.com through a proxy rewrite in `apps/www/lib/rewrites.js`: ```js { source: '/evals', destination: 'https://supabase-evals.vercel.app', }, ``` `apps/www/internals/generate-sitemap.mjs` builds its URL list by globbing local route source files (`pages/**`, `_blog/*.mdx`, prerendered `.next/server/pages/**`, etc.) and never resolves rewrites. There is no page file behind `/evals`, so the globs can't discover it. Closes GROWTH-1113. ## What is the new behavior? `https://supabase.com/evals` appears once in the generated `sitemap_www.xml`, with the same `weekly` and `0.5` as every other entry in the file (no entry in this sitemap carries a ``). The entry is a small named const spread into the final `urlset` join, next to `changelogDetailUrls` — the existing precedent in this file for URLs with no page file behind them. Nothing else in the script changed, and the sitemap index output (`sitemap.xml`) is byte-identical. ```diff + // /evals is a separate app proxied onto supabase.com via a rewrite in lib/rewrites.js, + // so it has no page file for the globs above to find. Hardcode it here. + const proxiedAppUrls = [ + ` + + https://supabase.com/evals + weekly + 0.5 + + `, + ] + const sitemap = ` - ${[...staticUrls, ...changelogDetailUrls].join('')} + ${[...staticUrls, ...changelogDetailUrls, ...proxiedAppUrls].join('')} ` ``` This only makes the URL discoverable. Whether the page content itself is crawlable is separate work, tracked in the evals repo. ## Additional context Verification, run locally against this branch. The generator runs standalone (`node ./internals/generate-sitemap.mjs` from `apps/www`); a missing `.next` just means the globs match fewer pages, and the missing changelog RSS is caught internally. I generated `sitemap_www.xml` from `master` and from this branch and diffed the two. The added entry is the only difference: ``` 3271a3272,3277 > > > https://supabase.com/evals > weekly > 0.5 > ``` Exactly one occurrence, with its neighbouring entry for context: ``` $ grep -c 'https://supabase.com/evals' public/sitemap_www.xml 1 https://supabase.com/terms weekly 0.5 https://supabase.com/evals weekly 0.5 ``` Other checks: - Both outputs parse as well-formed XML (Python `xml.dom.minidom`): `sitemap_www.xml` has 545 `` elements, `sitemap.xml` parses OK. - `sitemap.xml` (the sitemap index) is identical to the pre-change output; `diff` reports no changes. - `npx prettier --check internals/generate-sitemap.mjs` → "All matched files use Prettier code style!" - Both generated sitemaps are gitignored (`apps/www/.gitignore` lines 29-30), confirmed with `git check-ignore`. `git status` shows only `apps/www/internals/generate-sitemap.mjs`, so no generated file is in the commit. - No test, snapshot, or fixture anywhere in the repo references the sitemap generator, so there was nothing to run. Its only caller is `apps/www`'s `postbuild` script. Not run: `pnpm --filter=www build`. It fails during "Collecting page data" on a clean `master` checkout in this environment too, so the failure is pre-existing and unrelated, and this change needs no build to verify. Co-authored-by: Claude --- apps/www/internals/generate-sitemap.mjs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/www/internals/generate-sitemap.mjs b/apps/www/internals/generate-sitemap.mjs index af7aa31cd73dd..939c6be54d1d6 100644 --- a/apps/www/internals/generate-sitemap.mjs +++ b/apps/www/internals/generate-sitemap.mjs @@ -139,10 +139,22 @@ async function generate() { } })() + // /evals is a separate app proxied onto supabase.com via a rewrite in lib/rewrites.js, + // so it has no page file for the globs above to find. Hardcode it here. + const proxiedAppUrls = [ + ` + + https://supabase.com/evals + weekly + 0.5 + + `, + ] + const sitemap = ` - ${[...staticUrls, ...changelogDetailUrls].join('')} + ${[...staticUrls, ...changelogDetailUrls, ...proxiedAppUrls].join('')} ` From bd76d7fc3460dd6a5665e0d04432ebb00f24235e Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 20 Aug 2026 11:35:34 +1000 Subject: [PATCH 2/4] feat(studio): wrap assistant Edge Function approval in a Confirm card (#49168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature / UI refactor. ## What is the current behavior? Assistant Edge Function approval nests `ConfirmFooter` under the function block. `addToolApprovalResponse` is wired whenever state is `approval-requested`, including automatic approvals. ## What is the new behavior? Introduces a `Confirm` card that owns the frame, with the footer attached below the body. Edge Function approval uses that card. Interactive Approve/Deny only runs for manual `approval-requested` parts (`!approval.isAutomatic`), matching the [AI SDK tool-approvals `useChat` guidelines](https://ai-sdk.dev/docs/agents/tool-approvals). SQL still uses `DisplayBlockRenderer` until #49170. `ConfirmFooter` is inlined into `Confirm` so SQL can keep importing the named footer until that PR. ## Additional context Part of stack #49171. Base: `chore/ai-sdk-7` (#49167). Notebook proposal Confirm wrapping is **not** in this stack — that file lives on [#49159](https://github.com/supabase/supabase/pull/49159). Follow up after that stack merges. ## Test plan - [ ] Deploy-edge-function tool part shows Confirm with Skip / Deploy - [ ] Existing-function replace warning still requires the second confirm - [ ] After approve, footer morphs to loading and buttons disable - [ ] `Confirm.utils.test.ts` and `EdgeFunctionRenderer.test.tsx` pass ## Summary by CodeRabbit * **New Features** * Added confirmation cards for AI-assisted actions, including approve and cancel controls. * Improved handling of manual approval requests for SQL execution, notebook changes, and Edge Function deployment. * Added support for customizing report and Edge Function block styling. * **Bug Fixes** * Automatic approvals no longer appear as pending manual confirmations. * Skipped SQL actions now provide clearer messaging. * **Tests** * Expanded coverage for approval states, confirmation controls, and automatic decisions. --------- Co-authored-by: Cursor Co-authored-by: Claude Opus 5 --- .../ReportBlock/ReportBlockContainer.tsx | 7 +- .../AIAssistant.utils.test.ts | 12 ++ .../ui/AIAssistantPanel/AIAssistant.utils.ts | 3 +- .../ui/AIAssistantPanel/AssistantChat.tsx | 2 +- .../ui/AIAssistantPanel/Confirm.tsx | 126 +++++++++++++++++ .../ui/AIAssistantPanel/Confirm.utils.test.ts | 132 ++++++++++++++++++ .../ui/AIAssistantPanel/Confirm.utils.ts | 86 ++++++++++++ .../ui/AIAssistantPanel/ConfirmFooter.tsx | 42 ------ .../AIAssistantPanel/DisplayBlockRenderer.tsx | 2 +- .../EdgeFunctionRenderer.test.tsx | 18 ++- .../AIAssistantPanel/EdgeFunctionRenderer.tsx | 45 +++--- .../ui/AIAssistantPanel/Message.Parts.tsx | 28 ++-- .../NotebookProposalRenderer.tsx | 9 +- .../EdgeFunctionBlock/EdgeFunctionBlock.tsx | 3 + apps/studio/lib/ai/message-utils.test.ts | 58 ++++++-- apps/studio/lib/ai/message-utils.ts | 36 ++++- apps/studio/lib/ai/prompts.ts | 2 +- 17 files changed, 508 insertions(+), 103 deletions(-) create mode 100644 apps/studio/components/ui/AIAssistantPanel/Confirm.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/Confirm.utils.test.ts create mode 100644 apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts delete mode 100644 apps/studio/components/ui/AIAssistantPanel/ConfirmFooter.tsx diff --git a/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlockContainer.tsx b/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlockContainer.tsx index 44e7ed8722c5c..d14e2862687f1 100644 --- a/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlockContainer.tsx +++ b/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlockContainer.tsx @@ -11,6 +11,7 @@ interface ReportBlockContainerProps { draggable?: boolean showDragHandle?: boolean tooltip?: ReactNode + className?: string onDragStart?: (e: DragEvent) => void } @@ -23,6 +24,7 @@ export const ReportBlockContainer = ({ draggable = false, showDragHandle = false, tooltip, + className, onDragStart, children, }: PropsWithChildren) => { @@ -35,7 +37,10 @@ export const ReportBlockContainer = ({ draggable={draggable} unselectable={draggable ? 'on' : undefined} onDragStart={onDragStart} - className="h-full flex flex-col overflow-hidden bg-surface-100 border-overlay relative rounded-sm border shadow-xs" + className={cn( + 'h-full flex flex-col overflow-hidden bg-surface-100 border-overlay relative rounded-sm border shadow-xs', + className + )} > diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts index 2f28dda6bdb94..e7c9eaa7d25ef 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts @@ -127,6 +127,18 @@ describe('AIAssistant.utils.ts:hasPendingToolApproval', () => { expect(hasPendingToolApproval(messages)).toBe(true) }) + + test('Should ignore automatic approvals', () => { + const messages = createMessageWithPart({ + type: 'tool-execute_sql', + toolCallId: 'call-1', + state: 'approval-requested', + input: { sql: 'select 1', label: 'Test query' }, + approval: { id: 'approval-1', isAutomatic: true }, + } as UIMessage['parts'][number]) + + expect(hasPendingToolApproval(messages)).toBe(false) + }) }) describe('AIAssistant.utils.ts:resolvePendingToolApprovalsAsDenied', () => { diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts index 92d4b1d694eaa..a3cb9b63a3f47 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts @@ -15,6 +15,7 @@ import { databaseKeys } from '@/data/database/keys' import { enumeratedTypesKeys } from '@/data/enumerated-types/keys' import { handleError } from '@/data/fetchers' import { tableKeys } from '@/data/tables/keys' +import { isManualApprovalRequested } from '@/lib/ai/message-utils' import { tryParseJson } from '@/lib/helpers' import type { SqlSnippet } from '@/state/ai-assistant-state' import { ResponseError } from '@/types' @@ -85,7 +86,7 @@ export const hasPendingToolApproval = (messages: Pick { if (message.role !== 'assistant') return false - return message.parts?.some((part) => isToolUIPart(part) && part.state === 'approval-requested') + return message.parts?.some((part) => isManualApprovalRequested(part)) }) } diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx index 5bdb8363df2f0..25d7f4cad6e53 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx @@ -194,7 +194,7 @@ export const AssistantChat = ({ addToolApprovalResponse, stop, regenerate, - } = useChat({ + } = useChat({ id: chatId, ...(chatInstance ? { chat: chatInstance } : {}), sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, diff --git a/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx b/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx new file mode 100644 index 0000000000000..8282bdce2ae3c --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx @@ -0,0 +1,126 @@ +import { type PropsWithChildren } from 'react' +import { Button, cn } from 'ui' + +import { getConfirmFooterBar } from './Confirm.utils' + +interface ConfirmFooterProps { + message: string + cancelLabel?: string + confirmLabel?: string + confirmLabelLoading?: string + isLoading?: boolean + isDisabled?: boolean + /** Escape hatch for consumers that attach the bar directly under their own frame. */ + className?: string + onCancel?: () => void | Promise + onConfirm?: () => void | Promise +} + +/** Action bar that sits at the bottom of `Confirm`. */ +export const ConfirmFooter = ({ + message, + cancelLabel = 'Cancel', + confirmLabel = 'Confirm', + confirmLabelLoading = 'Working...', + isLoading = false, + isDisabled = false, + className, + onCancel, + onConfirm, +}: ConfirmFooterProps) => { + const isInactive = isLoading || isDisabled + + return ( +
+
{message}
+
+ + +
+
+ ) +} + +interface ConfirmProps { + /** + * Result of `getManualToolApprovalConfirmState`. Interactive buttons only for + * `approval-requested`; `approval-responded` is the post-approve loading morph. + */ + state?: string + message: string + cancelLabel?: string + confirmLabel?: string + confirmLabelLoading?: string + extraLoading?: boolean + isLoading?: boolean + /** + * Children fill the remaining height of the card (e.g. `QueryEditor` in viewport + * mode). Omit for content-sized bodies like notebook previews. + */ + fill?: boolean + className?: string + onCancel?: () => void | Promise + onConfirm?: () => void | Promise +} + +/** + * Card that wraps an assistant tool preview and optionally attaches a confirm footer + * below it. The card owns the frame; nested surfaces (QueryEditor viewport, unframed + * edge-function blocks) fill the body. + */ +export const Confirm = ({ + children, + state, + message, + cancelLabel = 'Skip', + confirmLabel = 'Confirm', + confirmLabelLoading = 'Working...', + extraLoading = false, + isLoading = false, + fill = false, + className, + onCancel, + onConfirm, +}: PropsWithChildren) => { + const bar = getConfirmFooterBar(state) + const showLoading = bar.isLoading || extraLoading || isLoading + const isApprovalRequested = state === 'approval-requested' + + return ( +
+
+ {children} +
+ {bar.show && ( + + )} +
+ ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.test.ts b/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.test.ts new file mode 100644 index 0000000000000..933e5c0c64c79 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + getConfirmFooterBar, + getManualToolApprovalConfirmState, + getManualToolApprovalHandlers, + getManualToolApprovalId, + USER_SKIPPED_TOOL_REASON, +} from './Confirm.utils' + +describe('getConfirmFooterBar', () => { + it('hides the bar when no approval state is provided', () => { + expect(getConfirmFooterBar()).toEqual({ show: false, isLoading: false }) + }) + + it('shows the bar while approval is requested', () => { + expect(getConfirmFooterBar('approval-requested')).toEqual({ show: true, isLoading: false }) + }) + + it('shows a loading bar after the user approves', () => { + expect(getConfirmFooterBar('approval-responded')).toEqual({ show: true, isLoading: true }) + }) + + it('hides the bar for every other tool state', () => { + expect(getConfirmFooterBar('input-available')).toEqual({ show: false, isLoading: false }) + expect(getConfirmFooterBar('output-available')).toEqual({ show: false, isLoading: false }) + expect(getConfirmFooterBar('output-denied')).toEqual({ show: false, isLoading: false }) + }) +}) + +describe('getManualToolApprovalConfirmState', () => { + it('shows an interactive footer for a manual approval request', () => { + expect( + getManualToolApprovalConfirmState({ + state: 'approval-requested', + approval: { id: 'approval-1' }, + }) + ).toBe('approval-requested') + }) + + it('keeps a loading footer after a manual approve', () => { + expect( + getManualToolApprovalConfirmState({ + state: 'approval-responded', + approval: { id: 'approval-1', approved: true }, + }) + ).toBe('approval-responded') + }) + + it('hides the footer for automatic approvals', () => { + expect( + getManualToolApprovalConfirmState({ + state: 'approval-requested', + approval: { id: 'approval-1', isAutomatic: true }, + }) + ).toBeUndefined() + expect( + getManualToolApprovalConfirmState({ + state: 'approval-responded', + approval: { id: 'approval-1', approved: true, isAutomatic: true }, + }) + ).toBeUndefined() + }) + + it('hides the footer when the user denied the request', () => { + expect( + getManualToolApprovalConfirmState({ + state: 'approval-responded', + approval: { id: 'approval-1', approved: false }, + }) + ).toBeUndefined() + }) + + it('ignores non-approval tool states', () => { + expect(getManualToolApprovalConfirmState({ state: 'input-available' })).toBeUndefined() + expect(getManualToolApprovalConfirmState({ state: 'output-available' })).toBeUndefined() + expect(getManualToolApprovalConfirmState({ state: 'output-denied' })).toBeUndefined() + }) +}) + +describe('getManualToolApprovalId', () => { + it('returns the approval id only for a manual approval-requested part', () => { + expect( + getManualToolApprovalId({ state: 'approval-requested', approval: { id: 'approval-1' } }) + ).toBe('approval-1') + expect( + getManualToolApprovalId({ + state: 'approval-requested', + approval: { id: 'approval-1', isAutomatic: true }, + }) + ).toBeUndefined() + expect( + getManualToolApprovalId({ + state: 'approval-responded', + approval: { id: 'approval-1', approved: true }, + }) + ).toBeUndefined() + }) +}) + +describe('getManualToolApprovalHandlers', () => { + it('wires approve and deny only while a manual approval is requested', () => { + const addToolApprovalResponse = vi.fn() + const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({ + state: 'approval-requested', + approval: { id: 'approval-1' }, + addToolApprovalResponse, + }) + + expect(confirmState).toBe('approval-requested') + onApprove?.() + onDeny?.() + expect(addToolApprovalResponse).toHaveBeenCalledWith({ id: 'approval-1', approved: true }) + expect(addToolApprovalResponse).toHaveBeenCalledWith({ + id: 'approval-1', + approved: false, + reason: USER_SKIPPED_TOOL_REASON, + }) + }) + + it('does not call addToolApprovalResponse for automatic approvals', () => { + const addToolApprovalResponse = vi.fn() + const handlers = getManualToolApprovalHandlers({ + state: 'approval-requested', + approval: { id: 'approval-1', isAutomatic: true }, + addToolApprovalResponse, + }) + + expect(handlers).toEqual({ confirmState: undefined }) + expect(addToolApprovalResponse).not.toHaveBeenCalled() + }) +}) diff --git a/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts b/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts new file mode 100644 index 0000000000000..9ce728c929417 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts @@ -0,0 +1,86 @@ +export type ConfirmFooterApprovalState = 'approval-requested' | 'approval-responded' + +/** Sent with Skip so the model sees a user choice, not the SDK default "Tool execution denied." */ +export const USER_SKIPPED_TOOL_REASON = 'The user skipped this action.' + +export type ToolApprovalFields = { + id?: string + approved?: boolean + /** AI SDK v7: automatic policy decisions must not get a confirm footer or response. */ + isAutomatic?: boolean +} + +/** + * Whether the confirm bar should render, and whether it is in the post-approve loading + * morph. Driven by the AI SDK tool approval state; any other state hides the bar. + */ +export function getConfirmFooterBar(state?: string): { show: boolean; isLoading: boolean } { + if (state === 'approval-requested') return { show: true, isLoading: false } + if (state === 'approval-responded') return { show: true, isLoading: true } + return { show: false, isLoading: false } +} + +/** + * Maps a tool part onto the confirm footer. Follows the AI SDK `useChat` rule: + * interactive Approve/Deny only for `approval-requested` when `!approval.isAutomatic`. + * `approval-responded` keeps a loading morph after a manual approve; denials and + * automatic decisions hide the bar. + * + * @see https://ai-sdk.dev/docs/agents/tool-approvals + */ +export function getManualToolApprovalConfirmState({ + state, + approval, +}: { + state: string + approval?: ToolApprovalFields +}): ConfirmFooterApprovalState | undefined { + if (approval?.isAutomatic) return undefined + if (state === 'approval-requested') return 'approval-requested' + if (state === 'approval-responded' && approval?.approved !== false) return 'approval-responded' + return undefined +} + +export function getManualToolApprovalId({ + state, + approval, +}: { + state: string + approval?: ToolApprovalFields +}): string | undefined { + if (state !== 'approval-requested' || approval?.isAutomatic) return undefined + return approval?.id +} + +export function getManualToolApprovalHandlers({ + state, + approval, + addToolApprovalResponse, +}: { + state: string + approval?: ToolApprovalFields + addToolApprovalResponse?: (args: { + id: string + approved: boolean + reason?: string + }) => void | PromiseLike +}): { + confirmState?: ConfirmFooterApprovalState + onApprove?: () => void + onDeny?: () => void +} { + const confirmState = getManualToolApprovalConfirmState({ state, approval }) + const approvalId = getManualToolApprovalId({ state, approval }) + if (!approvalId) return { confirmState } + + return { + confirmState, + onApprove: () => addToolApprovalResponse?.({ id: approvalId, approved: true }), + onDeny: () => + addToolApprovalResponse?.({ + id: approvalId, + approved: false, + reason: USER_SKIPPED_TOOL_REASON, + }), + } +} diff --git a/apps/studio/components/ui/AIAssistantPanel/ConfirmFooter.tsx b/apps/studio/components/ui/AIAssistantPanel/ConfirmFooter.tsx deleted file mode 100644 index cd0c61966ee2e..0000000000000 --- a/apps/studio/components/ui/AIAssistantPanel/ConfirmFooter.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { PropsWithChildren } from 'react' -import { Button, cn } from 'ui' - -interface ConfirmFooterProps { - message: string - cancelLabel?: string - confirmLabel?: string - confirmLabelLoading?: string - isLoading?: boolean - onCancel?: () => void | Promise - onConfirm?: () => void | Promise -} - -export const ConfirmFooter = ({ - message, - cancelLabel = 'Cancel', - confirmLabel = 'Confirm', - confirmLabelLoading = 'Working...', - isLoading = false, - onCancel, - onConfirm, -}: PropsWithChildren) => { - return ( -
-
{message}
-
- - -
-
- ) -} diff --git a/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx index 908b947dc095f..bd2eb1620709a 100644 --- a/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx @@ -8,7 +8,7 @@ import { useRef, useState, type DragEvent, type PropsWithChildren } from 'react' import { DEFAULT_CHART_CONFIG, QueryBlock } from '../QueryBlock/QueryBlock' import { identifyQueryType } from './AIAssistant.utils' -import { ConfirmFooter } from './ConfirmFooter' +import { ConfirmFooter } from './Confirm' import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig' import { entityTypeKeys } from '@/data/entity-types/keys' import { lintKeys } from '@/data/lint/keys' diff --git a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.test.tsx b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.test.tsx index 0d22406570bce..bae71ec86c3d5 100644 --- a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.test.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.test.tsx @@ -1,5 +1,6 @@ import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { type ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { EdgeFunctionRenderer } from './EdgeFunctionRenderer' @@ -70,17 +71,22 @@ vi.mock('../EdgeFunctionBlock/EdgeFunctionBlock', () => ({ ), })) -vi.mock('./ConfirmFooter', () => ({ - ConfirmFooter: ({ +vi.mock('./Confirm', () => ({ + Confirm: ({ + children, confirmLabel, onConfirm, }: { + children?: ReactNode confirmLabel?: string onConfirm?: () => void }) => ( - +
+ {children} + +
), })) @@ -104,6 +110,7 @@ describe('EdgeFunctionRenderer', () => { label="Deploy Edge Function" code="Deno.serve(() => new Response('ok'))" functionName="hello-world" + confirmState="approval-requested" onApprove={onApprove} /> ) @@ -132,6 +139,7 @@ describe('EdgeFunctionRenderer', () => { label="Deploy Edge Function" code="Deno.serve(() => new Response('ok'))" functionName="hello-world" + confirmState="approval-requested" onApprove={onApprove} /> ) diff --git a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx index bb5c4079511e3..d22e46c9cd537 100644 --- a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx @@ -1,8 +1,9 @@ import { useParams } from 'common' -import { useMemo, useState, type PropsWithChildren } from 'react' +import { useMemo, useState } from 'react' import { EdgeFunctionBlock } from '../EdgeFunctionBlock/EdgeFunctionBlock' -import { ConfirmFooter } from './ConfirmFooter' +import { Confirm } from './Confirm' +import { type ConfirmFooterApprovalState } from './Confirm.utils' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query' import { useTrack } from '@/lib/telemetry/track' @@ -15,7 +16,7 @@ interface EdgeFunctionRendererProps { onDeny?: () => void isDeploying?: boolean initialIsDeployed?: boolean - showConfirmFooter?: boolean + confirmState?: ConfirmFooterApprovalState } export const EdgeFunctionRenderer = ({ @@ -26,8 +27,8 @@ export const EdgeFunctionRenderer = ({ onDeny, isDeploying = false, initialIsDeployed, - showConfirmFooter = true, -}: PropsWithChildren) => { + confirmState, +}: EdgeFunctionRendererProps) => { const { ref } = useParams() const track = useTrack() const [showReplaceWarning, setShowReplaceWarning] = useState(false) @@ -74,36 +75,36 @@ export const EdgeFunctionRenderer = ({ approveDeploy() } + const isConfirming = confirmState !== undefined + return ( -
+ setShowReplaceWarning(false)} onConfirmReplace={approveDeploy} /> - {showConfirmFooter && ( -
- onDeny?.()} - onConfirm={handleDeploy} - /> -
- )} -
+ ) } diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx index fc3132f9b5e17..d53bb3e099d4d 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx @@ -3,6 +3,7 @@ import { type DynamicToolUIPart, type ReasoningUIPart, type TextUIPart, type Too import { BrainIcon, CheckIcon, Loader2 } from 'lucide-react' import { cn } from 'ui' +import { getManualToolApprovalHandlers, USER_SKIPPED_TOOL_REASON } from './Confirm.utils' import { DisplayBlockRenderer } from './DisplayBlockRenderer' import { EdgeFunctionRenderer } from './EdgeFunctionRenderer' import { Tool } from './elements/Tool' @@ -166,7 +167,12 @@ function MessagePartExecuteSql({ } onDeny={ approvalId - ? () => addToolApprovalResponse?.({ id: approvalId, approved: false }) + ? () => + addToolApprovalResponse?.({ + id: approvalId, + approved: false, + reason: USER_SKIPPED_TOOL_REASON, + }) : undefined } /> @@ -211,24 +217,22 @@ function MessagePartDeployEdgeFunction({ toolPart }: { toolPart: ToolUIPart }) { const isInitiallyDeployed = state === 'output-available' && parsedOutput.success && parsedOutput.data.success === true - const approvalId = state === 'approval-requested' ? toolPart.approval?.id : undefined + const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({ + state, + approval: toolPart.approval, + addToolApprovalResponse, + }) return ( addToolApprovalResponse?.({ id: approvalId, approved: true }) : undefined - } - onDeny={ - approvalId - ? () => addToolApprovalResponse?.({ id: approvalId, approved: false }) - : undefined - } + onApprove={onApprove} + onDeny={onDeny} /> ) } diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx index 18a38634a89a4..9a9ea60455171 100644 --- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx @@ -5,7 +5,7 @@ import { Button, cn } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' -import { ConfirmFooter } from './ConfirmFooter' +import { ConfirmFooter } from './Confirm' import { createNotebookInputSchema, notebookToolOutputSchema, @@ -119,10 +119,12 @@ interface NotebookConfirmFooterProps { } /** - * `ConfirmFooter` is built to sit flush under the block it confirms (`border-t-0 rounded-b-lg`), - * so that block has to square off its own bottom corners and the two must not be gapped apart. + * `ConfirmFooter` now ships bare so the `Confirm` card can own the frame, so this renderer + * supplies its own flush-under-block border. The block above still has to square off its + * bottom corners and the two must not be gapped apart. */ const GLUED_TO_FOOTER = 'rounded-b-none' +const FLUSH_UNDER_BLOCK = 'border border-t-0 rounded-b-lg' function hasConfirmFooter(state: NotebookProposalState) { return state === 'approval-requested' || state === 'approval-responded' @@ -146,6 +148,7 @@ function NotebookConfirmFooter({ return ( void /** Handler for triggering a deploy */ onDeploy?: () => void + className?: string } export const EdgeFunctionBlock = ({ @@ -70,6 +71,7 @@ export const EdgeFunctionBlock = ({ onDeploy, draggable = false, onDragStart, + className, }: EdgeFunctionBlockProps) => { const resolvedFunctionUrl = functionUrl ?? 'Function URL will be available after deployment' const resolvedDownloadCommand = downloadCommand ?? `supabase functions download ${functionName}` @@ -84,6 +86,7 @@ export const EdgeFunctionBlock = ({ loading={isDeploying} draggable={draggable} onDragStart={onDragStart} + className={className} actions={ hideDeployButton || !onDeploy ? ( (actions ?? null) diff --git a/apps/studio/lib/ai/message-utils.test.ts b/apps/studio/lib/ai/message-utils.test.ts index 0125996ddc766..d5acac0b32862 100644 --- a/apps/studio/lib/ai/message-utils.test.ts +++ b/apps/studio/lib/ai/message-utils.test.ts @@ -1,16 +1,21 @@ import type { DynamicToolUIPart, UIMessage } from 'ai' import { describe, expect, it } from 'vitest' -import { getParallelApprovalIdsToReject, prepareMessagesForAPI } from './message-utils' - -const makeApprovalPart = (id: string): DynamicToolUIPart => ({ - type: 'dynamic-tool', - toolName: 'test_tool', - toolCallId: id, - state: 'approval-requested', - input: {}, - approval: { id }, -}) +import { + getParallelApprovalIdsToReject, + isManualApprovalRequested, + prepareMessagesForAPI, +} from './message-utils' + +const makeApprovalPart = (id: string, isAutomatic = false): DynamicToolUIPart => + ({ + type: 'dynamic-tool', + toolName: 'test_tool', + toolCallId: id, + state: 'approval-requested', + input: {}, + approval: { id, ...(isAutomatic ? { isAutomatic: true } : {}) }, + }) as DynamicToolUIPart const makeResultPart = (id: string): DynamicToolUIPart => ({ type: 'dynamic-tool', @@ -21,6 +26,24 @@ const makeResultPart = (id: string): DynamicToolUIPart => ({ output: {}, }) +describe('isManualApprovalRequested', () => { + it('returns true for a human approval-requested tool part', () => { + expect(isManualApprovalRequested(makeApprovalPart('a1'))).toBe(true) + }) + + it('returns false for an automatic approval', () => { + expect(isManualApprovalRequested(makeApprovalPart('a1', true))).toBe(false) + }) + + it('returns false for a tool result part', () => { + expect(isManualApprovalRequested(makeResultPart('r1'))).toBe(false) + }) + + it('returns false for a content part with no state or approval', () => { + expect(isManualApprovalRequested({ type: 'text', text: 'hello' })).toBe(false) + }) +}) + describe('getParallelApprovalIdsToReject', () => { it('returns [] for empty messages', () => { expect(getParallelApprovalIdsToReject([])).toEqual([]) @@ -75,6 +98,21 @@ describe('getParallelApprovalIdsToReject', () => { ] expect(getParallelApprovalIdsToReject(messages)).toEqual(['a2']) }) + + it('ignores automatic approvals when picking extras to reject', () => { + const messages: UIMessage[] = [ + { + id: '1', + role: 'assistant', + parts: [ + makeApprovalPart('auto', true), + makeApprovalPart('manual-1'), + makeApprovalPart('manual-2'), + ], + }, + ] + expect(getParallelApprovalIdsToReject(messages)).toEqual(['manual-2']) + }) }) describe('prepareMessagesForAPI', () => { diff --git a/apps/studio/lib/ai/message-utils.ts b/apps/studio/lib/ai/message-utils.ts index d04e25b5c1d69..bb00d524a2c8a 100644 --- a/apps/studio/lib/ai/message-utils.ts +++ b/apps/studio/lib/ai/message-utils.ts @@ -1,4 +1,12 @@ -import { isToolUIPart, type UIMessage } from 'ai' +import { + isToolUIPart, + type UIDataTypes, + type UIMessage, + type UIMessagePart, + type UITools, +} from 'ai' + +type UIPart = UIMessagePart /** * Prepares messages for API transmission by cleaning and limiting history @@ -24,6 +32,24 @@ export function prepareMessagesForAPI(messages: UIMessage[]): UIMessage[] { return cleanedMessages } +/** + * Approval id when the part is waiting on a human Approve/Deny. + * Narrows with `isToolUIPart` first, matching the AI SDK `useChat` approval pattern: + * `state === 'approval-requested' && !approval.isAutomatic`. + * + * @see https://ai-sdk.dev/docs/agents/tool-approvals + */ +export function getManualApprovalId(part: UIPart): string | undefined { + if (!isToolUIPart(part) || part.state !== 'approval-requested') return undefined + if ('isAutomatic' in part.approval && part.approval.isAutomatic === true) return undefined + return part.approval.id +} + +/** True when the part is waiting on a human Approve/Deny, not an automatic policy decision. */ +export function isManualApprovalRequested(part: UIPart): boolean { + return getManualApprovalId(part) !== undefined +} + /** * Returns approval IDs to auto-deny when the model issues multiple approval-required * tool calls in the same turn — all but the first, so the model reissues them sequentially. @@ -32,8 +58,10 @@ export function getParallelApprovalIdsToReject(messages: UIMessage[]): string[] const lastMessage = messages.findLast((m) => m.role === 'assistant') if (!lastMessage) return [] - const pendingIds = (lastMessage.parts ?? []).flatMap((part) => - isToolUIPart(part) && part.state === 'approval-requested' ? [part.approval.id] : [] - ) + const pendingIds: string[] = [] + for (const part of lastMessage.parts ?? []) { + const id = getManualApprovalId(part) + if (id) pendingIds.push(id) + } return pendingIds.slice(1) } diff --git a/apps/studio/lib/ai/prompts.ts b/apps/studio/lib/ai/prompts.ts index f1ababc6acb93..8629718e8fc95 100644 --- a/apps/studio/lib/ai/prompts.ts +++ b/apps/studio/lib/ai/prompts.ts @@ -731,7 +731,7 @@ export const CHAT_PROMPT = ` - Do not show the SQL query before execution; the client will display it to the user. - Set chartConfig \`view\` to \`chart\` and xAxis/yAxis if the results would be best displayed as a chart e.g. count of items by date - On execution error, explain succinctly and attempt to correct if possible, validating each outcome briefly (1–2 lines) after execution. -- If a user skips execution, acknowledge and suggest alternatives. +- If a user skips execution, acknowledge and suggest alternatives. A skip is a user choice, not a permission or environment error. - Use markdown code blocks (\`\`\`sql\`\`\`) for illustrative SQL only if requested by the user or when providing non-executable examples. - Never call \`execute_sql\` or \`deploy_edge_function\` in parallel within the same step. Each requires user approval, so issue one per step and wait for its result before calling the next. - After execution, summarize outcomes concisely without duplicating results, as the client will present these. From 6e64ad039c86f0142528a683cb7c38cb064b7569 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 20 Aug 2026 11:35:35 +1000 Subject: [PATCH 3/4] feat(studio): add AssistantQueryCell on the shared QueryEditor (#49169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature. ## What is the current behavior? Notebooks and query tabs use `QueryEditor`. Assistant SQL still uses `DisplayBlockRenderer` / `QueryBlock`. ## What is the new behavior? Adds `AssistantQueryCell`, a local-state wrapper around the shared `QueryEditor` (`variant="viewport"`, `isRunDisabled` while confirming). Nothing is wired into the conversation yet — that is #49170 — so this PR is the reusable cell plus the small editor/report-container hooks it needs. ## Additional context Part of stack #49171. Base: `feat/assistant-confirm` (#49168). ## Test plan - [ ] `AssistantQueryCell.utils.test.ts` passes - [ ] Query editor still runs in Explorer notebooks / query tabs - [ ] No assistant conversation UI change in this PR (still DisplayBlockRenderer) --------- Co-authored-by: Cursor --- .../interfaces/Explorer/QueryEditor/index.tsx | 20 ++- .../AIAssistantPanel/AssistantQueryCell.tsx | 143 ++++++++++++++++++ .../AssistantQueryCell.utils.test.ts | 90 +++++++++++ .../AssistantQueryCell.utils.ts | 77 ++++++++++ 4 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.test.ts create mode 100644 apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.ts diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx index 150df0f12d36c..50985ecf9e98e 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx @@ -80,6 +80,9 @@ export type QueryEditorProps = { roleImpersonationState?: RoleImpersonationController display?: QueryDisplay toolbarActions?: ReactNode + className?: string + /** When true, toolbar and editor run actions are disabled. */ + isRunDisabled?: boolean onTitleChange: (title: string) => void onSqlChange: (sql: string) => void onSqlCommit?: (sql: string) => void @@ -87,6 +90,7 @@ export type QueryEditorProps = { onResultChange: (result: QueryResult) => void onRowLimitChange?: (val: number) => void onDisplayChange?: (display: QueryDisplay) => void + onRun?: () => void } export type QueryEditorHandle = { @@ -108,6 +112,8 @@ export const QueryEditor = forwardRef(funct roleImpersonationState, display, toolbarActions, + className, + isRunDisabled = false, onTitleChange, onSqlChange, onSqlCommit, @@ -115,6 +121,7 @@ export const QueryEditor = forwardRef(funct onResultChange, onRowLimitChange, onDisplayChange, + onRun, }: QueryEditorProps, ref ) { @@ -166,8 +173,9 @@ export const QueryEditor = forwardRef(funct * Postgres SQL cannot reach the analytics wire or vice versa. */ const handleRunQuery = async (rawSql: string = sql) => { - if (!project || isBusy || rewriteProposal || rawSql.trim().length === 0) return + if (!project || isBusy || rewriteProposal || isRunDisabled || rawSql.trim().length === 0) return + onRun?.() onSqlCommit?.(rawSql) if (query._tag === 'logs') { @@ -227,7 +235,7 @@ export const QueryEditor = forwardRef(funct const Shell = variant === 'viewport' ? ExplorerQueryViewport : ExplorerQuery return ( - + @@ -268,7 +276,11 @@ export const QueryEditor = forwardRef(funct icon={} tooltip="Run query" disabled={ - isLoadingProject || isExecuting || rewriteProposal !== null || sql.trim().length === 0 + isLoadingProject || + isExecuting || + rewriteProposal !== null || + isRunDisabled || + sql.trim().length === 0 } onClick={() => handleRunQuery()} > @@ -296,7 +308,7 @@ export const QueryEditor = forwardRef(funct placeholder="select * from your_table limit 100;" placeholderClassName="top-[13px]" className={variant === 'embedded' ? 'h-44' : undefined} - actions={{ runQuery: { enabled: true, callback: handleRunQuery } }} + actions={{ runQuery: { enabled: !isRunDisabled, callback: handleRunQuery } }} options={{ minimap: { enabled: false }, padding: { top: 8 } }} onInputChange={(value) => onSqlChange(value ?? '')} onMount={(editor) => { diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx new file mode 100644 index 0000000000000..17d79b8b6d4dd --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx @@ -0,0 +1,143 @@ +import { useRef, useState } from 'react' + +import { identifyQueryType } from './AIAssistant.utils' +import { + changeAssistantQuerySource, + createAssistantQueryModel, + DEFAULT_ASSISTANT_QUERY_TITLE, + getAssistantQueryDisplay, + setAssistantQuerySql, + toAssistantQueryResult, +} from './AssistantQueryCell.utils' +import { Confirm } from './Confirm' +import { type ConfirmFooterApprovalState } from './Confirm.utils' +import { QueryEditor } from '@/components/interfaces/Explorer/QueryEditor' +import { type QueryDisplay, type QueryResult } from '@/components/interfaces/Explorer/types' +import { type QuerySourceBinding } from '@/data/query-sources/query-source-registry' +import { useTrack } from '@/lib/telemetry/track' +import { useLocalRoleImpersonationState } from '@/state/role-impersonation-state' + +interface AssistantQueryCellProps { + id: string + sql: string + title?: string + initialRows?: unknown + view?: 'table' | 'chart' + xAxis?: string + yAxis?: string + /** Follow incoming SQL while the assistant is still streaming the query text. */ + isStreaming?: boolean + confirmState?: ConfirmFooterApprovalState + onApprove?: () => void + onDeny?: () => void +} + +/** Assistant adapter around the shared QueryEditor. Local state only — nothing is persisted. */ +export const AssistantQueryCell = ({ + id, + sql: initialSql, + title: initialTitle, + initialRows, + view, + xAxis, + yAxis, + isStreaming = false, + confirmState, + onApprove, + onDeny, +}: AssistantQueryCellProps) => { + const track = useTrack() + const roleImpersonationState = useLocalRoleImpersonationState() + + const [title, setTitle] = useState(initialTitle?.trim() || DEFAULT_ASSISTANT_QUERY_TITLE) + const [query, setQuery] = useState(() => createAssistantQueryModel(initialSql)) + const [result, setResult] = useState(() => + toAssistantQueryResult(initialRows) + ) + const [display, setDisplay] = useState(() => + getAssistantQueryDisplay({ view, xAxis, yAxis }) + ) + + const prevId = useRef(id) + const prevSql = useRef(initialSql) + const prevRows = useRef(initialRows) + + if (prevId.current !== id) { + prevId.current = id + prevSql.current = initialSql + prevRows.current = initialRows + setTitle(initialTitle?.trim() || DEFAULT_ASSISTANT_QUERY_TITLE) + setQuery(createAssistantQueryModel(initialSql)) + setResult(toAssistantQueryResult(initialRows)) + setDisplay(getAssistantQueryDisplay({ view, xAxis, yAxis })) + } + + if (prevSql.current !== initialSql) { + prevSql.current = initialSql + if (isStreaming) { + setQuery((current) => setAssistantQuerySql(current, initialSql)) + } + } + + if (prevRows.current !== initialRows) { + prevRows.current = initialRows + setResult(toAssistantQueryResult(initialRows)) + } + + const handleTitleChange = (value: string) => { + const nextTitle = value.trim() + if (!nextTitle) return + setTitle(nextTitle) + } + + const handleSourceChange = (source: QuerySourceBinding) => { + const isBackendChange = source._tag !== query._tag + if (isBackendChange) setResult(undefined) + setQuery((current) => changeAssistantQuerySource(current, source)) + } + + const handleRun = () => { + const sql = query.uncheckedSql + const mutationType = identifyQueryType(sql) + track('assistant_suggestion_run_query_clicked', { + queryType: mutationType ? 'mutation' : 'select', + ...(mutationType ? { mutationType } : {}), + }) + } + + const isConfirming = confirmState !== undefined + + return ( + + setQuery((current) => setAssistantQuerySql(current, sql))} + onSourceChange={handleSourceChange} + onResultChange={setResult} + onRowLimitChange={(rowLimit) => + setQuery((current) => (current._tag === 'database' ? { ...current, rowLimit } : current)) + } + onDisplayChange={setDisplay} + onRun={handleRun} + /> + + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.test.ts b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.test.ts new file mode 100644 index 0000000000000..b64eddf7e3e45 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.test.ts @@ -0,0 +1,90 @@ +import { untrustedSql } from '@supabase/pg-meta' +import { describe, expect, it } from 'vitest' + +import { + changeAssistantQuerySource, + createAssistantQueryModel, + getAssistantQueryDisplay, + setAssistantQuerySql, + toAssistantQueryResult, +} from './AssistantQueryCell.utils' +import { DEFAULT_CELL_ROW_LIMIT } from '@/components/interfaces/Explorer/QueryCell/QueryCell.utils' +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' + +describe('getAssistantQueryDisplay', () => { + it('defaults to a table view with no chart when axes are missing', () => { + expect(getAssistantQueryDisplay({})).toEqual({ view: 'table', chart: undefined }) + }) + + it('builds a bar chart config from axis hints', () => { + expect(getAssistantQueryDisplay({ view: 'chart', xAxis: 'day', yAxis: 'signups' })).toEqual({ + view: 'chart', + chart: { + type: 'bar', + x_column: 'day', + y_columns: ['signups'], + cumulative: false, + scale: 'linear', + show_labels: false, + }, + }) + }) + + it('keeps an empty y-axis list when only the x-axis is provided', () => { + expect(getAssistantQueryDisplay({ xAxis: 'day' }).chart?.y_columns).toEqual([]) + }) +}) + +describe('toAssistantQueryResult', () => { + it('returns undefined when the output is not an array of row objects', () => { + expect(toAssistantQueryResult(undefined)).toBeUndefined() + expect(toAssistantQueryResult('error')).toBeUndefined() + expect(toAssistantQueryResult({ rows: [] })).toBeUndefined() + }) + + it('keeps row objects and drops primitives, arrays, and nulls', () => { + expect(toAssistantQueryResult([{ id: 1 }, null, ['x'], 4, { id: 2 }])).toEqual({ + rows: [{ id: 1 }, { id: 2 }], + }) + }) + + it('accepts an empty array as a successful empty result', () => { + expect(toAssistantQueryResult([])).toEqual({ rows: [] }) + }) +}) + +describe('assistant query model', () => { + it('starts as a database query with the notebook default row limit', () => { + expect(createAssistantQueryModel('select 1')).toEqual({ + _tag: 'database', + uncheckedSql: untrustedSql('select 1'), + rowLimit: DEFAULT_CELL_ROW_LIMIT, + }) + }) + + it('rebrands the live SQL for the current backend', () => { + const database = createAssistantQueryModel('select 1') + expect(setAssistantQuerySql(database, 'select 2').uncheckedSql).toBe(untrustedSql('select 2')) + + const logs = changeAssistantQuerySource(database, { + _tag: 'logs', + time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 }, + }) + expect(logs._tag).toBe('logs') + expect(setAssistantQuerySql(logs, 'select 3').uncheckedSql).toBe(untrustedLogSql('select 3')) + }) + + it('carries the SQL across a source change and restores the default row limit onto logs → database', () => { + const logs = changeAssistantQuerySource(createAssistantQueryModel('select 1'), { + _tag: 'logs', + time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 }, + }) + const database = changeAssistantQuerySource(logs, { _tag: 'database' }) + + expect(database).toEqual({ + _tag: 'database', + uncheckedSql: untrustedSql('select 1'), + rowLimit: DEFAULT_CELL_ROW_LIMIT, + }) + }) +}) diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.ts new file mode 100644 index 0000000000000..a09fc6fba5875 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.ts @@ -0,0 +1,77 @@ +import { untrustedSql } from '@supabase/pg-meta' + +import { DEFAULT_CELL_ROW_LIMIT } from '@/components/interfaces/Explorer/QueryCell/QueryCell.utils' +import { type ExplorerQueryModel } from '@/components/interfaces/Explorer/QueryEditor' +import { type QueryDisplay, type QueryResult } from '@/components/interfaces/Explorer/types' +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' +import { type QuerySourceBinding } from '@/data/query-sources/query-source-registry' + +export const DEFAULT_ASSISTANT_QUERY_TITLE = 'SQL query' + +export function getAssistantQueryDisplay({ + view, + xAxis, + yAxis, +}: { + view?: 'table' | 'chart' + xAxis?: string + yAxis?: string +}): QueryDisplay { + const hasChartAxes = Boolean(xAxis || yAxis) + + return { + view: view ?? 'table', + chart: hasChartAxes + ? { + type: 'bar', + x_column: xAxis ?? '', + y_columns: yAxis ? [yAxis] : [], + cumulative: false, + scale: 'linear', + show_labels: false, + } + : undefined, + } +} + +export function toAssistantQueryResult(output: unknown): QueryResult | undefined { + if (!Array.isArray(output)) return undefined + + const rows = output.filter( + (row): row is Record => + row !== null && typeof row === 'object' && !Array.isArray(row) + ) + + return { rows } +} + +export function createAssistantQueryModel(sql: string): ExplorerQueryModel { + return { + _tag: 'database', + uncheckedSql: untrustedSql(sql), + rowLimit: DEFAULT_CELL_ROW_LIMIT, + } +} + +export function setAssistantQuerySql(query: ExplorerQueryModel, sql: string): ExplorerQueryModel { + if (query._tag === 'logs') { + return { ...query, uncheckedSql: untrustedLogSql(sql) } + } + + return { ...query, uncheckedSql: untrustedSql(sql) } +} + +export function changeAssistantQuerySource( + query: ExplorerQueryModel, + source: QuerySourceBinding +): ExplorerQueryModel { + if (source._tag === 'logs') { + return { ...source, uncheckedSql: untrustedLogSql(query.uncheckedSql) } + } + + return { + ...source, + uncheckedSql: untrustedSql(query.uncheckedSql), + rowLimit: query._tag === 'database' ? query.rowLimit : DEFAULT_CELL_ROW_LIMIT, + } +} From fd8ccf85b78ec216ccb4b6149327e3e8269ab32a Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 20 Aug 2026 11:35:35 +1000 Subject: [PATCH 4/4] feat(studio): render assistant SQL with AssistantQueryCell (#49170) image ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature. ## What is the current behavior? Assistant `execute_sql` tool parts and markdown SQL fences render through `DisplayBlockRenderer`. The confirm footer is gated to the last part of the last message, so a pending SQL approval can disappear if the assistant keeps writing. ## What is the new behavior? SQL tool parts and markdown fences use `AssistantQueryCell` inside `Confirm`. The footer follows the same manual-approval helpers as Edge Functions. `DisplayBlockRenderer` is removed. ## Additional context Top of stack #49171. Base: `feat/assistant-query-cell` (#49169). Does not wrap notebook create/update proposals. That depends on [#49159](https://github.com/supabase/supabase/pull/49159) merging first. ## Test plan - [ ] `execute_sql` approval shows Run query / Skip on the Confirm card under the editor - [ ] Footer still shows if the assistant writes text after the SQL tool part - [ ] Markdown SQL fences render as AssistantQueryCell without a confirm footer - [ ] After skip, the query cell remains so the user can run it locally - [ ] Edge Function confirm from #49168 still works --------- Co-authored-by: Cursor --- .../Explorer/QueryEditor/QueryResultChart.tsx | 76 +++-- .../ui/AIAssistantPanel/AIAssistant.utils.ts | 2 +- .../AIAssistantPanel/DisplayBlockRenderer.tsx | 265 ------------------ .../ui/AIAssistantPanel/Message.Display.tsx | 3 +- .../ui/AIAssistantPanel/Message.Parts.tsx | 67 ++--- .../ui/AIAssistantPanel/MessageMarkdown.tsx | 61 +--- 6 files changed, 73 insertions(+), 401 deletions(-) delete mode 100644 apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultChart.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultChart.tsx index 55c13a34c31a6..52958c120b795 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultChart.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultChart.tsx @@ -81,45 +81,43 @@ export const QueryResultChart = ({ chart, result }: QueryResultChartProps) => { } return ( - - - -
- {type === 'bar' && ( - - )} - {type === 'line' && ( - - )} -
+ + + + {type === 'bar' && ( + + )} + {type === 'line' && ( + + )} diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts index a3cb9b63a3f47..fad2f53f0d2ba 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts @@ -184,7 +184,7 @@ export const getSnippetContent = (snippet: SqlSnippet): string => * against the `logs` table — a single message can carry both dialects. * * It also keeps the two apart in the rendered message: MessageMarkdown treats a `sql` - * fence as runnable Postgres (`DisplayBlockRenderer`, branded with `untrustedSql`), + * fence as runnable Postgres (`AssistantQueryCell`, branded with `untrustedSql`), * which a ClickHouse query must never be offered as. */ function getSnippetFenceLanguage(snippet: SqlSnippet): 'sql' | 'clickhouse' { diff --git a/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx deleted file mode 100644 index bd2eb1620709a..0000000000000 --- a/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { acceptUntrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta' -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useQueryClient } from '@tanstack/react-query' -import type { ToolUIPart } from 'ai' -import { useParams } from 'common' -import { useRouter } from 'next/router' -import { useRef, useState, type DragEvent, type PropsWithChildren } from 'react' - -import { DEFAULT_CHART_CONFIG, QueryBlock } from '../QueryBlock/QueryBlock' -import { identifyQueryType } from './AIAssistant.utils' -import { ConfirmFooter } from './Confirm' -import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig' -import { entityTypeKeys } from '@/data/entity-types/keys' -import { lintKeys } from '@/data/lint/keys' -import { usePrimaryDatabase } from '@/data/read-replicas/replicas-query' -import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' -import { useChangedSync } from '@/hooks/misc/useChanged' -import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' -import { useProfile } from '@/lib/profile' -import { useTrack } from '@/lib/telemetry/track' - -interface DisplayBlockRendererProps { - messageId: string - toolCallId: string - initialArgs: { - sql: UntrustedSqlFragment - label?: string - isWriteQuery?: boolean - view?: 'table' | 'chart' - xAxis?: string - yAxis?: string - } - initialResults?: unknown - /** Called when locally running SQL fails before or during client-side execution. */ - onError?: (args: { messageId: string; errorText: string }) => void - /** Responds affirmatively to an AI SDK tool approval request; does not run SQL directly. */ - onApprove?: () => void - /** Responds negatively to an AI SDK tool approval request; does not run SQL directly. */ - onDeny?: () => void - /** AI SDK tool state used to show approval UI for pending tool calls. */ - toolState?: ToolUIPart['state'] - toolApprovalRespondedApproved?: boolean - isLastPart?: boolean - isLastMessage?: boolean - showConfirmFooter?: boolean - onChartConfigChange?: (chartConfig: ChartConfig) => void - /** Called when the user clicks the query block play button to run SQL locally. */ - onQueryRun?: (queryType: 'select' | 'mutation') => void -} - -export const DisplayBlockRenderer = ({ - messageId, - toolCallId, - initialArgs, - initialResults, - onError, - onApprove, - onDeny, - toolState, - toolApprovalRespondedApproved, - isLastPart = false, - isLastMessage = false, - showConfirmFooter = true, - onChartConfigChange, - onQueryRun, -}: PropsWithChildren) => { - const queryClient = useQueryClient() - - const savedInitialArgs = useRef(initialArgs) - const savedInitialResults = useRef(initialResults) - const savedInitialConfig = useRef({ - ...DEFAULT_CHART_CONFIG, - view: initialArgs.view === 'chart' ? 'chart' : 'table', - xKey: initialArgs.xAxis ?? '', - yKey: initialArgs.yAxis ?? '', - }) - - const router = useRouter() - const { ref } = useParams() - const { profile } = useProfile() - - const track = useTrack() - const { can: canCreateSQLSnippet } = useAsyncCheckPermissions( - PermissionAction.CREATE, - 'user_content', - { - resource: { type: 'sql', owner_id: profile?.id }, - subject: { id: profile?.id }, - } - ) - - const [chartConfig, setChartConfig] = useState(() => ({ - ...DEFAULT_CHART_CONFIG, - view: initialArgs.view === 'chart' ? 'chart' : 'table', - xKey: initialArgs.xAxis ?? '', - yKey: initialArgs.yAxis ?? '', - })) - - const [rows, setRows] = useState( - Array.isArray(initialResults) ? initialResults : undefined - ) - const isReportsPage = router.pathname.endsWith('/reports/[id]') - const isHomePage = router.pathname === '/project/[ref]' - const isDraggableToReports = canCreateSQLSnippet && (isReportsPage || isHomePage) - const label = initialArgs.label || 'SQL Results' - const [isWriteQuery, setIsWriteQuery] = useState(initialArgs.isWriteQuery || false) - const sqlQuery = initialArgs.sql - - const { database: primaryDatabase } = usePrimaryDatabase({ projectRef: ref }) - - const readOnlyConnectionString = primaryDatabase?.connection_string_read_only - const postgresConnectionString = primaryDatabase?.connectionString - - const { - mutate: executeSql, - error: executeSqlError, - isPending: executeSqlLoading, - } = useExecuteSqlMutation({ - onError: () => { - // Suppress toast because error message is displayed inline - }, - }) - - const toolCallIdChanged = useChangedSync(toolCallId) - if (toolCallIdChanged) { - setChartConfig(savedInitialConfig.current) - onChartConfigChange?.(savedInitialConfig.current) - setIsWriteQuery(savedInitialArgs.current.isWriteQuery || false) - setRows(Array.isArray(savedInitialResults.current) ? savedInitialResults.current : undefined) - } - - const initialResultsChanged = useChangedSync(initialResults) - if (initialResultsChanged) { - const normalized = Array.isArray(initialResults) ? initialResults : undefined - if (!normalized || normalized === rows) return - setRows(normalized) - } - - const handleRunQuery = (queryType: 'select' | 'mutation') => { - if (!sqlQuery) return - - onQueryRun?.(queryType) - - track('assistant_suggestion_run_query_clicked', { - queryType, - ...(queryType === 'mutation' - ? { mutationType: identifyQueryType(sqlQuery) ?? 'unknown' } - : {}), - }) - } - - const runQuery = (queryType: 'select' | 'mutation') => { - if (!ref || !sqlQuery) return - - const connectionString = - queryType === 'mutation' - ? postgresConnectionString - : (readOnlyConnectionString ?? postgresConnectionString) - - if (!connectionString) { - const fallbackMessage = 'Unable to find a database connection to execute this query.' - onError?.({ messageId, errorText: fallbackMessage }) - return - } - - if (queryType === 'mutation') { - setIsWriteQuery(true) - } - executeSql( - { projectRef: ref, connectionString, sql: acceptUntrustedSql(sqlQuery) }, - { - onSuccess: (data) => { - setRows(Array.isArray(data.result) ? data.result : undefined) - setIsWriteQuery(queryType === 'mutation' || initialArgs.isWriteQuery || false) - if (queryType === 'mutation') { - queryClient.invalidateQueries({ queryKey: lintKeys.lint(ref) }) - queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(ref) }) - } - }, - onError: (error) => { - const lowerMessage = error.message.toLowerCase() - const isReadOnlyError = - lowerMessage.includes('read-only transaction') || - lowerMessage.includes('permission denied') || - lowerMessage.includes('must be owner') - - if (queryType === 'select' && isReadOnlyError) { - setIsWriteQuery(true) - } - - onError?.({ messageId, errorText: error.message }) - }, - } - ) - } - - const handleExecute = (queryType: 'select' | 'mutation') => { - handleRunQuery(queryType) - runQuery(queryType) - } - - const handleUpdateChartConfig = ({ - chartConfig: updatedValues, - }: { - chartConfig: Partial - }) => { - setChartConfig((prev) => { - const next = { ...prev, ...updatedValues } - onChartConfigChange?.(next) - return next - }) - } - - const handleDragStart = (e: DragEvent) => { - e.dataTransfer.setData( - 'application/json', - JSON.stringify({ label, sql: sqlQuery, config: chartConfig }) - ) - } - - const isApprovalRequested = toolState === 'approval-requested' - const isApprovalResponded = toolState === 'approval-responded' - const isApprovalDenied = isApprovalResponded && toolApprovalRespondedApproved === false - const shouldShowConfirmFooter = - showConfirmFooter && - (isApprovalRequested || (isApprovalResponded && !isApprovalDenied)) && - isLastPart && - isLastMessage && - (isApprovalResponded || (!!onApprove && !!onDeny)) - const isRunningApprovedTool = (isApprovalResponded && !isApprovalDenied) || executeSqlLoading - - return ( -
-
- -
- {shouldShowConfirmFooter && ( -
- -
- )} -
- ) -} diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Display.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Display.tsx index eb8451215edb1..a6cd55409fa5f 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.Display.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.Display.tsx @@ -53,8 +53,7 @@ function MessageDisplayContent({ message }: { message: VercelMessage }) {
{messageParts?.length > 0 ? messageParts.map((part: NonNullable, idx) => { - const isLastPart = idx === messageParts.length - 1 - return + return }) : content && ( diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx index d53bb3e099d4d..62cd84b4ba2b5 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx @@ -3,8 +3,8 @@ import { type DynamicToolUIPart, type ReasoningUIPart, type TextUIPart, type Too import { BrainIcon, CheckIcon, Loader2 } from 'lucide-react' import { cn } from 'ui' -import { getManualToolApprovalHandlers, USER_SKIPPED_TOOL_REASON } from './Confirm.utils' -import { DisplayBlockRenderer } from './DisplayBlockRenderer' +import { AssistantQueryCell } from './AssistantQueryCell' +import { getManualToolApprovalHandlers } from './Confirm.utils' import { EdgeFunctionRenderer } from './EdgeFunctionRenderer' import { Tool } from './elements/Tool' import { useMessageActionsContext, useMessageInfoContext } from './Message.Context' @@ -111,14 +111,8 @@ function ToolDisplayExecuteSqlFailure() { return
Failed to execute SQL.
} -function MessagePartExecuteSql({ - toolPart, - isLastPart, -}: { - toolPart: ToolUIPart - isLastPart?: boolean -}) { - const { id, isLastMessage } = useMessageInfoContext() +function MessagePartExecuteSql({ toolPart }: { toolPart: ToolUIPart }) { + const { id } = useMessageInfoContext() const { addToolApprovalResponse } = useMessageActionsContext() const { toolCallId, state, input, output } = toolPart @@ -141,40 +135,25 @@ function MessagePartExecuteSql({ state === 'output-denied' || state === 'output-available' ) { - const approvalId = state === 'approval-requested' ? toolPart.approval?.id : undefined + const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({ + state, + approval: toolPart.approval, + addToolApprovalResponse, + }) + return (
- addToolApprovalResponse?.({ id: approvalId, approved: true }) - : undefined - } - onDeny={ - approvalId - ? () => - addToolApprovalResponse?.({ - id: approvalId, - approved: false, - reason: USER_SKIPPED_TOOL_REASON, - }) - : undefined - } +
) @@ -302,10 +281,8 @@ const MessagePart = { export function MessagePartSwitcher({ part, - isLastPart, }: { part: NonNullable[number] - isLastPart?: boolean }) { switch (part.type) { case 'dynamic-tool': { @@ -323,7 +300,7 @@ export function MessagePartSwitcher({ return case 'tool-execute_sql': { - return + return } case 'tool-deploy_edge_function': { return diff --git a/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx b/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx index 7a6ff7ff4885d..745fe9fdfc909 100644 --- a/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx @@ -1,15 +1,6 @@ -import { untrustedSql } from '@supabase/pg-meta' import dynamic from 'next/dynamic' import Link from 'next/link' -import React, { - isValidElement, - memo, - ReactNode, - useEffect, - useMemo, - useRef, - type ReactElement, -} from 'react' +import React, { isValidElement, memo, ReactNode, useMemo, type ReactElement } from 'react' import type { StreamdownProps } from 'streamdown' import { Button, @@ -28,10 +19,9 @@ import { markdownComponents } from 'ui-patterns/Markdown' import { EdgeFunctionBlock } from '../EdgeFunctionBlock/EdgeFunctionBlock' import { AssistantSnippetProps } from './AIAssistant.types' +import { AssistantQueryCell } from './AssistantQueryCell' import { CollapsibleCodeBlock } from './CollapsibleCodeBlock' -import { DisplayBlockRenderer } from './DisplayBlockRenderer' import { defaultUrlTransform, wrapPlaceholderUrls } from './Message.utils' -import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig' const Streamdown = dynamic( () => import('streamdown').then((mod) => mod.Streamdown), @@ -188,7 +178,7 @@ export function MessageMarkdown({ export const MarkdownPre = ({ children, id, - isLoading: _isLoading, + isLoading, readOnly, }: { children: any @@ -196,15 +186,6 @@ export const MarkdownPre = ({ isLoading: boolean readOnly?: boolean }) => { - // [Joshen] Using a ref as this data doesn't need to trigger a re-render - const chartConfig = useRef({ - view: 'table', - type: 'bar', - xKey: '', - yKey: '', - cumulative: false, - }) - const childArray = Array.isArray(children) ? children : [children] const codeElement = childArray.find( (child): child is ReactElement<{ className?: string; children: ReactNode }> => @@ -231,23 +212,13 @@ export const MarkdownPre = ({ const { xAxis, yAxis } = snippetProps const snippetId = snippetProps.id - const title = snippetProps.title || (language === 'edge' ? 'Edge Function' : 'SQL Query') + const title = snippetProps.title || (language === 'edge' ? 'Edge Function' : 'SQL query') const isChart = snippetProps.isChart === 'true' // Strip props from the content for both SQL and edge functions const cleanContent = rawContent.replace(/(?:--|\/\/)\s*props:\s*\{[^}]+\}/, '').trim() const toolCallId = String(snippetId ?? id) - useEffect(() => { - chartConfig.current = { - ...chartConfig.current, - view: isChart ? 'chart' : 'table', - xKey: xAxis ?? '', - yKey: yAxis ?? '', - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [snippetProps]) - if (!codeElement) { return
{children}
} @@ -265,22 +236,14 @@ export const MarkdownPre = ({ readOnly ? ( ) : ( - {}} - showConfirmFooter={false} - onChartConfigChange={(config) => { - chartConfig.current = { ...config } - }} + ) ) : (