-
Notifications
You must be signed in to change notification settings - Fork 618
thirdweb AI inside dashboard #8003
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds a thirdweb AI chat integration: streaming client, session CRUD and feedback endpoints, authenticated fetch helper, many UI components (chat UI, sidebar, mobile nav, assets/transactions, transaction/swap cards), image upload/viewer, local session stores, Storybook stories, an env constant, branding text updates, and a package dependency. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor User
participant ChatBar
participant ChatContent
participant promptNebula
participant AIHost as "AI Host (/chat)"
User->>ChatBar: Type message / attach images
ChatBar->>ChatContent: sendMessage(NebulaUserMessage)
ChatContent->>promptNebula: promptNebula({message, sessionId, authToken, context, abortController})
promptNebula->>AIHost: POST /chat (stream: true)
loop server-sent events
AIHost-->>promptNebula: event (init|delta|presence|image|action|context|error)
promptNebula->>ChatContent: handleStream(event)
alt delta/image/action
ChatContent->>ChatContent: append/merge messages
else presence/context/error
ChatContent->>ChatContent: update presence/context or append error
end
end
ChatContent-->>User: incremental UI updates
sequenceDiagram
autonumber
actor User
participant UI
participant SessionAPI
participant AIHost
User->>UI: Create/Open session
UI->>SessionAPI: createSession/updateSession
SessionAPI->>AIHost: POST/PUT /session
AIHost-->>SessionAPI: SessionInfo/UpdatedSessionInfo
SessionAPI-->>UI: SessionInfo
User->>UI: submit feedback
UI->>SessionAPI: submitFeedback({sessionId, requestId, rating})
SessionAPI->>AIHost: POST /feedback
AIHost-->>SessionAPI: 200 / error
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Pre-merge checks (1 passed, 2 warnings)❌ Failed Checks (2 warnings)
✅ Passed Checks (1 passed)
Warning Review ran into problems🔥 ProblemsErrors were encountered while retrieving linked issues. Errors (1)
✨ Finishing Touches
🧪 Generate unit tests
Comment |
How to use the Graphite Merge QueueAdd either label to this PR to merge it via the merge queue:
You must have a Graphite account in order to use the merge queue. Sign up using this link. An organization admin has enabled the Graphite Merge Queue in this repository. Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue. This stack of pull requests is managed by Graphite. Learn more about stacking. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #8003 +/- ##
=======================================
Coverage 56.64% 56.65%
=======================================
Files 904 904
Lines 58677 58677
Branches 4164 4165 +1
=======================================
+ Hits 33236 33241 +5
+ Misses 25335 25330 -5
Partials 106 106
🚀 New features to boost your workflow:
|
size-limit report 📦
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 33
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/dashboard/src/@/components/chat/ChatBar.tsx (2)
59-63: Block Enter-submit when message is empty, wallet is connecting, or IME composingPressing Enter currently submits even if the message is whitespace and even while a wallet is connecting; it also ignores IME composition. Align behavior with the Send button and avoid accidental sends.
Apply:
- onKeyDown={(e) => { + onKeyDown={(e) => { // ignore if shift key is pressed to allow entering new lines if (e.shiftKey) { return; } - if (e.key === "Enter" && !props.isChatStreaming) { + // avoid sending while composing (IME), while streaming, or while connecting wallet + // and block empty/whitespace messages + // @ts-expect-error - nativeEvent available on KeyboardEvent + const isComposing = e.nativeEvent?.isComposing === true; + if ( + e.key === "Enter" && + !props.isChatStreaming && + !props.isConnectingWallet && + !isComposing && + message.trim() !== "" + ) { e.preventDefault(); handleSubmit(message); } }}
28-38: Trim input and no-op on empty submits inside handleSubmitGuarantees consistent behavior regardless of call site.
- function handleSubmit(message: string) { + function handleSubmit(message: string) { + const trimmed = message.trim(); + if (!trimmed) { + return; + } const userMessage: NebulaUserMessage = { - content: [{ text: message, type: "text" }], + content: [{ text: trimmed, type: "text" }], role: "user", }; props.sendMessage(userMessage); setMessage(""); }
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/chat.ts
Outdated
Show resolved
Hide resolved
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/feedback.ts
Show resolved
Hide resolved
.../(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.stories.tsx
Show resolved
Hide resolved
.../(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.stories.tsx
Outdated
Show resolved
Hide resolved
.../(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.stories.tsx
Show resolved
Hide resolved
...ard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.tsx
Show resolved
Hide resolved
...ard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.tsx
Show resolved
Hide resolved
...ard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.tsx
Show resolved
Hide resolved
...ard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.tsx
Show resolved
Hide resolved
...eam_slug]/[project_slug]/(sidebar)/ai/components/TransactionsSection/TransactionsSection.tsx
Show resolved
Hide resolved
87aa725 to
ee25ba7
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/dashboard/src/app/(app)/(dashboard)/(chain)/components/server/products.ts (1)
1-1: Add server-only guardThis lives under a server path; add the guard to prevent accidental client import and keep bundles clean.
import "server-only";
♻️ Duplicate comments (11)
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.tsx (4)
1-1: Add 'use client' (hooks used in this file).Without it, Next will treat this as a Server Component and crash at runtime/build.
Apply:
+'use client'; + import { ArrowRightLeftIcon, CheckIcon } from "lucide-react";
42-43: Guard prepareTransaction when chain is undefined; disable button until ready (Swap).txChain can be undefined; calling prepareTransaction or enabling send will throw/allow invalid interaction.
Apply:
const txChain = useV5DashboardChain(swapData.transaction.chain_id); @@ disabled={ - props.status.type === "sending" || - props.status.type === "confirming" + !txChain || + props.status.type === "sending" || + props.status.type === "confirming" } @@ - onClick={async () => { + onClick={async () => { + if (!txChain) { + return; + } const tx = prepareTransaction({ chain: txChain, client: props.client, data: swapData.transaction.data, value: swapData.transaction.value ? BigInt(swapData.transaction.value) : undefined, extraGas: 50000n, erc20Value: isSellingNativeToken ? undefined : { amountWei: BigInt(swapData.from_token.amount), tokenAddress: swapData.from_token.address, }, to: swapData.transaction.to, }); - props.sendTx(tx); + await props.sendTx(tx); }} @@ - txChainID={swapData.transaction.chain_id} + txChainID={txChain?.id ?? swapData.transaction.chain_id}Also applies to: 98-101, 104-123, 126-127
164-168: Mirror chain-ready guards for Approve; await send.Prevent prepareTransaction with undefined chain; keep button disabled until ready.
Apply:
const txChain = useV5DashboardChain(swapData.transaction.chain_id); @@ - disabled={isTransactionPending} + disabled={!txChain || isTransactionPending} @@ - onClick={async () => { + onClick={async () => { + if (!txChain) { + return; + } const tx = prepareTransaction({ chain: txChain, client: props.client, data: swapData.transaction.data, to: swapData.transaction.to, }); - props.sendTx(tx); + await props.sendTx(tx); }} @@ - txChainID={swapData.transaction.chain_id} + txChainID={txChain?.id ?? swapData.transaction.chain_id}Also applies to: 217-224, 225-234, 237-238
258-276: Safely handle undefined chain in TokenRow.chain may be undefined until chains load; direct property access will throw.
Apply:
const chain = useV5DashboardChain(props.chainId); @@ <ChainIconClient className="size-4" client={props.client} - src={chain.icon?.url || ""} + src={chain?.icon?.url} /> - <div>{chain.name}</div> + <div>{chain?.name ?? `Chain ${props.chainId}`}</div>apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatBar.tsx (4)
56-60: Brand text: “Nebula” → “thirdweb” (toast copy)Align copy with the rename.
function showSigninToUploadImagesToast() { - toast.error("Sign in to upload images to Nebula", { + toast.error("Sign in to upload images to thirdweb", { position: "top-right", }); }
372-376: Brand text: “Nebula” → “thirdweb” (popover copy)Update popover message to reflect the rename.
- Get access to image uploads by signing in to Nebula + Get access to image uploads by signing in to thirdweb
176-199: Drag-over highlight can get stuck when uploads aren’t allowedOnly set the highlight when uploads are allowed; always clear it correctly; and accept
Node | nullincontains.onDragEnter={(e) => { e.preventDefault(); - setIsDragOver(true); - if (!props.allowImageUpload) { - return; - } + if (!props.allowImageUpload) return; + setIsDragOver(true); }} onDragLeave={(e) => { e.preventDefault(); - if (!props.allowImageUpload) { - return; - } - // Only set drag over to false if we're leaving the container entirely - if (!e.currentTarget.contains(e.relatedTarget as Node)) { + // Only set drag over to false if we're leaving the container entirely + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setIsDragOver(false); } }} onDragOver={(e) => { e.preventDefault(); - setIsDragOver(true); - if (!props.allowImageUpload) { - return; - } + if (!props.allowImageUpload) return; + setIsDragOver(true); }}
227-236: Prevent sending empty messages on EnterMatch the Send-button guard so Enter doesn’t submit empty text with no images.
if (e.key === "Enter" && !props.isChatStreaming) { - e.preventDefault(); - handleSubmit(message); + if (message.trim() !== "" || images.length > 0) { + e.preventDefault(); + handleSubmit(message); + } }apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageLayout.stories.tsx (1)
55-69: Story now supplies required props; renders as expected.Fix addresses the earlier missing-props issue.
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/chat.ts (1)
34-44: Wrap stream setup in try/catch and surface connection/abort errors to UI (duplicate).Network errors before first event currently throw and bypass
handleStream. Reuse the earlier suggested guard.- const events = await stream(`${NEXT_PUBLIC_THIRDWEB_AI_HOST}/chat`, { + try { + const events = await stream(`${NEXT_PUBLIC_THIRDWEB_AI_HOST}/chat`, { body: JSON.stringify(body), headers: { Authorization: `Bearer ${params.authToken}`, "x-team-id": params.project.teamId, "x-client-id": params.project.publishableKey, "Content-Type": "application/json", }, method: "POST", signal: params.abortController.signal, - }); + }); + for await (const _event of events) { + // existing switch… + } + } catch (e) { + if (params.abortController.signal.aborted) return; + params.handleStream({ + event: "error", + data: { + code: 0, + errorMessage: e instanceof Error ? e.message : "Failed to connect to chat stream", + }, + }); + }apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageContent.tsx (1)
160-167: Fix possible undefined .toLowerCase() (duplicate).Guard when no text segment exists.
- const lowerCaseMessage = message.content - .find((x) => x.type === "text") - ?.text.toLowerCase(); + const lowerCaseMessage = message.content + .find((x) => x.type === "text") + ?.text + ?.toLowerCase();
🧹 Nitpick comments (29)
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.stories.tsx (7)
5-15: Set default args and tidy controls in meta (optional).Avoid repeating
clientin every story, hide it from Controls, and enable autodocs/fullscreen.Apply:
const meta = { component: AssetsSectionUI, decorators: [ (Story) => ( <div className="mx-auto h-dvh w-full max-w-[300px] bg-card p-2"> <Story /> </div> ), ], title: "AI/AssetsSection", + args: { client: storybookThirdwebClient }, + parameters: { + layout: "fullscreen", + controls: { exclude: ["client"] }, + }, + tags: ["autodocs"], } satisfies Meta<typeof AssetsSectionUI>;
20-69: Gitleaks false-positives on EVM addresses; add a scoped allowlist.The
token_addressvalues are public contract addresses (not secrets) but may trip Gitleaks (see hints). Add a path-scoped allowlist to prevent CI noise.Option A (repo-level): create/append gitleaks config:
+# gitleaks.toml +[allowlist] +paths = [ + '''apps/dashboard/src/app/(app)/team/.*/AssetsSection/AssetsSection\.stories\.tsx''' +]Option B (inline, local): if you prefer inline suppression, add
// gitleaks:allowafter eachtoken_addressline.
71-77: Remove redundant client arg if meta.args is set.export const MultipleAssets: Story = { args: { - client: storybookThirdwebClient, data: tokensStub, isPending: false, }, };
79-85: Same: dedupe client arg.export const SingleAsset: Story = { args: { - client: storybookThirdwebClient, data: tokensStub.slice(0, 1), isPending: false, }, };
87-93: Same: dedupe client arg.export const EmptyAssets: Story = { args: { - client: storybookThirdwebClient, data: [], isPending: false, }, };
95-101: Same: dedupe client arg.export const Loading: Story = { args: { - client: storybookThirdwebClient, data: [], isPending: true, }, };
5-15: Expose className on AssetsSectionUI to match app guidelines.Per apps/{dashboard,playground}/**/*.tsx, components should accept
classNameon the root. AssetsSectionUI currently doesn’t. Minor refactor:Outside this file (AssetsSection.tsx):
@@ -import { XIcon } from "lucide-react"; +import { XIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; @@ -export function AssetsSectionUI(props: { - data: AssetBalance[]; - isPending: boolean; - client: ThirdwebClient; -}) { - if (props.data.length === 0 && !props.isPending) { +export function AssetsSectionUI({ + data, + isPending, + client, + className, +}: { + data: AssetBalance[]; + isPending: boolean; + client: ThirdwebClient; + className?: string; +}) { + if (data.length === 0 && !isPending) { return ( - <div className="flex h-full flex-col items-center justify-center gap-3 px-2 py-1"> + <div className={cn("flex h-full flex-col items-center justify-center gap-3 px-2 py-1", className)}> <div className="rounded-full border p-1"> <XIcon className="size-4" /> </div> <div className="text-muted-foreground text-sm">No Assets </div> </div> ); } return ( - <div className="flex flex-col gap-1"> - {!props.isPending && - props.data.map((asset) => ( + <div className={cn("flex flex-col gap-1", className)}> + {!isPending && + data.map((asset) => ( <AssetItem asset={asset} - client={props.client} + client={client} key={`${asset.chain_id}-${asset.token_address}`} /> ))} - {props.isPending && + {isPending && new Array(10).fill(null).map((_, index) => ( // biome-ignore lint/suspicious/noArrayIndexKey: for the placeholder this is explicitly the key <SkeletonAssetItem key={index} /> ))} </div> );apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.tsx (3)
1-15: Expose className on exported components and add explicit return types.Matches dashboard conventions and improves composition; also aligns with “explicit return types” guideline.
Apply:
+'use client'; + +import type { JSX } from "react"; import { ArrowRightLeftIcon, CheckIcon } from "lucide-react"; @@ +import { cn } from "@/lib/utils"; @@ -export function SwapTransactionCard(props: { +export function SwapTransactionCard(props: { swapData: NebulaSwapData; client: ThirdwebClient; onTxSettled: (txHash: string) => void; -}) { + className?: string; +}): JSX.Element { @@ <SwapTransactionCardLayout client={props.client} sendTx={(tx) => sendTx(tx, props.onTxSettled)} setStatus={setStatus} status={status} swapData={props.swapData} + className={props.className} /> @@ -export function SwapTransactionCardLayout(props: { +export function SwapTransactionCardLayout(props: { swapData: NebulaSwapData; client: ThirdwebClient; status: TxStatus; setStatus: (status: TxStatus) => void; sendTx: (tx: PreparedTransaction) => Promise<void>; -}) { + className?: string; +}): JSX.Element { @@ - <div className="max-w-lg"> + <div className={cn("max-w-lg", props.className)}> @@ -export function ApproveTransactionCard(props: { +export function ApproveTransactionCard(props: { swapData: NebulaSwapData; client: ThirdwebClient; -}) { + className?: string; +}): JSX.Element { @@ <ApproveTransactionCardLayout client={props.client} sendTx={(tx) => sendTx(tx, undefined)} setStatus={setStatus} status={status} swapData={props.swapData} + className={props.className} /> @@ -export function ApproveTransactionCardLayout(props: { +export function ApproveTransactionCardLayout(props: { swapData: NebulaSwapData; client: ThirdwebClient; status: TxStatus; setStatus: (status: TxStatus) => void; sendTx: (tx: PreparedTransaction) => Promise<void>; -}) { + className?: string; +}): JSX.Element { @@ - <div className="max-w-lg"> + <div className={cn("max-w-lg", props.className)}> @@ -function TokenRow(props: { +function TokenRow(props: { amount: string; symbol: string; chainId: number; client: ThirdwebClient; title: string; decimals: number; -}) { +}): JSX.Element {Also applies to: 16-20, 34-40, 49-50, 139-144, 156-162, 170-171, 250-257
112-113: Consider estimating or parameterizing extraGas.Hard-coding 50k may be insufficient/excessive across routes; consider deriving from estimation with a safety multiplier or move to a prop.
102-103: isLoggedIn hard-coded to true.If this button should honor actual auth/wallet state, wire the real flag; otherwise add a comment explaining why always-true is correct.
Also applies to: 223-224
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatBar.tsx (3)
624-682: Remove duplicate WalletProvider nesting
WalletProvideris wrapped twice with the sameid, adding unnecessary providers.- <WalletProvider id={wallet.walletId}> - <div className="flex items-center gap-2.5"> + <div className="flex items-center gap-2.5"> <AccountAvatar className="size-8 rounded-full" fallbackComponent={accountAvatarFallback} loadingComponent={accountAvatarFallback} /> @@ - </div> - </WalletProvider> + </div>
114-165: Large inline b64 uploads can inflate request size; consider pre-upload + URLUp to 4×5MB images become ~26MB+ with base64 overhead. Prefer pre-uploading to storage and sending
image_urlreferences instead of inlining b64 in the chat message.
353-354: Drop no-op prop
value={undefined}onImageUploadButtonhas no effect and can be removed.- value={undefined}apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/layout.tsx (1)
83-93: Avoid making the layout dynamic just to hide the chat buttonUsing
headers()forces dynamic rendering. Consider moving the “/ai” guard into a tiny client wrapper usinguseSelectedLayoutSegments()(as done in CustomChatButton for “support”), or pass the current route via props from an existing client boundary.apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageLayout.stories.tsx (1)
12-15: Pass the Storybook client to ThirdwebProvider to avoid context warnings.Prevents potential runtime warnings and enables hooks inside stories if added later.
- <ThirdwebProvider> + <ThirdwebProvider client={storybookThirdwebClient}> <Story /> </ThirdwebProvider>apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.tsx (3)
100-107: Use a stable key to minimize unnecessary re-renders.
keyincludes timestamps; it will change whenupdated_atchanges.- key={session.id + session.updated_at + session.created_at} + key={session.id}
185-188: Avoid duplicate IDs indeletedSessionsStore.Prevents store bloat if users retry deletions.
- const prev = deletedSessionsStore.getValue(); - deletedSessionsStore.setValue([...prev, props.session.id]); + const prev = deletedSessionsStore.getValue(); + if (!prev.includes(props.session.id)) { + deletedSessionsStore.setValue([...prev, props.session.id]); + }
7-7: Optional: lazy-load Fuse to trim initial bundle.If bundle size for this route matters, consider dynamically importing
fuse.jsonly when search is used (e.g., on first non-empty query) and caching the instance.apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/chat.ts (2)
46-182: Guard all JSON.parse calls to prevent a single bad event from killing the stream.Add a tiny safe-parse helper and use it per case.
+function safeParse<T>(s: string): T | null { + try { return JSON.parse(s) as T; } catch { return null; } +} ... - case "delta": { - params.handleStream({ - data: { - v: JSON.parse(event.data).v, - }, - event: "delta", - }); + case "delta": { + const d = safeParse<{ v: string }>(event.data); + if (!d) break; + params.handleStream({ data: { v: d.v }, event: "delta" }); break; } ... - case "presence": { - params.handleStream({ - data: JSON.parse(event.data), - event: "presence", - }); + case "presence": { + const d = safeParse<{ session_id: string; request_id: string; source: string; data: string }>(event.data); + if (!d) break; + params.handleStream({ data: d, event: "presence" }); break; } ... - case "image": { - const data = JSON.parse(event.data) as { + case "image": { + const data = safeParse<{ data: { width: number; height: number; url: string }; request_id: string; - }; + }>(event.data); + if (!data) break; ... - case "action": { - const data = JSON.parse(event.data); + case "action": { + const data = safeParse<{ type: "sign_transaction" | "sign_swap"; data: unknown; request_id: string }>(event.data); + if (!data) break; ... - case "error": { - const data = JSON.parse(event.data) as { + case "error": { + const data = safeParse<{ code: number; error: { message: string } }>(event.data); + if (!data) break; ... - case "init": { - const data = JSON.parse(event.data); + case "init": { + const data = safeParse<{ request_id: string; session_id: string }>(event.data); + if (!data) break; ... - case "context": { - const data = JSON.parse(event.data) as { + case "context": { + const data = safeParse<{ data: string; request_id: string; session_id: string }>(event.data); + if (!data) break; - const contextData = JSON.parse(data.data) as { + const contextData = safeParse<{ wallet_address: string; chain_ids: number[]; networks: NebulaContext["networks"] }>(data.data); + if (!contextData) break;
20-33: Type the request payload and dedupe context mapping.Define a
ChatRequestPayloadand atoApiContext()helper to reduce casts and keepchain_idsmapping consistent across modules.- const body: Record<string, string | boolean | object> = { + type ChatRequestPayload = { + messages: [NebulaUserMessage]; + session_id: string; + stream: true; + context?: { chain_ids: string[]; networks: NebulaContext["networks"]; wallet_address: string | null }; + }; + const body: ChatRequestPayload = { messages: [params.message], session_id: params.sessionId, stream: true, };apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageContent.tsx (5)
36-49: Expose className on root and merge via cn().Matches dashboard .tsx guideline.
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; ... export function ChatPageContent(props: { project: Project; session: SessionInfo | undefined; accountAddress: string; authToken: string; client: ThirdwebClient; type: "landing" | "new-chat"; + className?: string; initialParams: ... - return ( - <div className="flex grow flex-col overflow-hidden"> + return ( + <div className={cn("flex grow flex-col overflow-hidden", props.className)}>Also applies to: 305-312
365-369: On abort, remove the trailing “presence” placeholder.Prevents a stuck “presence” row after user cancels.
abortChatStream={() => { chatAbortController?.abort(); setChatAbortController(undefined); setIsChatStreaming(false); + setMessages((prev) => + prev[prev.length - 1]?.type === "presence" + ? prev.slice(0, -1) + : prev, + ); }}
710-726: Wrap image history JSON.parse in try/catch like actions.A malformed row will currently throw and drop the render.
- case "image": { - const content = JSON.parse(message.content) as { + case "image": { + try { + const content = JSON.parse(message.content) as { type: "image"; request_id: string; data: { width: number; height: number; url: string }; - }; - - messages.push({ - data: content.data, - request_id: content.request_id, - type: "image", - }); + }; + messages.push({ + data: content.data, + request_id: content.request_id, + type: "image", + }); + } catch (e) { + console.error("error processing image message", e, { message }); + } break; }
745-751: Add break to avoid future fallthrough.Safer if new cases are appended later.
case "assistant": { messages.push({ request_id: undefined, text: message.content, type: message.role, }); + break; }
42-48: Unused prop “type”.If unused, remove to reduce API surface; otherwise, add usage or a comment.
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/session.ts (4)
27-42: Redundant res.ok checks; rely on fetchWithAuthToken throwing on non-OK.Simplify and let the wrapper handle errors.
const res = await fetchWithAuthToken({ project: params.project, authToken: params.authToken, body: body, endpoint: `${NEXT_PUBLIC_THIRDWEB_AI_HOST}/session`, method: "POST", }); - - if (!res.ok) { - const error = await res.text(); - throw new Error(`Failed to create session: ${error}`); - } const data = await res.json(); return data.result as SessionInfo;
60-74: Apply the same simplification to update/delete/list/get; fix delete error message.The wrapper already throws; also “Failed to update session” in delete/list/get is misleading.
- if (!res.ok) { - throw new Error("Failed to update session"); - } + // fetchWithAuthToken throws on non-OK const data = await res.json(); return data.result as UpdatedSessionInfo;- if (!res.ok) { - throw new Error("Failed to update session"); - } + // fetchWithAuthToken throws on non-OK const data = await res.json(); return data.result as DeletedSessionInfo;- if (!res.ok) { - throw new Error("Failed to update session"); - } + // fetchWithAuthToken throws on non-OK const data = await res.json(); return data.result as TruncatedSessionInfo[];- if (!res.ok) { - throw new Error("Failed to update session"); - } + // fetchWithAuthToken throws on non-OK const data = await res.json(); return data.result as SessionInfo;Also applies to: 80-94, 100-113, 120-133
12-16: Add explicit return types per TS guideline.Clarifies API surfaces.
-export async function createSession(params: { ... }) { +export async function createSession(params: { ... }): Promise<SessionInfo> { ... -export async function updateSession(params: { ... }) { +export async function updateSession(params: { ... }): Promise<UpdatedSessionInfo> { ... -export async function deleteSession(params: { ... }) { +export async function deleteSession(params: { ... }): Promise<DeletedSessionInfo> { ... -export async function getSessions(params: { ... }) { +export async function getSessions(params: { ... }): Promise<TruncatedSessionInfo[]> { ... -export async function getSessionById(params: { ... }) { +export async function getSessionById(params: { ... }): Promise<SessionInfo> {Also applies to: 44-49, 76-80, 96-99, 115-119
19-25: DRY context mapping with a helper.Avoid repeating
chain_ids/networks/wallet_addressmapping.function toApiContext(ctx: NebulaContext) { return { chain_ids: ctx.chainIds || [], networks: ctx.networks, wallet_address: ctx.walletAddress, }; }- if (params.context) { - body.context = { - chain_ids: params.context.chainIds || [], - networks: params.context.networks, - wallet_address: params.context.walletAddress, - }; - } + if (params.context) body.context = toApiContext(params.context);Also applies to: 52-58
.../src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.tsx
Show resolved
Hide resolved
.../dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatBar.tsx
Show resolved
Hide resolved
.../dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatBar.tsx
Show resolved
Hide resolved
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/layout.tsx
Outdated
Show resolved
Hide resolved
ee25ba7 to
9138778
Compare
9138778 to
b4c89eb
Compare
51e0057 to
7b94e0f
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
♻️ Duplicate comments (19)
apps/dashboard/src/@/actions/proxies.ts (1)
18-19: Wire AbortSignal through and handle network/abort errors; disable caching for proxied calls.
signalis added to params but not passed tofetch, and network/abort errors will throw and bypass your typed result. Also considercache: "no-store"for true proxy semantics.- const res = await fetch(url, { - body: params.body, - headers: { - ...params.headers, - ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), - }, - method: params.method, - }); + let res: Response; + try { + res = await fetch(url, { + body: params.body, + headers: { + ...params.headers, + ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), + }, + method: params.method, + signal: params.signal, + cache: "no-store", + // next: { revalidate: 0 }, // optional: Next.js caching control + }); + } catch (err) { + const message = (err as Error)?.message || "Network error"; + const aborted = + (err as any)?.name === "AbortError" || message.toLowerCase().includes("abort"); + return { + ok: false, + status: aborted ? 499 : 502, + error: aborted ? "Request aborted" : message, + }; + }Also applies to: 51-58
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/TransactionsSection/TransactionsSection.tsx (3)
12-24: Tighten decoded.inputs typing (avoidobject).Model known fields to get type-safety where you access inputs.to / inputs.spender.
export type WalletTransaction = { chain_id: string; value: string; hash: string; from_address: string; to_address: string; decoded?: { - name: string; - signature: string; - inputs: null | object; + name?: string | null; + signature?: string | null; + inputs?: null | { + to?: string; + spender?: string; + }; }; };
165-201: Harden React Query: types, abort signal, ok-check, cache.Add explicit generics, wire AbortSignal, check response.ok, and configure stale/gc time.
- const txQuery = useQuery({ + const txQuery = useQuery<WalletTransaction[]>({ enabled: !!account && !!activeChain, - queryFn: async () => { + queryFn: async ({ signal }) => { if (!account || !activeChain) { return []; } @@ - const response = await fetch(url.toString()); + const response = await fetch(url.toString(), { signal }); + if (!response.ok) { + return []; + } const json = (await response.json()) as { data?: WalletTransaction[]; }; return json.data ?? []; }, queryKey: ["v1/wallets/transactions", account?.address, activeChain?.id], - retry: false, + staleTime: 60_000, + gcTime: 5 * 60_000, + retry: false, });
1-1: Add 'use client' (hooks used in this file).This component uses React Query and thirdweb hooks and must be a Client Component.
Apply:
+ 'use client'; + import { useQuery } from "@tanstack/react-query";apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatBar.tsx (6)
56-60: Brand string: “Nebula” → “thirdweb”.Align with PR rename.
function showSigninToUploadImagesToast() { - toast.error("Sign in to upload images to Nebula", { + toast.error("Sign in to upload images to thirdweb", { position: "top-right", }); }
372-376: Update popover copy to “thirdweb”.- Get access to image uploads by signing in to Nebula + Get access to image uploads by signing in to thirdweb
176-199: Fix drag-over highlight getting stuck; gate on allowImageUpload and handle null relatedTarget.onDragEnter={(e) => { e.preventDefault(); - setIsDragOver(true); - if (!props.allowImageUpload) { - return; - } + if (!props.allowImageUpload) return; + setIsDragOver(true); }} onDragLeave={(e) => { e.preventDefault(); - if (!props.allowImageUpload) { - return; - } - // Only set drag over to false if we're leaving the container entirely - if (!e.currentTarget.contains(e.relatedTarget as Node)) { + // Only set drag over to false if we're leaving the container entirely + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setIsDragOver(false); } }} onDragOver={(e) => { e.preventDefault(); - setIsDragOver(true); - if (!props.allowImageUpload) { - return; - } + if (!props.allowImageUpload) return; + setIsDragOver(true); }}
232-235: Prevent sending empty messages on Enter (no text and no images).- if (e.key === "Enter" && !props.isChatStreaming) { - e.preventDefault(); - handleSubmit(message); - } + if (e.key === "Enter" && !props.isChatStreaming) { + if (message.trim() === "" && images.length === 0) return; + e.preventDefault(); + handleSubmit(message); + }
93-101: Send raw base64 (strip data URL prefix) to reduce payloads.- if (image.b64) { - userMessage.content.push({ - b64: image.b64, - image_url: null, - type: "image", - }); - } + if (image.b64) { + const dataUrl = image.b64; + const base64 = dataUrl.includes(",") + ? dataUrl.split(",")[1] + : dataUrl; + userMessage.content.push({ + b64: base64, + image_url: null, + type: "image", + }); + }
536-541: Don’t mutate props when sorting wallets.- const sortedWallets = props.wallets.sort((a, b) => { + const sortedWallets = [...props.wallets].sort((a, b) => { if (a.walletId === "smart") return -1; if (b.walletId === "smart") return 1; return 0; });apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Chats.tsx (3)
88-110: Add cleanup for event listeners to prevent leaks.useEffect(() => { if (!enableAutoScroll) { return; } @@ chatScrollContainer.addEventListener("mousedown", disableScroll); chatScrollContainer.addEventListener("wheel", disableScroll); + return () => { + chatScrollContainer.removeEventListener("mousedown", disableScroll); + chatScrollContainer.removeEventListener("wheel", disableScroll); + }; }, [setEnableAutoScroll, enableAutoScroll]);
1-1: Add 'use client' (hooks used).+'use client'; import { MarkdownRenderer } from "@workspace/ui/components/markdown-renderer";
62-74: Remove unused authToken prop; avoid exposing tokens in client.It’s unused and should not be part of client props.
export function Chats(props: { project: Project; messages: Array<ChatMessage>; isChatStreaming: boolean; - authToken: string; sessionId: string | undefined; @@ <div @@ <RenderMessage project={props.project} - authToken={props.authToken} client={props.client} @@ function RenderMessage(props: { message: ChatMessage; isMessagePending: boolean; client: ThirdwebClient; sendMessage: (message: NebulaUserMessage) => void; nextMessage: ChatMessage | undefined; - authToken: string; sessionId: string | undefined; project: Project; }) { @@ <ScrollShadow className="rounded-lg"> <RenderResponse project={props.project} - authToken={props.authToken} client={props.client} isMessagePending={props.isMessagePending} message={message} nextMessage={props.nextMessage} sendMessage={props.sendMessage} sessionId={props.sessionId} /> </ScrollShadow>And drop the
authTokenfield from RenderResponse props:function RenderResponse(props: { message: ChatMessage; isMessagePending: boolean; client: ThirdwebClient; sendMessage: (message: NebulaUserMessage) => void; nextMessage: ChatMessage | undefined; sessionId: string | undefined; - authToken: string; project: Project; }) {Also applies to: 146-155, 167-176, 254-266, 287-296
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.tsx (4)
1-1: Add 'use client' (hooks used).+'use client'; import { ArrowRightLeftIcon, CheckIcon } from "lucide-react";
92-127: Guard undefined chain and await send (Swap).Prevent crashes when chain metadata isn’t ready; disable button until ready and early-return in handler.
{props.status.type !== "confirmed" && ( <div className="flex items-center justify-end border-t px-4 py-4 lg:px-5"> <TransactionButton className="gap-2" client={props.client} - disabled={ - props.status.type === "sending" || - props.status.type === "confirming" - } + disabled={ + !txChain || + props.status.type === "sending" || + props.status.type === "confirming" + } isLoggedIn={true} isPending={props.status.type === "sending"} onClick={async () => { + if (!txChain) return; const tx = prepareTransaction({ chain: txChain, client: props.client, data: swapData.transaction.data, value: swapData.transaction.value ? BigInt(swapData.transaction.value) : undefined, extraGas: 50000n, erc20Value: isSellingNativeToken ? undefined : { amountWei: BigInt(swapData.from_token.amount), tokenAddress: swapData.from_token.address, }, to: swapData.transaction.to, }); - props.sendTx(tx); + await props.sendTx(tx); }} size="sm" transactionCount={undefined} - txChainID={swapData.transaction.chain_id} + txChainID={txChain?.id ?? swapData.transaction.chain_id} variant="default" >
216-239: Mirror chain-ready guards for Approve.{props.status.type !== "confirmed" && ( <div className="flex items-center justify-end border-t px-4 py-4 lg:px-5"> <TransactionButton className="gap-2" client={props.client} - disabled={isTransactionPending} + disabled={!txChain || isTransactionPending} isLoggedIn={true} isPending={isTransactionPending} onClick={async () => { + if (!txChain) return; const tx = prepareTransaction({ chain: txChain, client: props.client, data: swapData.transaction.data, to: swapData.transaction.to, }); - props.sendTx(tx); + await props.sendTx(tx); }} size="sm" transactionCount={undefined} - txChainID={swapData.transaction.chain_id} + txChainID={txChain?.id ?? swapData.transaction.chain_id} variant="default" >
258-276: Avoid NPEs: chain can be undefined while loading.const chain = useV5DashboardChain(props.chainId); const tokenDisplayValue = toTokens(BigInt(props.amount), props.decimals); @@ <div className="flex items-center gap-1.5"> <ChainIconClient className="size-4" client={props.client} - src={chain.icon?.url || ""} + src={chain?.icon?.url} /> - <div>{chain.name}</div> + <div>{chain?.name ?? `Chain ${props.chainId}`}</div> </div>apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/types.ts (1)
6-15: Make “image” a true one‑of (exactly one of b64 or image_url).type NebulaUserMessageContentItem = | { type: "image"; - image_url: string | null; - b64: string | null; + image_url: string; + b64: null; } + | { + type: "image"; + image_url: null; + b64: string; + } | { type: "text"; text: string; }apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/page.tsx (1)
31-38: Do not pass auth tokens into client-rendered children; keep tokens server-sidePassing
authTokentoChatPageLayout/ChatPageContent(both render client trees) exposes the JWT to the browser. Route client calls through server actions or route handlers that read the token viagetAuthToken()and attachAuthorization: Bearer <token>server-side. Minimal change in this file:<ChatPageLayout team_slug={params.team_slug} project={project} - authToken={authToken} client={client} accountAddress={""} sessions={sessions} > <ChatPageContent project={project} accountAddress={""} - authToken={authToken} client={client} initialParams={undefined} session={undefined} type="new-chat" /> </ChatPageLayout>Follow-up (outside this file): remove
authTokenfrom those components’ props and have their network calls hit internal/api/ai/*handlers or server actions that inject the header.Also applies to: 39-47
🧹 Nitpick comments (11)
apps/dashboard/src/@/actions/proxies.ts (3)
6-8: Remove unused import or add an AI proxy helper.
NEXT_PUBLIC_THIRDWEB_AI_HOSTis imported but never used. Either remove it or add a dedicatedaiServerProxyto keep parity with the other helpers.+export async function aiServerProxy<T>(params: ProxyActionParams) { + return proxy<T>(NEXT_PUBLIC_THIRDWEB_AI_HOST, params); +}
42-49: Usesetinstead ofappendfor query params to avoid accidental duplicates.- if (params.searchParams) { - for (const key in params.searchParams) { - const value = params.searchParams[key]; - if (value) { - url.searchParams.append(key, value); - } - } - } + if (params.searchParams) { + for (const key in params.searchParams) { + const value = params.searchParams[key]; + if (value !== undefined) { + url.searchParams.set(key, value); + } + } + }
77-81: Guard JSON parsing for 204/empty or non-JSON responses.
res.json()will throw on 204 or non-JSON payloads.- return { - data: params.parseAsText ? await res.text() : await res.json(), - ok: true, - status: res.status, - }; + const ct = res.headers.get("content-type") || ""; + const isJson = ct.includes("application/json") || ct.includes("+json"); + const data = params.parseAsText + ? await res.text() + : res.status === 204 + ? (null as unknown as T) + : isJson + ? await res.json() + : ((await res.text()) as unknown as T); + return { data, ok: true, status: res.status };apps/dashboard/src/@/components/chat/CustomChatButton.tsx (2)
18-24: Add explicit return type to match TS guidelinesDeclare the component’s return type.
Use:
export function CustomChatButton(props: { /* … */ }): JSX.Element {
14-16: Lazy‑init thirdweb client to keep initial bundle leanCreating the client at module scope pulls thirdweb into the initial chunk. Initialize when the chat first opens.
Example:
// remove module-scope client export function CustomChatButton(/* … */): JSX.Element { const [isOpen, setIsOpen] = useState(false); const [hasBeenOpened, setHasBeenOpened] = useState(false); const clientRef = useRef<ReturnType<typeof createThirdwebClient> | null>(null); if (hasBeenOpened && !clientRef.current) { clientRef.current = createThirdwebClient({ clientId: NEXT_PUBLIC_DASHBOARD_CLIENT_ID }); } // … {hasBeenOpened && isOpen && clientRef.current && ( <CustomChatContent client={clientRef.current} /* … */ /> )} }apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatSidebar.tsx (1)
85-96: Use NavLink for internal route to get active state styling.Optional: swap Link → NavLink for “Analytics”.
-import Link from "next/link"; +import Link from "next/link"; +import { NavLink } from "@/components/ui/NavLink"; @@ - <Link + <NavLink className="flex items-center gap-1 rounded-full text-foreground text-sm hover:underline justify-between" href={`/team/${props.team_slug}/${props.project.slug}/ai/analytics`} > @@ - </Link> + </NavLink>apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/types.ts (1)
77-82: Narrow NebulaTxData.to to an address literal type.export type NebulaTxData = { chain_id: number; data: `0x${string}`; - to: string; + to: `0x${string}`; value?: string; };apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/page.tsx (4)
1-2: Add "server-only" to enforce Server Component boundaryPrevents accidental client bundling/imports.
+import "server-only"; import { notFound } from "next/navigation"; import { getAuthToken, getUserThirdwebClient } from "@/api/auth-token";
8-10: Declare an explicit return type for PageMatches repo guidelines for TS explicit return types.
-export default async function Page(props: { +export default async function Page(props: { params: Promise<{ team_slug: string; project_slug: string }>; -}) { +}): Promise<JSX.Element> {
11-11: Simplify awaiting paramsNo need for Promise.all on a single promise.
- const [params] = await Promise.all([props.params]); + const params = await props.params;
28-28: Swallowing session fetch errors hides issues; at least logKeeps UX resilient but adds visibility when the API fails.
- const sessions = await getSessions({ project }).catch(() => []); + const sessions = await getSessions({ project }).catch((err) => { + console.error("getSessions failed", err); + return []; + });
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/types.ts
Show resolved
Hide resolved
| jwt: authToken, | ||
| teamId: project.teamId, | ||
| }); | ||
| const client = await getUserThirdwebClient({ teamId: project.teamId }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Verify ThirdwebClient is safe/serializable across the RSC → client boundary
If any downstream is a Client Component, passing a ThirdwebClient instance may fail serialization or leak server-only config. Prefer constructing a browser-safe client in the client layer (using public config) or pass minimal primitives.
Run to check which components are client-rendered and receive client:
Also applies to: 31-38
🏁 Script executed:
#!/bin/bash
# Locate ChatPage* files and detect 'use client' + 'client: ThirdwebClient' usage
fd -t f 'ChatPage*.tsx' apps | xargs -I{} sh -c 'echo "== {} =="; head -n 3 {}; rg -n "^'\''use client'\''" {}; rg -nP -C1 "\bclient\s*:\s*ThirdwebClient" {}; echo'
# Find downstream props that also accept `client`
rg -nP -C1 --type tsx "\bclient\s*:\s*ThirdwebClient\b" appsLength of output: 27
🏁 Script executed:
#!/bin/bash
# Locate the ai/page.tsx file under the team project path
FILE=$(find apps/dashboard/src/app -type f -path "*/team/*/*/ai/page.tsx")
echo "Inspecting file: $FILE"
# Show top of file for directives and imports
sed -n '1,30p' "$FILE"
# Check for client component directive
rg -n "'use client'" "$FILE"
# Search for any prop or invocation passing `client =`
rg -nP "client\s*=" -C1 "$FILE"Length of output: 1248
🏁 Script executed:
#!/bin/bash
set -e
# Locate the ai page component
PAGE=$(find apps/dashboard/src/app -type f -path "*/team/*/*/ai/page.tsx")
COMP_DIR=$(dirname "$PAGE")/components
for comp in ChatPageContent.tsx ChatPageLayout.tsx; do
FILE="$COMP_DIR/$comp"
echo "== $FILE =="
# Show top lines for 'use client' directive and imports
sed -n '1,20p' "$FILE"
# Check for client component
rg -n "'use client'" "$FILE" || echo "no 'use client' directive"
echo
doneLength of output: 1424
Avoid passing server-side ThirdwebClient into a client component
In page.tsx you fetch a server-only ThirdwebClient and pass it via ChatPageLayout into ChatPageContent.tsx, which is a "use client" component. Class instances aren’t JSON-serializable and may expose server config. Instead, construct a browser-safe client inside the client component (e.g. use new ThirdwebClient({…publicConfig}) or the useClient() hook from thirdweb/react), or pass only serializable primitives (tokens, chain IDs).
🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/page.tsx
around line 26, you are creating a server-only ThirdwebClient and passing that
instance into a client component; instead, stop passing the class instance
across the server->client boundary and only pass serializable primitives (e.g.,
ownerAddress, accessToken, chainId) or construct the Thirdweb client inside the
client component. Remove the server-side client from ChatPageLayout props,
change the layout to accept only serializable values, and in ChatPageContent (or
its wrapper) create a browser-safe client (either via new
ThirdwebClient({…publicConfig}) or the thirdweb/react useClient() hook) using
the passed primitives/tokens.
7b94e0f to
0ece290
Compare
0ece290 to
8cfc3cb
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
♻️ Duplicate comments (16)
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/data/examplePrompts.ts (1)
1-5: Import the shared ExamplePrompt type to avoid driftReuse the canonical type from chat/types instead of redefining it locally.
-type ExamplePrompt = { - title: string; - message: string; - interceptedReply?: string; -}; +import type { ExamplePrompt } from "@/components/chat/types";apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/page.tsx (2)
34-34: Do not forward JWTs to client-rendered children.Passing
authTokenintoChatPageLayout/ChatPageContentleaks the token to the browser; route client calls via internal API routes/server actions that read the token server-side.project={project} - authToken={authToken} client={client} accountAddress={""} sessions={sessions} > <ChatPageContent project={project} accountAddress={""} - authToken={authToken} client={client} initialParams={undefined} session={undefined} type="new-chat" />Would you like a follow-up patch adding /api/ai/* route handlers that attach
Authorization: Bearer <token>usinggetAuthToken()?#!/bin/bash # Verify any 'use client' components accept/receive authToken rg -nP -C2 "^'use client'|authToken\s*:" apps/dashboard/src/app/(app)/team/.*/ai/componentsAlso applies to: 42-42
35-35: Avoid passing a server-created ThirdwebClient into client components.Class instances aren’t RSC-serializable and can expose server-only config. Construct a browser-safe client inside the client layer (e.g.
useClient()ornew ThirdwebClient({...public})) or pass only serializable primitives.- client={client} + /* construct client in client component instead */#!/bin/bash # Find client components receiving a ThirdwebClient instance rg -nP -C2 "^'use client'|client\s*:\s*ThirdwebClient|client=\{client\}" apps/dashboard/src/app/(app)/team/.*/aiAlso applies to: 43-43
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.stories.tsx (2)
9-11: Replace nonstandardh-dvhwith a supported utility.Use
h-[100dvh](or a configured DVH token) to ensure height applies in Storybook.- <div className="mx-auto h-dvh w-full max-w-[300px] bg-card p-2"> + <div className="mx-auto h-[100dvh] w-full max-w-[300px] bg-card p-2">
2-2: Use a stubbed client in Storybook and de-duplicateclientviameta.args.Avoid network calls in stories. Import the stubbed client (not the real SDK) and set it once in
meta.args, then drop per-storyclientprops.-import { storybookThirdwebClient } from "@/storybook/utils"; +import { storybookThirdwebClient } from "@/storybook/stubs"; const meta = { component: AssetsSectionUI, decorators: [ (Story) => ( - <div className="mx-auto h-dvh w-full max-w-[300px] bg-card p-2"> + <div className="mx-auto h-dvh w-full max-w-[300px] bg-card p-2"> <Story /> </div> ), ], title: "AI/AssetsSection", + args: { client: storybookThirdwebClient }, } satisfies Meta<typeof AssetsSectionUI>; export const MultipleAssets: Story = { args: { - client: storybookThirdwebClient, data: tokensStub, isPending: false, }, }; export const SingleAsset: Story = { args: { - client: storybookThirdwebClient, data: tokensStub.slice(0, 1), isPending: false, }, }; export const EmptyAssets: Story = { args: { - client: storybookThirdwebClient, data: [], isPending: false, }, }; export const Loading: Story = { args: { - client: storybookThirdwebClient, data: [], isPending: true, }, };Also applies to: 5-15, 71-77, 79-85, 87-93, 95-101
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ExecuteTransactionCard.tsx (4)
95-100: Guard BigInt conversion and undefined chain to prevent runtime crashes
BigInt(txData.value)can throw andchainmay be undefined. Render safely.- {txData.value !== undefined && ( + {txData.value !== undefined && ( <div className="flex items-center justify-between gap-2"> <span className="font-medium text-muted-foreground">Value</span> - {toEther(BigInt(txData.value))} {chain.nativeCurrency?.symbol} + <span> + {(() => { + try { + return `${toEther(BigInt(txData.value))} ${chain?.nativeCurrency?.symbol ?? ""}`; + } catch { + return "—"; + } + })()} + </span> </div> )}
106-114: Optional chain for icon/name; add safe fallbacksAvoid property access on possibly undefined
chain.<ChainIconClient className="size-5 rounded-full" client={props.client} - src={chain.icon?.url} + src={chain?.icon?.url} /> <span className="text-foreground"> - {chain.name || `Chain ID: ${txData.chain_id}`} + {chain?.name ?? `Chain ID: ${txData.chain_id}`} </span>
134-139: Disable send without wallet/chain; set isLoggedIn from statePrevents attempts to send without prerequisites.
disabled={ - props.status.type === "sending" || - props.status.type === "confirming" + props.status.type === "sending" || + props.status.type === "confirming" || + !account || + !chain } - isLoggedIn={true} + isLoggedIn={!!account}
141-149: Guard preconditions and wrap value parsing in try/catchAvoids crashes and sending invalid txs.
- onClick={async () => { - const tx = prepareTransaction({ - chain: chain, - client: props.client, - data: txData.data, - to: txData.to, - value: txData.value ? BigInt(txData.value) : undefined, - }); - - props.sendTx(tx, props.onTxSettled); - }} + onClick={async () => { + if (!account || !chain) return; + let valueWei: bigint | undefined = undefined; + try { + valueWei = txData.value ? BigInt(txData.value) : undefined; + } catch { + return; + } + const tx = prepareTransaction({ + chain, + client: props.client, + data: txData.data, + to: txData.to, + value: valueWei, + }); + props.sendTx(tx, props.onTxSettled); + }}apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/types.ts (2)
6-15: Make “image” content a true one-of to avoid invalid statesPrevents
{ image_url: null, b64: null }.type NebulaUserMessageContentItem = | { type: "image"; - image_url: string | null; - b64: string | null; + image_url: string; + b64: null; } + | { + type: "image"; + image_url: null; + b64: string; + } | { type: "text"; text: string; }
40-56: Typo: modal_name → model_name (twice)Matches upstream naming and avoids dropped fields.
export type SessionInfo = { id: string; account_id: string; - modal_name: string; + model_name: string; @@ export type UpdatedSessionInfo = { title: string; - modal_name: string; + model_name: string; account_id: string; context: SessionContextFilter | null; };apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ExecuteTransactionCard.stories.tsx (1)
67-67: Fix sendTx prop signature to match component contractCurrent zero-arg handler fails type checks.
- sendTx={async () => {}} + sendTx={async (_tx, _onTxSettled) => { + // no-op in story + }}apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatSidebarLink.tsx (1)
17-19: Don’t call tokened session API from client; proxy via internal routeKeep JWT server-side by hitting an internal DELETE route.
-import { deleteSession } from "../api/session"; +import { cn } from "@/lib/utils"; @@ -// TODO - add delete chat confirmation dialog +// TODO - add delete chat confirmation dialog +// Temporary client shim; replace with typed internal route when available. +async function deleteSessionViaApi(sessionId: string) { + const res = await fetch( + `/api/thirdweb-ai/session/${encodeURIComponent(sessionId)}`, + { method: "DELETE" }, + ); + if (!res.ok) { + throw new Error("Failed to delete session"); + } + return res.json(); +} @@ -export function ChatSidebarLink(props: { +export function ChatSidebarLink(props: { sessionId: string; title: string; project: Project; team_slug: string; -}) { + className?: string; +}) { @@ - const deleteChat = useMutation({ - mutationFn: () => { - return deleteSession({ - project: props.project, - sessionId: props.sessionId, - }); - }, + const deleteChat = useMutation({ + mutationFn: () => deleteSessionViaApi(props.sessionId), @@ - return ( - <div - className="group relative rounded-lg hover:bg-accent" + return ( + <div + className={cn("group relative rounded-lg hover:bg-accent", props.className)} key={props.sessionId} >Also applies to: 32-46, 74-91
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.stories.tsx (3)
12-17: Provide Thirdweb client to ProviderUse the shared storybook client.
decorators: [ (Story) => ( - <ThirdwebProvider> + <ThirdwebProvider client={storybookThirdwebClient}> <Story /> </ThirdwebProvider> ), ],
54-69: Stable IDs and consistent timestamps for mock sessionsMake
updated_at≥created_atand use UUIDs when available.-function createRandomSessions(length: number) { +function createRandomSessions(length: number) { const sessions = []; for (let i = 0; i < length; i++) { - sessions.push({ - created_at: new Date().toISOString(), - id: Math.random().toString(), - title: randomLorem(Math.floor(5 + Math.random() * 15)), - updated_at: subDays( - new Date(), - Math.floor(Math.random() * 10), - ).toISOString(), - }); + const daysAgo = Math.floor(Math.random() * 10) + 1; + sessions.push({ + created_at: subDays(new Date(), daysAgo).toISOString(), + id: + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : Math.random().toString(36).slice(2), + title: randomLorem(Math.floor(5 + Math.random() * 15)), + updated_at: subDays(new Date(), daysAgo - 1).toISOString(), + }); } return sessions; }
71-90: Generate sessions once and reuse for layout + UI to keep them in syncAvoid two different lists on the same screen.
-function Variant(props: { length: number; prefillSearch?: string }) { - return ( +function Variant(props: { length: number; prefillSearch?: string }) { + const sessions = createRandomSessions(props.length); + return ( <ChatPageLayout accountAddress="0x1234567890" authToken="xxxxxxxx" - sessions={createRandomSessions(props.length)} + sessions={sessions} project={projectStub("xxxxx", "team-1")} client={storybookThirdwebClient} team_slug="team-1" > <ChatHistoryPageUI deleteSession={async () => { await new Promise((resolve) => setTimeout(resolve, 1000)); }} prefillSearch={props.prefillSearch} - sessions={createRandomSessions(props.length)} + sessions={sessions} team_slug="team-1" project={projectStub("xxxxx", "team-1")} /> </ChatPageLayout> ); }
🧹 Nitpick comments (20)
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/data/examplePrompts.ts (3)
7-7: Rename variable to match the “thirdweb AI” rebrandKeep identifiers consistent with the copy; drop “Nebula”.
-const whatCanNebulaDoReply = ` +const whatCanThirdwebAIDoReply = ` @@ - interceptedReply: whatCanNebulaDoReply, + interceptedReply: whatCanThirdwebAIDoReply,Also applies to: 85-85
7-82: Consider lazy-loading these large reply strings to trim the client bundleThese multiline Markdown blobs will ship to the client wherever this module is imported. Split them into a separate chunk and dynamically import in the empty‑state component.
Example in the consumer:
// inside EmptyStateChatPageContent const { examplePrompts } = await import("../data/examplePrompts"); // on demand
9-9: Centralize the docs URLMove the portal URL to a shared constants/config to avoid future link drift and ease updates.
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/page.tsx (3)
1-2: Add the server-only guard.Mark this page as a Server Component explicitly to prevent accidental client import.
+import "server-only"; import { notFound } from "next/navigation"; import { getAuthToken, getUserThirdwebClient } from "@/api/auth-token";
8-10: Annotate the return type.Match our TS guideline for explicit return types on exported functions.
export default async function Page(props: { params: Promise<{ team_slug: string; project_slug: string }>; -}) { +}): Promise<JSX.Element> {
26-29: Fetch client and sessions concurrently to cut TTFB.Both depend only on
project; parallelize:- const client = await getUserThirdwebClient({ teamId: project.teamId }); - - const sessions = await getSessions({ project }).catch(() => []); + const [client, sessions] = await Promise.all([ + getUserThirdwebClient({ teamId: project.teamId }), + getSessions({ project }).catch(() => []), + ]);apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.stories.tsx (2)
27-27: Gitleaks false positives: these are public Ethereum token addresses, not secrets.Silence CI noise by allow-listing the address pattern in Gitleaks config.
Add to
.gitleaks.toml:[allowlist] description = "Allow public Ethereum addresses in stubs/stories" regexes = [ '''(?i)\b0x[a-f0-9]{40}\b''' ]Alternatively, scope it by path (stories/fixtures) if you prefer stricter matching.
Also applies to: 35-35, 43-43, 51-51, 59-59
9-11: Nit: prefer design-token widths over arbitrarymax-w-[300px].Use a tokenized width like
max-w-xs(20rem) orw-80for consistency with the design system.- <div className="mx-auto h-[100dvh] w-full max-w-[300px] bg-card p-2"> + <div className="mx-auto h-[100dvh] w-full max-w-xs bg-card p-2">apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/TransactionsSection/TransactionsSection.stories.tsx (4)
8-18: Hoist default args (client) into meta to DRY the stories.Removes duplication and reduces drift risk.
const meta = { component: TransactionSectionUI, decorators: [ (Story) => ( <div className="mx-auto h-dvh w-full max-w-[300px] bg-card p-2"> <Story /> </div> ), ], + args: { + client: storybookThirdwebClient, + }, title: "AI/TransactionsSection", } satisfies Meta<typeof TransactionSectionUI>;
23-23: Usesatisfiesfor tighter type conformance without widening.Prevents accidental extra keys while keeping literal types for better DX.
-const transactionsStub: WalletTransaction[] = [ +const transactionsStub = [ @@ -]; +] satisfies WalletTransaction[];Also applies to: 121-121
40-50: Prefer omittingdecodedentirely when unknown instead of empty strings.Empty strings are ambiguous;
decodedbeing absent better reflects “no decoding available” and exercises that UI path.{ chain_id: "8453", - decoded: { - inputs: null, - name: "", - signature: "", - }, from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37", hash: "0xc521bfa0ba3e68fa1a52c67f93a8e215d3ade0b45956ba215390bcc0576202f1", to_address: "0x83dd93fa5d8343094f850f90b3fb90088c1bb425", value: "1000000000000000", },
123-129: Drop repeatedclientargs in individual stories after hoisting to meta.Keeps stories concise and consistent.
export const MultipleAssets: Story = { args: { - client: storybookThirdwebClient, data: transactionsStub, isPending: false, }, }; export const SingleAsset: Story = { args: { - client: storybookThirdwebClient, data: transactionsStub.slice(0, 1), isPending: false, }, }; export const EmptyAssets: Story = { args: { - client: storybookThirdwebClient, data: [], isPending: false, }, }; export const Loading: Story = { args: { - client: storybookThirdwebClient, data: [], isPending: true, }, };Also applies to: 131-137, 139-145, 147-153
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ExecuteTransactionCard.tsx (1)
1-20: Expose className on root and merge with cn() per dashboard TSX rulesLets parents control outer spacing/layout.
+import { cn } from "@/lib/utils"; @@ -export function ExecuteTransactionCard(props: { +export function ExecuteTransactionCard(props: { txData: NebulaTxData; client: ThirdwebClient; onTxSettled: (txHash: string) => void; -}) { + className?: string; +}) { @@ <ExecuteTransactionCardLayout client={props.client} onTxSettled={props.onTxSettled} + className={props.className} sendTx={sendTx} setStatus={setStatus} status={status} txData={props.txData} /> @@ -export function ExecuteTransactionCardLayout(props: { +export function ExecuteTransactionCardLayout(props: { txData: NebulaTxData; client: ThirdwebClient; status: TxStatus; setStatus: (status: TxStatus) => void; onTxSettled: (txHash: string) => void; sendTx: ( tx: PreparedTransaction, onTxSettled: (txHash: string) => void, ) => Promise<void>; -}) { + className?: string; +}) { @@ - return ( - <div> + return ( + <div className={cn(props.className)}>Also applies to: 21-38, 40-51, 55-63
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ExecuteTransactionCard.stories.tsx (1)
12-16: Pass client to ThirdwebProvider in storiesPrevents context-dependent issues.
- (Story) => ( - <ThirdwebProvider> + (Story) => ( + <ThirdwebProvider client={storybookThirdwebClient}> <Story /> </ThirdwebProvider> ),apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.tsx (1)
7-13: Consider lazy-loading Fuse.js to reduce initial bundleFuse is relatively heavy; defer loading until needed (when there are sessions or a query).
Example pattern:
// replace static import // import Fuse from "fuse.js"; const [FuseCtor, setFuseCtor] = useState<typeof import("fuse.js")["default"] | null>(null); useEffect(() => { import("fuse.js").then((m) => setFuseCtor(m.default)); }, []); const fuse = useMemo(() => { if (!FuseCtor) return null; return new FuseCtor(allSessions, { keys: [{ name: "title", weight: 1 }], threshold: 0.5 }); }, [allSessions, FuseCtor]); const filtered = useMemo(() => (searchVal && fuse ? fuse.search(searchVal).map((e) => e.item) : allSessions), [searchVal, fuse, allSessions]);Also applies to: 59-69
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatSidebar.tsx (5)
2-9: Use NavLink for internal navigation to get active statesPer dashboard rules, prefer NavLink for internal routes. Keep Link for the external docs.
+import { NavLink } from "@/components/ui/NavLink"; import Link from "next/link";- <Link + <NavLink + activeClassName="underline" className="flex items-center gap-1 rounded-full text-foreground text-sm hover:underline justify-between" href={`/team/${props.team_slug}/${props.project.slug}/ai/analytics`} - > + > <div className="flex items-center gap-2"> <ChartLineIcon className="size-3.5 text-muted-foreground" /> Analytics </div> <ChevronRightIcon className="size-3.5 text-muted-foreground" /> - </Link> + </NavLink>- <Link + <NavLink + activeClassName="underline" className="flex items-center gap-1 rounded-full text-foreground text-xs hover:underline" href={`/team/${props.team_slug}/${props.project.slug}/ai/chat/history`} - > + > View All <ChevronRightIcon className="size-3.5 text-muted-foreground" /> - </Link> + </NavLink>Also applies to: 86-95, 114-120
38-44: Add explicit return types for TSX componentsFollow TS guideline: explicit return types help with inference and future refactors.
-export function ChatSidebar(props: { ... }) { +export function ChatSidebar(props: { ... }): JSX.Element {-function WalletDetails(props: { client: ThirdwebClient }) { +function WalletDetails(props: { client: ThirdwebClient }): JSX.Element {-function CustomConnectButton(props: { client: ThirdwebClient }) { +function CustomConnectButton(props: { client: ThirdwebClient }): JSX.Element {Also applies to: 147-151, 201-205
170-196: Remove redundant isExpanded check inside isExpanded blockThe inner {isExpanded && ...} is nested within another isExpanded, so it’s dead code. Also add an id to hook up aria-controls.
- {isExpanded && ( - <div className="h-[230px] overflow-y-auto py-4 pr-1 pl-2"> - {tab === "assets" && <AssetsSection client={props.client} />} - - {tab === "transactions" && ( - <TransactionsSection client={props.client} /> - )} - </div> - )} + <div + className="h-[230px] overflow-y-auto py-4 pr-1 pl-2" + id="wallet-details-content" + > + {tab === "assets" && <AssetsSection client={props.client} />} + {tab === "transactions" && ( + <TransactionsSection client={props.client} /> + )} + </div>
67-76: Avoid full page reload; force re-mount via search paramPrevent a jarring reload and preserve app state by replacing with a same-path navigation that changes search params.
onClick={() => { if (pathname === newChatPage) { - // TODO - make it not reload the whole page, but just the React session state of the chat component - window.location.reload(); + // Force a re-mount without a full reload + router.replace(`${newChatPage}?r=${Date.now()}`); } else { router.push(newChatPage); } }}
154-164: Improve a11y for collapse toggleExpose expanded state and control relationship.
- <Button + <Button className={cn( "h-auto w-auto p-1.5 transition-transform duration-300", isExpanded ? "rotate-180" : "", )} onClick={() => setIsExpanded(!isExpanded)} + aria-expanded={isExpanded} + aria-controls="wallet-details-content" size="sm" variant="ghost" > <ChevronDownIcon className="size-4" /> </Button>Also applies to: 186-193
| import { deleteSession } from "../../api/session"; | ||
| import type { TruncatedSessionInfo } from "../../api/types"; | ||
| import { useSessionsWithLocalOverrides } from "../../hooks/useSessionsWithLocalOverrides"; | ||
| import { deletedSessionsStore } from "../../stores"; | ||
|
|
||
| export function ChatHistoryPage(props: { | ||
| sessions: TruncatedSessionInfo[]; | ||
| prefillSearch?: string; | ||
| team_slug: string; | ||
| project: Project; | ||
| }) { | ||
| return ( | ||
| <ChatHistoryPageUI | ||
| {...props} | ||
| deleteSession={async (s) => { | ||
| await deleteSession({ | ||
| project: props.project, | ||
| sessionId: s, | ||
| }); | ||
| }} | ||
| /> | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Move session deletion off the client; avoid importing tokened API here
This is a client module. Proxy via an internal route to keep JWT server-side.
-import { deleteSession } from "../../api/session";
+// do not import tokened server API in client components
@@
export function ChatHistoryPage(props: {
@@
return (
<ChatHistoryPageUI
{...props}
- deleteSession={async (s) => {
- await deleteSession({
- project: props.project,
- sessionId: s,
- });
- }}
+ deleteSession={async (s) => {
+ const res = await fetch(
+ `/api/thirdweb-ai/session/${encodeURIComponent(s)}`,
+ { method: "DELETE" },
+ );
+ if (!res.ok) throw new Error("Failed to delete session");
+ await res.json();
+ }}
/>
);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| import { deleteSession } from "../../api/session"; | |
| import type { TruncatedSessionInfo } from "../../api/types"; | |
| import { useSessionsWithLocalOverrides } from "../../hooks/useSessionsWithLocalOverrides"; | |
| import { deletedSessionsStore } from "../../stores"; | |
| export function ChatHistoryPage(props: { | |
| sessions: TruncatedSessionInfo[]; | |
| prefillSearch?: string; | |
| team_slug: string; | |
| project: Project; | |
| }) { | |
| return ( | |
| <ChatHistoryPageUI | |
| {...props} | |
| deleteSession={async (s) => { | |
| await deleteSession({ | |
| project: props.project, | |
| sessionId: s, | |
| }); | |
| }} | |
| /> | |
| ); | |
| // do not import tokened server API in client components | |
| import type { TruncatedSessionInfo } from "../../api/types"; | |
| import { useSessionsWithLocalOverrides } from "../../hooks/useSessionsWithLocalOverrides"; | |
| import { deletedSessionsStore } from "../../stores"; | |
| export function ChatHistoryPage(props: { | |
| sessions: TruncatedSessionInfo[]; | |
| prefillSearch?: string; | |
| team_slug: string; | |
| project: Project; | |
| }) { | |
| return ( | |
| <ChatHistoryPageUI | |
| {...props} | |
| deleteSession={async (s) => { | |
| const res = await fetch( | |
| `/api/thirdweb-ai/session/${encodeURIComponent(s)}`, | |
| { method: "DELETE" }, | |
| ); | |
| if (!res.ok) throw new Error("Failed to delete session"); | |
| await res.json(); | |
| }} | |
| /> | |
| ); | |
| } |
| export function ChatSidebar(props: { | ||
| sessions: TruncatedSessionInfo[]; | ||
| team_slug: string; | ||
| project: Project; | ||
| client: ThirdwebClient; | ||
| type: "desktop" | "mobile"; | ||
| }) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Expose className on root and merge with cn()
Dashboard components should accept a className and apply it on the root. Add the prop and merge classes.
-export function ChatSidebar(props: {
+export function ChatSidebar(props: {
sessions: TruncatedSessionInfo[];
team_slug: string;
project: Project;
client: ThirdwebClient;
type: "desktop" | "mobile";
-}) {
+ className?: string;
+}): JSX.Element {
const sessions = useSessionsWithLocalOverrides(props.sessions);- <div className="flex h-full flex-col">
+ <div className={cn("flex h-full flex-col", props.className)}>Also applies to: 51-52
🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatSidebar.tsx
around lines 38-44 (and also update the prop types at lines 51-52), add an
optional className?: string to the component props, then merge it with the
component's existing classes using the cn(...) utility and apply the result to
the root/outermost JSX element; ensure the prop is optional, forwarded/used when
present, and that any existing hardcoded class strings are combined with the
incoming className via cn so external styling can be applied.
| type: "desktop" | "mobile"; | ||
| }) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Unused prop ‘type’ ("desktop" | "mobile")
It isn’t used in this component. If not consumed by callers, drop it; otherwise, consider using it (e.g., default collapsed on mobile).
Run to confirm external usage before removal:
🏁 Script executed:
#!/bin/bash
# Find ChatSidebar call sites and look for a `type` prop usage.
rg -nP --type tsx -C2 '\b<ChatSidebar\b' | sed -n '1,200p'
rg -nP --type tsx '\btype\s*=\s*"(desktop|mobile)"'Length of output: 55
🏁 Script executed:
#!/bin/bash
# Search for any ChatSidebar usage in TSX/JSX files
rg -n '<ChatSidebar' -g '*.{tsx,jsx}' -C3
# Search for explicit type="desktop" or type="mobile" assignments
rg -n 'type\s*=\s*"(desktop|mobile)"' -g '*.{tsx,jsx}'Length of output: 5392
Remove unused type prop from ChatSidebar component signature
The type prop isn’t used inside this component and no callers in apps/dashboard pass it; remove it from the props definition.
🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatSidebar.tsx
around lines 43-44, the component signature includes an unused prop `type:
"desktop" | "mobile"`; remove `type` from the props parameter and any related
type annotations in this file so the component only accepts the props it
actually uses, and update the component’s prop type/interface definition
accordingly (also run a quick search to confirm no internal references to `type`
remain).
8cfc3cb to
1f6c03e
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
♻️ Duplicate comments (23)
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.tsx (4)
1-1: Add 'use client' (hooks are used in this file).Without it, Next will treat this as a Server Component and crash at runtime/build.
+'use client'; + import { ArrowRightLeftIcon, CheckIcon } from "lucide-react";
42-47: Guard prepareTransaction and disable the Swap button until chain is ready.txChain can be undefined; calling prepareTransaction with an undefined chain will throw. Also await sendTx and pass txChain?.id to TransactionButton.
const txChain = useV5DashboardChain(swapData.transaction.chain_id); @@ <TransactionButton className="gap-2" client={props.client} - disabled={ - props.status.type === "sending" || - props.status.type === "confirming" - } + disabled={ + !txChain || + props.status.type === "sending" || + props.status.type === "confirming" + } isLoggedIn={true} isPending={props.status.type === "sending"} onClick={async () => { + if (!txChain) { + return; + } const tx = prepareTransaction({ chain: txChain, client: props.client, data: swapData.transaction.data, value: swapData.transaction.value ? BigInt(swapData.transaction.value) : undefined, extraGas: 50000n, erc20Value: isSellingNativeToken ? undefined : { amountWei: BigInt(swapData.from_token.amount), tokenAddress: swapData.from_token.address, }, to: swapData.transaction.to, }); - props.sendTx(tx); + await props.sendTx(tx); }} size="sm" transactionCount={undefined} - txChainID={swapData.transaction.chain_id} + txChainID={txChain?.id ?? swapData.transaction.chain_id} variant="default" >Also applies to: 92-103, 104-123, 126-127
164-168: Mirror the same chain-ready guards for Approve.Avoid prepareTransaction with an undefined chain; await sendTx; pass txChain?.id.
const isTransactionPending = props.status.type === "sending" || props.status.type === "confirming"; @@ <TransactionButton className="gap-2" client={props.client} - disabled={isTransactionPending} + disabled={!txChain || isTransactionPending} isLoggedIn={true} isPending={isTransactionPending} onClick={async () => { + if (!txChain) { + return; + } const tx = prepareTransaction({ chain: txChain, client: props.client, data: swapData.transaction.data, to: swapData.transaction.to, }); - props.sendTx(tx); + await props.sendTx(tx); }} size="sm" transactionCount={undefined} - txChainID={swapData.transaction.chain_id} + txChainID={txChain?.id ?? swapData.transaction.chain_id} variant="default" >Also applies to: 217-224, 225-234, 237-238
258-276: Avoid undefined access: guard chain metadata in TokenRow.chain may be undefined while chains load; chain.icon/name access will throw.
const chain = useV5DashboardChain(props.chainId); @@ <ChainIconClient className="size-4" client={props.client} - src={chain.icon?.url || ""} + src={chain?.icon?.url} /> - <div>{chain.name}</div> + <div>{chain?.name ?? `Chain ${props.chainId}`}</div>apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.stories.tsx (2)
9-11: Replace non-standardh-dvhwith supported utilityTailwind may not have
h-dvhconfigured. Useh-[100dvh](or your DVH token).- <div className="mx-auto h-dvh w-full max-w-[300px] bg-card p-2"> + <div className="mx-auto h-[100dvh] w-full max-w-[300px] bg-card p-2">
2-2: Use a stubbed Storybook client (no network in stories)Make sure
storybookThirdwebClientis a stub/mocked client so stories never hit live APIs. If the version in@/storybook/utilsuses the real SDK, swap to a stub.Example change (adjust path to your existing stub):
-import { storybookThirdwebClient } from "@/storybook/utils"; +import { storybookThirdwebClient } from "@/storybook/stubs";apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.tsx (1)
213-245: Type queries, gate execution, and setstaleTimeAligns with guidelines and reduces refetch churn.
- const nativeBalances = useQuery({ - queryFn: async () => { + const nativeBalances = useQuery<AssetBalance[]>({ + enabled: !!account && !!activeChain, + staleTime: 60_000, + queryFn: async () => { if (!account || !activeChain) { return []; } @@ - queryKey: ["getWalletBalance", account?.address, activeChain?.id], + queryKey: ["getWalletBalance", account?.address, activeChain?.id], });apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/data/examplePrompts.ts (1)
1-5: Reuse the shared ExamplePrompt type from the chat types barrelAvoid duplicate shapes; import the canonical type.
-type ExamplePrompt = { - title: string; - message: string; - interceptedReply?: string; -}; +import type { ExamplePrompt } from "@/components/chat/types";apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/chat.ts (1)
11-19: Critical: JWT is exposed to the browser; proxy the stream via an internal routeThis function is used by client code; passing
authTokenand settingAuthorizationhere leaks the JWT. Move the upstream call behind a Next.js route that reads the token server-side, then call that route from the client.-export async function promptNebula(params: { +export async function promptNebula(params: { message: NebulaUserMessage; sessionId: string; - authToken: string; handleStream: (res: ChatStreamedResponse) => void; abortController: AbortController; context: undefined | NebulaContext; project: Project; -}) { +}): Promise<void> { @@ - const events = await stream(`${NEXT_PUBLIC_THIRDWEB_AI_HOST}/chat`, { + const events = await stream(`/api/thirdweb-ai/chat`, { body: JSON.stringify(body), headers: { - Authorization: `Bearer ${params.authToken}`, "x-team-id": params.project.teamId, "x-client-id": params.project.publishableKey, "Content-Type": "application/json", },Example route (new file) to add server-side:
// apps/dashboard/src/app/api/thirdweb-ai/chat/route.ts import "server-only"; import { NextRequest } from "next/server"; import { getAuthToken } from "@/api/auth-token"; import { NEXT_PUBLIC_THIRDWEB_AI_HOST } from "@/constants/public-envs"; export async function POST(req: NextRequest) { const token = await getAuthToken(); if (!token) return new Response("unauthorized", { status: 401 }); const upstream = await fetch(`${NEXT_PUBLIC_THIRDWEB_AI_HOST}/chat`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "content-type": "application/json", "x-team-id": req.headers.get("x-team-id") ?? "", "x-client-id": req.headers.get("x-client-id") ?? "", }, body: await req.text(), }); return new Response(upstream.body, { status: upstream.status, headers: { "content-type": upstream.headers.get("content-type") ?? "text/event-stream", }, }); }Also applies to: 38-41
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.tsx (1)
25-29: Do not call tokened server utilities from the client; delete session via an internal API routeImporting
../../api/sessionhere risks leaking auth details and won’t work in the browser. Proxy the DELETE through/api/thirdweb-ai/session/[id].-import { deleteSession } from "../../api/session"; @@ return ( <ChatHistoryPageUI {...props} deleteSession={async (s) => { - await deleteSession({ - project: props.project, - sessionId: s, - }); + const res = await fetch( + `/api/thirdweb-ai/session/${encodeURIComponent(s)}`, + { method: "DELETE" }, + ); + if (!res.ok) { + throw new Error("Failed to delete session"); + } }} /> );Example API route to add:
// apps/dashboard/src/app/api/thirdweb-ai/session/[id]/route.ts import "server-only"; import { NEXT_PUBLIC_THIRDWEB_AI_HOST } from "@/constants/public-envs"; import { getAuthToken } from "@/api/auth-token"; import { NextRequest } from "next/server"; export async function DELETE( _req: NextRequest, { params }: { params: { id: string } }, ) { const token = await getAuthToken(); if (!token) return new Response("unauthorized", { status: 401 }); const upstream = await fetch( `${NEXT_PUBLIC_THIRDWEB_AI_HOST}/session/${encodeURIComponent(params.id)}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` } }, ); return new Response(await upstream.text(), { status: upstream.status }); }Also applies to: 39-45
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/NebulaImage.tsx (1)
1-3: Missing 'use client' directiveThis component uses hooks; add the client directive at the top.
+'use client'; + import { useMutation } from "@tanstack/react-query";apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.stories.tsx (2)
71-90: Create sessions once and pass to both layout and UI; add explicit return type.Prevents sidebar/content desync and satisfies the “explicit return types” guideline.
-function Variant(props: { length: number; prefillSearch?: string }) { +function Variant(props: { length: number; prefillSearch?: string }): JSX.Element { - return ( + const sessions = createRandomSessions(props.length); + return ( <ChatPageLayout accountAddress="0x1234567890" authToken="xxxxxxxx" - sessions={createRandomSessions(props.length)} + sessions={sessions} project={projectStub("xxxxx", "team-1")} client={storybookThirdwebClient} team_slug="team-1" > <ChatHistoryPageUI deleteSession={async () => { await new Promise((resolve) => setTimeout(resolve, 1000)); }} prefillSearch={props.prefillSearch} - sessions={createRandomSessions(props.length)} + sessions={sessions} team_slug="team-1" project={projectStub("xxxxx", "team-1")} /> </ChatPageLayout> ); }
13-15: Pass a client to ThirdwebProvider (story otherwise breaks runtime).Provider requires a client; use the story’s stubbed client.
- <ThirdwebProvider> + <ThirdwebProvider client={storybookThirdwebClient}> <Story /> </ThirdwebProvider>apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageContent.tsx (1)
159-166: Guard optional message text to prevent runtime crash on image-only user messages.
.toLowerCase()can be invoked on undefined.- const lowerCaseMessage = message.content - .find((x) => x.type === "text") - ?.text.toLowerCase(); + const lowerCaseMessage = message.content + .find((x) => x.type === "text") + ?.text + ?.toLowerCase();apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Chats.tsx (3)
90-110: Add effect cleanup for event listeners (prevent leaks).useEffect(() => { if (!enableAutoScroll) { return; } @@ chatScrollContainer.addEventListener("mousedown", disableScroll); chatScrollContainer.addEventListener("wheel", disableScroll); + return () => { + chatScrollContainer.removeEventListener("mousedown", disableScroll); + chatScrollContainer.removeEventListener("wheel", disableScroll); + }; }, [setEnableAutoScroll, enableAutoScroll]);
1-4: Mark as Client Component.This file uses React hooks; add the directive.
+ 'use client'; import { MarkdownRenderer } from "@workspace/ui/components/markdown-renderer";
66-74: Remove unused authToken prop (avoid exposing secrets to UI).Not used here and should not flow through UI components.
isChatStreaming: boolean; - authToken: string; sessionId: string | undefined; @@ - <RenderMessage + <RenderMessage project={props.project} - authToken={props.authToken} client={props.client} @@ -function RenderMessage(props: { +function RenderMessage(props: { message: ChatMessage; isMessagePending: boolean; client: ThirdwebClient; sendMessage: (message: NebulaUserMessage) => void; nextMessage: ChatMessage | undefined; - authToken: string; sessionId: string | undefined; project: Project; }) { @@ - <RenderResponse + <RenderResponse project={props.project} - authToken={props.authToken} client={props.client} @@ -function RenderResponse(props: { +function RenderResponse(props: { message: ChatMessage; isMessagePending: boolean; client: ThirdwebClient; sendMessage: (message: NebulaUserMessage) => void; nextMessage: ChatMessage | undefined; sessionId: string | undefined; - authToken: string; project: Project; }) {Also applies to: 146-155, 259-266, 173-176, 287-296
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatBar.tsx (6)
56-60: Rename brand in toast: “Nebula” → “thirdweb”.function showSigninToUploadImagesToast() { - toast.error("Sign in to upload images to Nebula", { + toast.error("Sign in to upload images to thirdweb", { position: "top-right", }); }
374-376: Rename brand in popover copy: “Nebula” → “thirdweb”.- Get access to image uploads by signing in to Nebula + Get access to image uploads by signing in to thirdweb
537-541: Avoid mutating props when sorting wallets.Clone before sorting.
- const sortedWallets = props.wallets.sort((a, b) => { + const sortedWallets = [...props.wallets].sort((a, b) => { if (a.walletId === "smart") return -1; if (b.walletId === "smart") return 1; return 0; });
176-199: Fix drag-over highlight getting stuck and adjust contains() typing.onDragEnter={(e) => { e.preventDefault(); - setIsDragOver(true); - if (!props.allowImageUpload) { - return; - } + if (!props.allowImageUpload) return; + setIsDragOver(true); }} onDragLeave={(e) => { e.preventDefault(); - if (!props.allowImageUpload) { - return; - } // Only set drag over to false if we're leaving the container entirely - if (!e.currentTarget.contains(e.relatedTarget as Node)) { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setIsDragOver(false); } }} onDragOver={(e) => { e.preventDefault(); - setIsDragOver(true); - if (!props.allowImageUpload) { - return; - } + if (!props.allowImageUpload) return; + setIsDragOver(true); }}
232-236: Prevent Enter from sending empty messages (no text and no images).- if (e.key === "Enter" && !props.isChatStreaming) { + if (e.key === "Enter" && !props.isChatStreaming) { e.preventDefault(); - handleSubmit(message); + if (message.trim() !== "" || images.length > 0) { + handleSubmit(message); + } }
95-101: Send raw base64, not a full data URL, in image content.Shrinks payloads and matches typical API schemas for b64 fields.
- userMessage.content.push({ - b64: image.b64, + const dataUrl = image.b64; + const base64 = dataUrl.includes(",") ? dataUrl.split(",")[1] : dataUrl; + userMessage.content.push({ + b64: base64, image_url: null, type: "image", });
🧹 Nitpick comments (22)
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.tsx (5)
16-20: Add explicit return types to exported components.Matches our TS guidelines and improves API clarity.
-export function SwapTransactionCard(props: { +export function SwapTransactionCard(props: { swapData: NebulaSwapData; client: ThirdwebClient; onTxSettled: (txHash: string) => void; -}) { +}): JSX.Element { @@ -export function SwapTransactionCardLayout(props: { +export function SwapTransactionCardLayout(props: { swapData: NebulaSwapData; client: ThirdwebClient; status: TxStatus; setStatus: (status: TxStatus) => void; sendTx: (tx: PreparedTransaction) => Promise<void>; -}) { +}): JSX.Element { @@ -export function ApproveTransactionCard(props: { +export function ApproveTransactionCard(props: { swapData: NebulaSwapData; client: ThirdwebClient; -}) { +}): JSX.Element { @@ -export function ApproveTransactionCardLayout(props: { +export function ApproveTransactionCardLayout(props: { swapData: NebulaSwapData; client: ThirdwebClient; status: TxStatus; setStatus: (status: TxStatus) => void; sendTx: (tx: PreparedTransaction) => Promise<void>; -}) { +}): JSX.Element { @@ -function TokenRow(props: { +function TokenRow(props: { amount: string; symbol: string; chainId: number; client: ThirdwebClient; title: string; decimals: number; -}) { +}): JSX.Element {Also applies to: 34-41, 139-144, 156-163, 250-257
16-20: Expose className on root and merge with cn().Required by our dashboard .tsx guideline; keeps components composable.
+import { cn } from "@/lib/utils"; @@ -export function SwapTransactionCard(props: { +export function SwapTransactionCard(props: { swapData: NebulaSwapData; client: ThirdwebClient; onTxSettled: (txHash: string) => void; -}): JSX.Element { + className?: string; +}): JSX.Element { @@ - <SwapTransactionCardLayout + <SwapTransactionCardLayout client={props.client} sendTx={(tx) => sendTx(tx, props.onTxSettled)} - setStatus={setStatus} + setStatus={setStatus} status={status} swapData={props.swapData} + className={props.className} /> @@ -export function SwapTransactionCardLayout(props: { +export function SwapTransactionCardLayout(props: { swapData: NebulaSwapData; client: ThirdwebClient; status: TxStatus; setStatus: (status: TxStatus) => void; sendTx: (tx: PreparedTransaction) => Promise<void>; -}): JSX.Element { + className?: string; +}): JSX.Element { @@ - return ( - <div className="max-w-lg"> + return ( + <div className={cn("max-w-lg", props.className)}> @@ -export function ApproveTransactionCard(props: { +export function ApproveTransactionCard(props: { swapData: NebulaSwapData; client: ThirdwebClient; -}): JSX.Element { + className?: string; +}): JSX.Element { @@ - <ApproveTransactionCardLayout + <ApproveTransactionCardLayout client={props.client} sendTx={(tx) => sendTx(tx, undefined)} - setStatus={setStatus} + setStatus={setStatus} status={status} swapData={props.swapData} + className={props.className} /> @@ -export function ApproveTransactionCardLayout(props: { +export function ApproveTransactionCardLayout(props: { swapData: NebulaSwapData; client: ThirdwebClient; status: TxStatus; setStatus: (status: TxStatus) => void; sendTx: (tx: PreparedTransaction) => Promise<void>; -}): JSX.Element { + className?: string; +}): JSX.Element { @@ - return ( - <div className="max-w-lg"> + return ( + <div className={cn("max-w-lg", props.className)}>Also applies to: 34-41, 49-50, 139-144, 156-163, 170-171
34-41: Remove unused setStatus prop from Layouts.It’s not referenced inside either Layout; simplify the API.
-export function SwapTransactionCardLayout(props: { +export function SwapTransactionCardLayout(props: { swapData: NebulaSwapData; client: ThirdwebClient; status: TxStatus; - setStatus: (status: TxStatus) => void; sendTx: (tx: PreparedTransaction) => Promise<void>; className?: string; }): JSX.Element { @@ - setStatus={setStatus} status={status} @@ -export function ApproveTransactionCardLayout(props: { +export function ApproveTransactionCardLayout(props: { swapData: NebulaSwapData; client: ThirdwebClient; status: TxStatus; - setStatus: (status: TxStatus) => void; sendTx: (tx: PreparedTransaction) => Promise<void>; className?: string; }): JSX.Element { @@ - setStatus={setStatus} status={status}Also applies to: 146-153, 156-163
259-266: Verify amount semantics; BigInt will throw for decimal strings.Confirm from_token.amount/to_token.amount are atomic-unit integers. If they may contain decimals, parse units first.
Example fallback:
- const tokenDisplayValue = toTokens(BigInt(props.amount), props.decimals); + const amountWei = + props.amount.includes(".") + ? /* parse to wei */ BigInt( + (Number.parseFloat(props.amount) * 10 ** props.decimals).toFixed(0), + ) + : BigInt(props.amount); + const tokenDisplayValue = toTokens(amountWei, props.decimals);
44-47: Broaden native-token detection (optional).Some sources use zero-address for native tokens; consider treating both sentinel and zero-address as native.
- const isSellingNativeToken = - getAddress(swapData.from_token.address) === - getAddress(NATIVE_TOKEN_ADDRESS); + const fromAddr = getAddress(swapData.from_token.address); + const isSellingNativeToken = + fromAddr === getAddress(NATIVE_TOKEN_ADDRESS) || + fromAddr === getAddress("0x0000000000000000000000000000000000000000");apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.stories.tsx (1)
20-69: Gitleaks “Generic API Key” alerts are false positives (token addresses)These
0x...values are public contract addresses in stub data, not secrets. Add an allowlist to silence noise in CI.Add to your gitleaks config (example):
# .gitleaks.toml [[rules.allowlist.regexes]] description = "Allow Ethereum-like addresses in story stubs" regex = '''0x[a-fA-F0-9]{40}'''Or exclude Storybook stubs path in
allowlist.paths.apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.tsx (2)
146-154: Pass throughclassNamefrom container to UIExpose
classNameonAssetsSectionand forward it toAssetsSectionUI.-export function AssetsSection(props: { client: ThirdwebClient }) { +export function AssetsSection(props: { client: ThirdwebClient; className?: string }) { @@ return ( - <AssetsSectionUI client={props.client} data={data} isPending={isPending} /> + <AssetsSectionUI + client={props.client} + data={data} + isPending={isPending} + className={props.className} + /> ); }Also applies to: 249-253
200-209: Optional: move sorting toselectfor memoized derivationUsing React Query’s
selectavoids re-sorting on re-renders when the fetched array identity is stable.Example:
const assetsQuery = useQuery<AssetBalance[]>({ // ... select: (data) => data.slice().sort(sortByTopTokens), });apps/dashboard/src/@/constants/public-envs.ts (1)
33-34: Normalize trailing slash to avoid accidental double slashes in requestsIf env includes a trailing slash,
${host}/chatbecomes//chat. Normalize once at definition.-export const NEXT_PUBLIC_THIRDWEB_AI_HOST = - process.env.NEXT_PUBLIC_THIRDWEB_AI_HOST || "https://nebula-api.thirdweb.com"; +const _AI_HOST = + process.env.NEXT_PUBLIC_THIRDWEB_AI_HOST || "https://nebula-api.thirdweb.com"; +export const NEXT_PUBLIC_THIRDWEB_AI_HOST = _AI_HOST.replace(/\/+$/, "");apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/data/examplePrompts.ts (1)
7-8: Consistency: rename Nebula-era identifierVariable name still says “Nebula”. Optional rename for clarity with the new branding.
-const whatCanNebulaDoReply = ` +const whatCanThirdwebAIDoReply = ` @@ - interceptedReply: whatCanNebulaDoReply, + interceptedReply: whatCanThirdwebAIDoReply,Also applies to: 85-88
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/chat.ts (3)
20-33: Strongly type the request body and coerce chain IDs to numbersPrevents schema drift and aligns with codebase TS guidelines.
- const body: Record<string, string | boolean | object> = { + type ChatRequestBody = { + messages: NebulaUserMessage[]; + session_id: string; + stream: true; + context?: { + chain_ids: number[]; + networks: NebulaContext["networks"]; + wallet_address: string | null; + }; + }; + + const body: ChatRequestBody = { messages: [params.message], session_id: params.sessionId, stream: true, }; if (params.context) { - body.context = { - chain_ids: params.context.chainIds || [], - networks: params.context.networks, - wallet_address: params.context.walletAddress, - }; + body.context = { + chain_ids: (params.context.chainIds ?? []) + .map((x) => Number(x)) + .filter((n) => Number.isFinite(n)), + networks: params.context.networks, + wallet_address: params.context.walletAddress ?? null, + }; }
184-194: Abort semantics: don’t surface an error when the user cancelsIf the request was intentionally aborted, skip emitting an error event.
- } catch (error) { - console.error("failed to stream events", error); - params.handleStream({ + } catch (error) { + if (params.abortController.signal.aborted) { + return; + } + console.error("failed to stream events", error); + params.handleStream({ data: { code: 500, errorMessage: `Failed to stream events: ${error instanceof Error ? error.message : "Unknown error"}`, }, event: "error", }); params.abortController.abort(); }
55-62: Harden per-event parsing to avoid stream teardown on bad JSONWrap
JSON.parseper branch; a single malformed event currently aborts the entire stream.If you want, I can push a patch that adds small try/catch blocks around each parse and logs the offending event without breaking the loop.
Also applies to: 65-71, 73-89, 91-121, 125-141, 143-153, 155-173
apps/dashboard/src/@/components/ui/image-upload-button.tsx (2)
7-15: Expose wrapper className and allow same-file reselect
- Add
wrapperClassNamefor styling the root element (per dashboard rules), keep existingclassNamefor the Button.- Clear input value after change so selecting the same file twice triggers onChange.
import { useRef } from "react"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; interface ImageUploadProps { value: File | undefined; onChange?: (files: File[]) => void; children?: React.ReactNode; variant?: React.ComponentProps<typeof Button>["variant"]; className?: string; + wrapperClassName?: string; multiple?: boolean; accept: string; } @@ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const files = Array.from(e.target.files || []); props.onChange?.(files); + // allow selecting the same file again + e.target.value = ""; }; return ( - <div> + <div className={cn(props.wrapperClassName)}> <Button className={props.className} onClick={() => fileInputRef.current?.click()} variant={props.variant} > {props.children} </Button>Also applies to: 25-43
7-12: Dead prop:valueis unusedIf not needed by consumers, drop it to keep the API minimal. Otherwise, document its behavior.
Want me to scan usage to confirm it’s safe to remove?
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageLayout.tsx (1)
13-14: Unused prop:accountAddressNot read in this component. Consider removing to keep the surface lean.
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.tsx (1)
191-201: Key stabilityKeying by
id + updated_at + created_atcauses unnecessary re-mounts. Prefersession.id.- key={session.id + session.updated_at + session.created_at} + key={session.id}apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/NebulaImage.tsx (2)
17-35: ExposeclassNameon the root element and wire via cn()Improves composability and aligns with dashboard component conventions.
+import { cn } from "@/lib/utils"; @@ export function NebulaImage( props: | { project: Project; type: "response"; url: string; width: number; height: number; client: ThirdwebClient; requestId: string; sessionId: string | undefined; + className?: string; } | { project: Project; type: "submitted"; url: string; client: ThirdwebClient; + className?: string; }, ) { @@ - return ( - <div className="group relative w-full max-w-[80%] lg:max-w-[50%]"> + return ( + <div + className={cn( + "group relative w-full max-w-[80%] lg:max-w-[50%]", + props.className, + )} + >Also applies to: 51-53
111-126: Download fallback for strict CORSIf CORS blocks fetching the image, fall back to opening the src in a new tab.
} catch (error) { - console.error("Download failed:", error); + console.error("Download failed:", error); + try { + window.open(src, "_blank", "noopener,noreferrer"); + } catch {} }apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.stories.tsx (2)
23-24: Align Storybook title with branding: “thirdweb/history”.Matches the PR-wide rename.
- title: "AI/history", + title: "thirdweb/history",
54-69: Type the factory and ensure monotonic timestamps + stable IDs.Return type + make updated_at ≥ created_at and use uuid when available.
-function createRandomSessions(length: number) { +import type { TruncatedSessionInfo } from "../../api/types"; + +function createRandomSessions(length: number): TruncatedSessionInfo[] { const sessions = []; for (let i = 0; i < length; i++) { + const daysAgo = Math.floor(Math.random() * 10) + 1; sessions.push({ - created_at: new Date().toISOString(), - id: Math.random().toString(), + created_at: subDays(new Date(), daysAgo).toISOString(), + id: + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : Math.random().toString(36).slice(2), title: randomLorem(Math.floor(5 + Math.random() * 15)), - updated_at: subDays( - new Date(), - Math.floor(Math.random() * 10), - ).toISOString(), + updated_at: subDays(new Date(), daysAgo - 1).toISOString(), }); } return sessions; }apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Chats.tsx (1)
143-145: Prefer stable keys for message list.Index keys can cause incorrect re-use on edits; consider
request_id || indexfor assistant/action/image, and a hash of content for user/presence.
| export function AssetsSectionUI(props: { | ||
| data: AssetBalance[]; | ||
| isPending: boolean; | ||
| client: ThirdwebClient; | ||
| }) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Expose className on exported components and merge with cn()
Guideline: apps//.tsx components should accept a className on the root element.
+import { cn } from "@/lib/utils";
@@
-export function AssetsSectionUI(props: {
+export function AssetsSectionUI(props: {
data: AssetBalance[];
isPending: boolean;
client: ThirdwebClient;
+ className?: string;
}) {
if (props.data.length === 0 && !props.isPending) {
return (
- <div className="flex h-full flex-col items-center justify-center gap-3 px-2 py-1">
+ <div
+ className={cn(
+ "flex h-full flex-col items-center justify-center gap-3 px-2 py-1",
+ props.className,
+ )}
+ >
<div className="rounded-full border p-1">
<XIcon className="size-4" />
</div>
- <div className="text-muted-foreground text-sm">No Assets </div>
+ <div className="text-muted-foreground text-sm">No Assets</div>
</div>
);
}
return (
- <div className="flex flex-col gap-1">
+ <div className={cn("flex flex-col gap-1", props.className)}>Removed trailing space in “No Assets”.
Also applies to: 40-49, 51-68
| const assetsQuery = useQuery({ | ||
| enabled: !!account && !!activeChain, | ||
| queryFn: async () => { | ||
| if (!account || !activeChain) { | ||
| return []; | ||
| } | ||
| const chains = [...new Set([1, 8453, 10, 137, activeChain.id])]; | ||
| const url = new URL( | ||
| `https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/erc20/${account?.address}`, | ||
| ); | ||
| url.searchParams.set("limit", "50"); | ||
| url.searchParams.set("metadata", "true"); | ||
| url.searchParams.set("include_spam", "false"); | ||
| url.searchParams.set("clientId", props.client.clientId); | ||
| for (const chain of chains) { | ||
| url.searchParams.append("chain", chain.toString()); | ||
| } | ||
|
|
||
| const response = await fetch(url.toString()); | ||
| const json = (await response.json()) as { | ||
| data: AssetBalance[]; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
React Query: add generics, abort signal, and handle non-2xx responses
Prevents stale requests from updating state and ensures failures bubble to React Query.
- const assetsQuery = useQuery({
- enabled: !!account && !!activeChain,
- queryFn: async () => {
+ const assetsQuery = useQuery<AssetBalance[]>({
+ enabled: !!account && !!activeChain,
+ staleTime: 60_000,
+ queryFn: async ({ signal }) => {
if (!account || !activeChain) {
return [];
}
@@
- const response = await fetch(url.toString());
- const json = (await response.json()) as {
- data: AssetBalance[];
- };
+ const response = await fetch(url.toString(), { signal });
+ if (!response.ok) {
+ throw new Error(`insight API ${response.status}: ${await response.text()}`);
+ }
+ const json = (await response.json()) as { data: AssetBalance[] };📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const assetsQuery = useQuery({ | |
| enabled: !!account && !!activeChain, | |
| queryFn: async () => { | |
| if (!account || !activeChain) { | |
| return []; | |
| } | |
| const chains = [...new Set([1, 8453, 10, 137, activeChain.id])]; | |
| const url = new URL( | |
| `https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/erc20/${account?.address}`, | |
| ); | |
| url.searchParams.set("limit", "50"); | |
| url.searchParams.set("metadata", "true"); | |
| url.searchParams.set("include_spam", "false"); | |
| url.searchParams.set("clientId", props.client.clientId); | |
| for (const chain of chains) { | |
| url.searchParams.append("chain", chain.toString()); | |
| } | |
| const response = await fetch(url.toString()); | |
| const json = (await response.json()) as { | |
| data: AssetBalance[]; | |
| }; | |
| const assetsQuery = useQuery<AssetBalance[]>({ | |
| enabled: !!account && !!activeChain, | |
| staleTime: 60_000, | |
| queryFn: async ({ signal }) => { | |
| if (!account || !activeChain) { | |
| return []; | |
| } | |
| const chains = [...new Set([1, 8453, 10, 137, activeChain.id])]; | |
| const url = new URL( | |
| `https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/erc20/${account?.address}`, | |
| ); | |
| url.searchParams.set("limit", "50"); | |
| url.searchParams.set("metadata", "true"); | |
| url.searchParams.set("include_spam", "false"); | |
| url.searchParams.set("clientId", props.client.clientId); | |
| for (const chain of chains) { | |
| url.searchParams.append("chain", chain.toString()); | |
| } | |
| const response = await fetch(url.toString(), { signal }); | |
| if (!response.ok) { | |
| throw new Error(`insight API ${response.status}: ${await response.text()}`); | |
| } | |
| const json = (await response.json()) as { data: AssetBalance[] }; | |
| // …rest of your logic, e.g.: | |
| // return json.data; | |
| }, | |
| }); |
🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.tsx
around lines 150-171, the queryFn should be typed with React Query generics,
accept the query function context to get the AbortSignal, pass that signal into
fetch to abort stale requests, and check the fetch response status — if non-2xx
throw an Error (or include response text) so failures bubble to React Query;
finally ensure the function returns the typed AssetBalance[] (or empty array)
and not undefined.
| export function ChatPageContent(props: { | ||
| project: Project; | ||
| session: SessionInfo | undefined; | ||
| accountAddress: string; | ||
| authToken: string; | ||
| client: ThirdwebClient; | ||
| type: "landing" | "new-chat"; | ||
| initialParams: | ||
| | { | ||
| q: string | undefined; | ||
| chainIds: number[]; | ||
| } | ||
| | undefined; | ||
| }) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do not expose authToken to the browser; move streaming behind an internal API route.
Passing JWT to a Client Component violates the repo’s “keep tokens secret via internal routes or server actions” rule and risks leakage. Proxy promptNebula via a Next.js route that reads the token server-side, then stream to the client.
Minimal local diffs (removing the token from props and calls):
export function ChatPageContent(props: {
project: Project;
session: SessionInfo | undefined;
accountAddress: string;
- authToken: string;
client: ThirdwebClient;
type: "landing" | "new-chat";
@@
await handleNebulaPrompt({
abortController,
project: props.project,
- authToken: props.authToken,
contextFilters: contextFilters,
message: message,
sessionId: currentSessionId,
setContextFilters,
setMessages,
});
@@
<Chats
project={props.project}
- authToken={props.authToken}
className="min-w-0 pt-6 pb-32"
client={props.client}Follow-up (outside this diff): introduce /api/ai/stream (server route) that calls promptNebula with Authorization; swap handleNebulaPrompt to call that route without passing tokens client-side. I can draft that patch if you want.
Also applies to: 211-221, 346-358
🤖 Prompt for AI Agents
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageContent.tsx
lines 36-49: the component is accepting and exposing authToken to a Client
Component; remove authToken from the component props and all client-side usages,
and instead implement an internal Next.js API route (e.g., /api/ai/stream) that
performs promptNebula calls server-side using the JWT from the server
environment or a server action; update handleNebulaPrompt and any other client
functions to call this internal route (fetch to /api/ai/stream) and consume the
streamed response without ever sending the JWT to the browser; also apply the
same removal and routing changes to the other occurrences referenced (lines
~211-221 and ~346-358) so no client code carries the token.
| export function ChatPageLayout(props: { | ||
| team_slug: string; | ||
| authToken: string; | ||
| project: Project; | ||
| client: ThirdwebClient; | ||
| accountAddress: string; | ||
| sessions: TruncatedSessionInfo[]; | ||
| children: React.ReactNode; | ||
| className?: string; | ||
| }) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Critical: JWT is passed to a client component via props
authToken flows into MobileNav (client) which exposes it to the browser. Remove the prop entirely; MobileNav does not use it.
export function ChatPageLayout(props: {
team_slug: string;
- authToken: string;
project: Project;
client: ThirdwebClient;
- accountAddress: string;
sessions: TruncatedSessionInfo[];
children: React.ReactNode;
className?: string;
}) {
@@
<MobileNav
project={props.project}
- authToken={props.authToken}
sessions={props.sessions}
client={props.client}
team_slug={props.team_slug}
/>Also remove authToken from NebulaMobileNav.tsx props signature.
I can provide a follow-up patch for NebulaMobileNav.tsx if helpful.
Also applies to: 25-31
🏁 Script executed:
#!/bin/bash
# Locate and display the ChatPageLayout component
fd ChatPageLayout.tsx --exec sed -n '1,200p' {}
# Locate and display the NebulaMobileNav (MobileNav) component
fd NebulaMobileNav.tsx --exec sed -n '1,200p' {}Length of output: 6502
Remove unused authToken and accountAddress props from ChatPageLayout
In apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageLayout.tsx:
export function ChatPageLayout(props: {
team_slug: string;
- authToken: string;
project: Project;
client: ThirdwebClient;
- accountAddress: string;
sessions: TruncatedSessionInfo[];
children: React.ReactNode;
className?: string;
}) {
@@
<MobileNav
project={props.project}
- authToken={props.authToken}
sessions={props.sessions}
client={props.client}
team_slug={props.team_slug}
/>Also remove the unused authToken parameter from the MobileNav signature in apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/NebulaMobileNav.tsx. I can provide a follow-up patch if helpful.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function ChatPageLayout(props: { | |
| team_slug: string; | |
| authToken: string; | |
| project: Project; | |
| client: ThirdwebClient; | |
| accountAddress: string; | |
| sessions: TruncatedSessionInfo[]; | |
| children: React.ReactNode; | |
| className?: string; | |
| }) { | |
| export function ChatPageLayout(props: { | |
| team_slug: string; | |
| project: Project; | |
| client: ThirdwebClient; | |
| sessions: TruncatedSessionInfo[]; | |
| children: React.ReactNode; | |
| className?: string; | |
| }) { | |
| return ( | |
| <div className={cn("flex h-full flex-col", props.className)}> | |
| <MobileNav | |
| project={props.project} | |
| sessions={props.sessions} | |
| client={props.client} | |
| team_slug={props.team_slug} | |
| /> | |
| {/* ...rest of layout... */} | |
| {props.children} | |
| </div> | |
| ); | |
| } |
🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageLayout.tsx
around lines 8-17, remove the unused props authToken and accountAddress from the
ChatPageLayout props interface and any references to them in the component
signature/usage; then open
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/NebulaMobileNav.tsx
and remove the unused authToken parameter from the MobileNav function signature
and any callers passing it (update call sites to stop passing authToken), run
TypeScript to ensure no remaining references, and adjust imports/exports if
necessary.

PR-Codex overview
This PR focuses on updating the
Nebulaapplication tothirdweb AI, enhancing functionality, and improving UI components related to AI interactions and transactions.Detailed summary
Nebulatothirdweb AI.AbortSignalto request options inproxies.ts.package.json.Summary by CodeRabbit
New Features
Improvements
Chores