Repository navigation
thirdweb AI inside dashboard - #8003
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.
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(""); }
87aa725 to
ee25ba7
Compare
There was a problem hiding this comment.
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
ee25ba7 to
9138778
Compare
9138778 to
b4c89eb
Compare
There was a problem hiding this comment.
Actionable comments posted: 6
♻️ Duplicate comments (15)
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatBar.tsx (6)
56-60: Rename brand in toast: “Nebula” → “thirdweb”.Keep branding consistent with PR notes.
function showSigninToUploadImagesToast() { - toast.error("Sign in to upload images to Nebula", { + toast.error("Sign in to upload images to thirdweb", { position: "top-right", }); }
176-199: Drag-over highlight can get stuck when uploads aren’t allowed; also fix contains typing.Only set highlight when uploads are allowed; always clear on leave; cast contains arg as Node | null.
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); }}
92-101: Strip data URL prefix before sending b64 to API.Send raw base64, keep data URL only for preview.
if (images.length > 0) { for (const image of images) { if (image.b64) { + const dataUrl = image.b64; + const base64 = dataUrl.includes(",") + ? dataUrl.split(",")[1] + : dataUrl; userMessage.content.push({ - b64: image.b64, + b64: base64, image_url: null, type: "image", }); } } }If the backend actually expects a full data URL, ignore this change. Please confirm the schema in ../api/types and chat API.
372-376: Rename brand in popover copy: “Nebula” → “thirdweb”.Aligns with the product rename.
- <p className="mb-3 text-muted-foreground text-sm"> - Get access to image uploads by signing in to Nebula - </p> + <p className="mb-3 text-muted-foreground text-sm"> + Get access to image uploads by signing in to thirdweb + </p>
537-541: Don’t mutate props when sorting wallets.Clone before sort to avoid side effects.
-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; });
227-236: Don’t send empty messages on Enter.Mirror the Send button guard to avoid empty submissions.
onKeyDown={(e) => { // ignore if shift key is pressed to allow entering new lines if (e.shiftKey) { return; } if (e.key === "Enter" && !props.isChatStreaming) { - e.preventDefault(); - handleSubmit(message); + if (message.trim() === "" && images.length === 0) return; + e.preventDefault(); + handleSubmit(message); } }}apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/TransactionsSection/TransactionsSection.tsx (7)
26-60: Expose className on UI component and merge with cn().Matches apps/{dashboard}/**/*.tsx guideline and enables layout control.
-export function TransactionSectionUI(props: { +export function TransactionSectionUI(props: { data: WalletTransaction[]; 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 Recent Activity</div> </div> ); } return ( - <div className="flex flex-col gap-1"> + <div className={cn("flex flex-col gap-1", props.className)}> {!props.isPending && props.data.map((asset) => ( <TransactionInfo client={props.client} key={asset.hash} transaction={asset} /> ))} {props.isPending && new Array(10).fill(null).map((_, index) => ( // biome-ignore lint/suspicious/noArrayIndexKey: TODO <SkeletonAssetItem key={index} /> ))} </div> ); }
95-101: Keep Link without rel; repo doesn’t require it.Prior bot comment suggested rel for target="_blank", but maintainers confirmed it’s not required for external Links in this project. Current code is OK.
134-159: Simplify description with typed inputs + explicit return type.Use the narrowed DecodedInputs type.
-function getTransactionDescription(transaction: WalletTransaction) { +function getTransactionDescription(transaction: WalletTransaction): string { if (!transaction.decoded) { return `To: ${shortenAddress(transaction.to_address)}`; } - if ( - typeof transaction.decoded.inputs === "object" && - transaction.decoded.inputs !== null - ) { - if ( - "to" in transaction.decoded.inputs && - typeof transaction.decoded.inputs.to === "string" - ) { - return `To: ${shortenAddress(transaction.decoded.inputs.to)}`; - } - - if ( - "spender" in transaction.decoded.inputs && - typeof transaction.decoded.inputs.spender === "string" - ) { - return `Spender: ${shortenAddress(transaction.decoded.inputs.spender)}`; - } - } + const inputs = transaction.decoded.inputs; + if (inputs?.to) { + return `To: ${shortenAddress(inputs.to)}`; + } + if (inputs?.spender) { + return `Spender: ${shortenAddress(inputs.spender)}`; + } return `To: ${shortenAddress(transaction.to_address)}`; }
203-210: Pass className through to UI.Keeps styling extensible for embedding contexts.
return ( <TransactionSectionUI client={props.client} data={txQuery.data ?? []} isPending={txQuery.isPending} + className={props.className} /> );
1-1: Add 'use client' (hooks used).This file uses React Query and thirdweb hooks; it must be a Client Component.
+'use client';
12-24: Tighten decoded.inputs typing; avoidobject.Model the known shape so property access is type-safe.
-// Note: this is not the full object type returned from insight API, it only includes fields we care about +// Note: this is not the full object type returned from insight API, it only includes fields we care about +type DecodedInputs = { + to?: string; + spender?: string; +} & Record<string, unknown>; 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?: DecodedInputs | null; }; };
161-201: Type the query, hook into AbortSignal, add cache settings, and guard bad responses.Prevents leaks and aligns with dashboard React Query guidance.
-export function TransactionsSection(props: { client: ThirdwebClient }) { +export function TransactionsSection(props: { client: ThirdwebClient; className?: string }) { const account = useActiveAccount(); const activeChain = useActiveWalletChain(); - const txQuery = useQuery({ + const txQuery = useQuery<WalletTransaction[]>({ enabled: !!account && !!activeChain, - queryFn: async () => { + 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/wallets/${account.address}/transactions`, ); url.searchParams.set("limit", "20"); url.searchParams.set("decode", "true"); url.searchParams.set("clientId", props.client.clientId); const threeMonthsAgoUnixTime = Math.floor( (Date.now() - 3 * 30 * 24 * 60 * 60 * 1000) / 1000, ); url.searchParams.set( "filter_block_timestamp_gte", `${threeMonthsAgoUnixTime}`, ); for (const chain of chains) { url.searchParams.append("chain", chain.toString()); } - 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, });apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatSidebarLink.tsx (1)
17-18: Do not call tokened AI session APIs from a client component; proxy via an internal API route (duplicate).Importing and calling
deleteSessionhere sends an authenticated request directly from the browser. Per dashboard rules, attach JWT on the server via an internal Next.js route and call that route from this client component.Apply locally (temporary shim until the server route exists):
-import { deleteSession } from "../api/session"; +// TODO: replace with typed internal route once added +async function deleteSessionViaApi(sessionId: string): Promise<void> { + const res = await fetch( + `/api/thirdweb-ai/session/${encodeURIComponent(sessionId)}`, + { method: "DELETE" }, + ); + if (!res.ok) { + throw new Error("Failed to delete session"); + } +}- const deleteChat = useMutation({ - mutationFn: () => { - return deleteSession({ - project: props.project, - sessionId: props.sessionId, - }); - }, + const deleteChat = useMutation({ + mutationFn: () => deleteSessionViaApi(props.sessionId), onSuccess: () => { const prev = deletedSessionsStore.getValue(); deletedSessionsStore.setValue([...prev, props.sessionId]); if (isDeletingCurrentPage) { router.replace(newChatLink); } }, });Add the server route (outside this file):
// apps/dashboard/src/app/api/thirdweb-ai/session/[sessionId]/route.ts import "server-only"; import { NextResponse } from "next/server"; import { getAuthToken } from "@/api/auth-token"; import { NEXT_PUBLIC_THIRDWEB_AI_HOST } from "@/constants/public-envs"; export async function DELETE( _req: Request, { params }: { params: { sessionId: string } }, ) { const token = await getAuthToken(); if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_AI_HOST}/session/${params.sessionId}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, }); const body = await res.json().catch(() => ({})); return NextResponse.json(body, { status: res.status }); }Also applies to: 32-46
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ExecuteTransactionCard.stories.tsx (1)
67-67: Fix prop type: sendTx must accept (tx, onTxSettled).Current zero-arg handler violates ExecuteTransactionCardLayout’s callback type and will fail type-checking.
Apply:
- sendTx={async () => {}} + sendTx={async (_tx, onTxSettled) => { + // simulate a happy-path flow in Storybook + setStatus({ type: "sending" }); + await new Promise((r) => setTimeout(r, 500)); + setStatus({ type: "confirming", txHash: exampleTxHash }); + await new Promise((r) => setTimeout(r, 500)); + setStatus({ type: "confirmed", txHash: exampleTxHash }); + onTxSettled(exampleTxHash); + }}
🧹 Nitpick comments (37)
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatBar.tsx (6)
447-448: Use a stable unique key for previews.File names can collide; include size/lastModified.
- key={image.file.name} + key={`${image.file.name}-${image.file.size}-${image.file.lastModified}`}
15-15: Sync prefillMessage when the prop changes.Keeps textarea in sync with parent updates.
-import { useState } from "react"; +import { useEffect, useState } from "react";
79-85: Add effect to update message when prefillMessage changes.const [message, setMessage] = useState(props.prefillMessage || ""); +useEffect(() => { + setMessage(props.prefillMessage || ""); +}, [props.prefillMessage]);
41-45: Reuse shared CopyButton instead of local duplicate.Reduce duplication by importing the existing UI component.
import { Skeleton } from "@/components/ui/skeleton"; import { AutoResizeTextarea } from "@/components/ui/textarea"; import { ToolTipLabel } from "@/components/ui/tooltip"; +import { CopyButton } from "@/components/ui/CopyButton";
653-655: Switch to shared CopyButton usage.Match the shared component’s props.
-<CopyButton address={wallet.address} /> +<CopyButton + text={wallet.address} + tooltip={false} + className="p-1.5" + iconClassName="size-3" />
698-720: Remove local CopyButton duplicate.Use the shared component imported above.
-function CopyButton(props: { address: string }) { - const [copied, setCopied] = useState(false); - return ( - <Button - className="h-auto w-auto p-1.5 text-muted-foreground" - onClick={(e) => { - e.stopPropagation(); - navigator.clipboard.writeText(props.address); - setCopied(true); - setTimeout(() => { - setCopied(false); - }, 1000); - }} - variant="ghost" - > - {copied ? ( - <CheckIcon className="size-3 text-green-500" /> - ) : ( - <CopyIcon className="size-3" /> - )} - </Button> - ); -}apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/TransactionsSection/TransactionsSection.tsx (2)
1-11: Import cn for className merging (design guideline).You expose no className yet; import cn to enable it (see next comment).
import { Skeleton } from "@/components/ui/skeleton"; import { isProd } from "@/constants/env-utils"; import { useAllChainsData } from "@/hooks/chains/allChains"; import { ChainIconClient } from "@/icons/ChainIcon"; +import { cn } from "@/lib/utils";
111-133: Harden title logic; explicit utility return type and robust value check.Avoid string-only zero check; handle hex/decimal safely.
-function getTransactionTitle(transaction: WalletTransaction) { +function getTransactionTitle(transaction: WalletTransaction): string { if (!transaction.decoded) { return "Transaction Sent"; } if (transaction.decoded.name) { return transaction.decoded.name; } if (transaction.decoded.signature) { const nameFromSignature = transaction.decoded.signature.split("(")[0]; if (nameFromSignature) { return nameFromSignature; } } - if (transaction.value !== "0") { - return "Transfer"; - } + try { + if (BigInt(transaction.value) !== 0n) return "Transfer"; + } catch { + if (transaction.value !== "0" && transaction.value !== "0x0") return "Transfer"; + } return "Transaction Sent"; }apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatSidebarLink.tsx (5)
30-31: Tighten current-page detection to avoid false positives.Using substring matching can misfire. Match the expected suffix:
- const isDeletingCurrentPage = pathname.includes(props.sessionId); + const isDeletingCurrentPage = pathname.endsWith(`/ai/chat/${props.sessionId}`);
40-41: Avoid duplicate entries indeletedSessionsStore.- const prev = deletedSessionsStore.getValue(); - deletedSessionsStore.setValue([...prev, props.sessionId]); + const prev = deletedSessionsStore.getValue(); + if (!prev.includes(props.sessionId)) { + deletedSessionsStore.setValue([...prev, props.sessionId]); + }
66-71: Replace raw color utilities with design tokens; add accessible label.- <Button - className="-translate-y-1/2 absolute top-1/2 right-1 z-10 h-auto w-auto bg-accent p-1 opacity-0 hover:bg-zinc-300 group-hover:opacity-100 dark:hover:bg-zinc-700" + <Button + className="-translate-y-1/2 absolute top-1/2 right-1 z-10 h-auto w-auto bg-accent p-1 opacity-0 hover:bg-muted group-hover:opacity-100" + aria-label="Open chat actions" variant="ghost" >
74-84: Disable the Delete button while the mutation is pending to prevent double-submits.- <Button + <Button className="!text-destructive-text flex w-full justify-start gap-2.5 px-2" onClick={() => { const promise = deleteChat.mutateAsync(); toast.promise(promise, { error: "Failed to delete chat", success: "Chat deleted successfully", }); }} + disabled={deleteChat.isPending} variant="ghost" >
20-20: Follow-up: add a confirm dialog before destructive delete.I can wire an
<AlertDialog>with “Delete/Cancel” and require explicit confirmation. Want a patch?apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ExecuteTransactionCard.stories.tsx (5)
32-32: Add explicit return type to Story.Conform to the repo TS guideline for explicit return types.
-function Story() { +function Story(): JSX.Element {
58-58: Add explicit return type to Variant.Match the explicit return type guidance.
-function Variant(props: { label: string; status: TxStatus }) { +function Variant(props: { label: string; status: TxStatus }): JSX.Element {
12-21: Point Storybook at the component under test for better docs/controls.Set meta.component to ExecuteTransactionCardLayout; use render/decorators for the wrapper.
-const meta = { - component: Story, +const meta = { + component: ExecuteTransactionCardLayout, parameters: { nextjs: { appDirectory: true, }, }, title: "AI/actions/ExecuteTransactionCard", -} satisfies Meta<typeof Story>; +} satisfies Meta<typeof ExecuteTransactionCardLayout>;
23-27: Avoid value/type name collision and wire render to wrapper.Rename the StoryObj alias to prevent confusion with the Story component, and render the wrapper if you adopt the meta change above.
-export default meta; -type Story = StoryObj<typeof meta>; +export default meta; +type StoryDef = StoryObj<typeof meta>; -export const Variants: Story = { - args: {}, -}; +export const Variants: StoryDef = { + args: {}, + render: () => <Story />, +};
72-74: Fix misleading inline comment.“thirdweb.eth” describes the recipient, not the data field.
- data: "0x", // thirdweb.eth - to: "0xEb0effdFB4dC5b3d5d3aC6ce29F3ED213E95d675", + data: "0x", + to: "0xEb0effdFB4dC5b3d5d3aC6ce29F3ED213E95d675", // thirdweb.ethapps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Chats.stories.tsx (4)
11-19: Pass client to ThirdwebProvider for consistent contextSome thirdweb React hooks/components expect the client on the provider. You’re already passing it to
ConnectButton; pass it to the provider too to avoid context gaps.Apply:
- <ThirdwebProvider> + <ThirdwebProvider client={storybookThirdwebClient}> <div className="container flex max-w-[800px] flex-col gap-14 py-10"> <div> <ConnectButton client={storybookThirdwebClient} /> </div> <Story /> </div> - </ThirdwebProvider> + </ThirdwebProvider>
105-123: Fix minor typos in markdown fixtureKeeps Storybook docs polished.
Apply:
-This a paragraph +This is a paragraph @@ -This a very long paragraph lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec pur us. Donec euismod, nunc nec vehicula. -ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec pur us. Donec euismod, nunc nec vehicula. +This is a very long paragraph lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec purus. Donec euismod, nunc nec vehicula. +ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec purus. Donec euismod, nunc nec vehicula. @@ -## Empasis +## Emphasis @@ -This a a paragraph with some `inlineCode()` +This is a paragraph with some `inlineCode()` @@ -This a `const longerCodeSnippet = "Example. This should be able to handle line breaks as well, it should not be overflowing the page";` +This is `const longerCodeSnippet = "Example. This should be able to handle line breaks as well, it should not be overflowing the page";`Also applies to: 155-158
198-203: Stabilize random content for visual regression tools
randomLorem()changes on every reload, causing flaky Chromatic/screenshot diffs. Prefer static strings or a seeded generator for stories.If you use Chromatic, confirm whether these stories show diffs between runs without code changes.
225-239: Annotate return type for VariantFollow repo TS guidance: explicit return types.
Apply:
-function Variant(props: { messages: ChatMessage[] }) { +function Variant(props: { messages: ChatMessage[] }): JSX.Element { return ( <Chats project={projectStub("xxxxx", "team-1")} authToken="xxxxx" client={storybookThirdwebClient} enableAutoScroll={false} isChatStreaming={false} messages={props.messages} sendMessage={() => {}} sessionId="xxxxx" setEnableAutoScroll={() => {}} /> ); }apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.stories.tsx (4)
1-1: Use @storybook/react for types (framework package doesn’t guarantee type exports).Switch Meta/StoryObj import to @storybook/react to avoid type resolution issues across SB versions.
-import type { Meta, StoryObj } from "@storybook/nextjs"; +import type { Meta, StoryObj } from "@storybook/react";
12-21: Avoid “Story” name overload (value and type); add explicit return types.Rename the component to SwapCardsStory and the StoryObj alias to StoryType; also make return types explicit per guidelines.
-const meta = { - component: Story, +const meta = { + component: SwapCardsStory, parameters: { nextjs: { appDirectory: true, }, }, title: "AI/actions/Swap Cards", -} satisfies Meta<typeof Story>; +} satisfies Meta<typeof SwapCardsStory>; export default meta; -type Story = StoryObj<typeof meta>; +type StoryType = StoryObj<typeof meta>; @@ -export const Swap: Story = { +export const Swap: StoryType = { @@ -export const Approve: Story = { +export const Approve: StoryType = { @@ -function Story(props: { swapData: NebulaSwapData }) { +function SwapCardsStory(props: { swapData: NebulaSwapData }): JSX.Element { @@ -function Variant(props: { +function Variant(props: { label: string; status: TxStatus; swapData: NebulaSwapData; -}) { +}): JSX.Element {Also applies to: 22-24, 77-112
121-139: Match sendTx signature and keep types explicit.Accept the tx param and type it; keeps parity with component prop types.
-import { +import { ApproveTransactionCardLayout, SwapTransactionCardLayout, } from "./SwapCards"; +import type { PreparedTransaction } from "thirdweb"; @@ <ApproveTransactionCardLayout client={storybookThirdwebClient} - sendTx={async () => {}} + sendTx={async (_tx: PreparedTransaction) => {}} setStatus={setStatus} status={status} swapData={props.swapData} /> @@ <SwapTransactionCardLayout client={storybookThirdwebClient} - sendTx={async () => {}} + sendTx={async (_tx: PreparedTransaction) => {}} setStatus={setStatus} status={status} swapData={props.swapData} />Also applies to: 3-3
33-41: Gitleaks false positive (hex strings are token/tx data, not secrets).Addresses and calldata here are public chain artifacts. Suggest allow-listing EVM addresses/calldata in gitleaks config to reduce noise.
If desired, I can draft a minimal gitleaks allowlist entry scoped to this path to avoid overbroad ignores.
Also applies to: 49-53
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageLayout.stories.tsx (4)
17-23: Use fullscreen layout to remove Storybook canvas paddingHelps the page-layout story render true-to-app.
parameters: { nextjs: { appDirectory: true, }, + layout: "fullscreen", },
46-53: Make fixtures deterministic and cover null titlesStable timestamps improve visual regression; occasional null title exercises UI fallback.
-function generateRandomSessions(count: number): TruncatedSessionInfo[] { - return Array.from({ length: count }, (_, i) => ({ - created_at: new Date().toISOString(), - id: i.toString(), - title: randomLorem(Math.floor(Math.random() * 10) + 1), - updated_at: new Date().toISOString(), - })); -} +function generateRandomSessions(count: number): TruncatedSessionInfo[] { + const baseMs = Date.parse("2025-01-01T00:00:00.000Z"); + return Array.from({ length: count }, (_, i) => { + const ts = new Date(baseMs + i * 60_000).toISOString(); // +i min, deterministic + const title = i % 5 === 0 ? null : randomLorem(Math.floor(Math.random() * 10) + 1); + return { + created_at: ts, + id: i.toString(), + title, + updated_at: ts, + }; + }); +}
28-44: Add a mixed/edge-case storyQuickly validates null titles and timestamp ordering.
export const ThirtyChats: Story = { args: { sessions: generateRandomSessions(30), }, }; + +export const MixedTitles: Story = { + args: { + sessions: generateRandomSessions(5), // includes one null title with the generator above + }, +};
55-56: Annotate explicit return typeAligns with repo TS guidelines for explicit returns.
-function Variant(props: { sessions: TruncatedSessionInfo[] }) { +function Variant(props: { sessions: TruncatedSessionInfo[] }): JSX.Element {apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/analytics/page.tsx (3)
15-23: Add explicit return type and widen searchParams typesAlign with our TS guidelines and the helpers that accept
string | string[].-export default async function Page(props: { - params: Promise<{ team_slug: string; project_slug: string }>; - searchParams: Promise<{ - from?: string; - to?: string; - type?: string; - interval?: string; - }>; -}) { +export default async function Page(props: { + params: Promise<{ team_slug: string; project_slug: string }>; + searchParams: Promise<{ + from?: string | string[]; + to?: string | string[]; + type?: string | string[]; + interval?: string | string[]; + }>; +}): Promise<JSX.Element> {
8-8: Importing from “.client.ts” in a Server Component—double-check module modeThis page is a Server Component. Importing from
thirdweb-client.client.tsis risky if that module is client-marked or pulls browser-only deps.
- If the constants file starts with
'use client'or imports browser-only libs, movegetClientThirdwebClientto a server-safe module (e.g.,thirdweb-client.server.ts) and import that here.Also applies to: 44-48
9-9: Branding consistency: Nebula vs thirdwebConfirm
NebulaIconis still the intended brand asset for AI pages. If branding migrated, switch to the corresponding thirdweb icon.Also applies to: 61-61
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.stories.tsx (4)
5-16: Exclude non-serializable “client” from Controls/Docs to avoid args table warnings.
Storybook will try to serialize the client; disable its control.Apply this diff inside the
metaobject:title: "AI/AssetsSection", + argTypes: { + client: { control: false }, + },
8-12: Type the decorator for explicit return type.
Keeps us aligned with “explicit return types” guidance.-import { type AssetBalance, AssetsSectionUI } from "./AssetsSection"; +import { type AssetBalance, AssetsSectionUI } from "./AssetsSection"; +import type { StoryFn } from "@storybook/react"; @@ - (Story) => ( + (Story: StoryFn): JSX.Element => (
18-19: Rename localStoryalias to avoid confusion with decorator param name.
Small readability win.-type Story = StoryObj<typeof meta>; +type StoryDef = StoryObj<typeof meta>; @@ -export const MultipleAssets: Story = { +export const MultipleAssets: StoryDef = { @@ -export const SingleAsset: Story = { +export const SingleAsset: StoryDef = { @@ -export const EmptyAssets: Story = { +export const EmptyAssets: StoryDef = { @@ -export const Loading: Story = { +export const Loading: StoryDef = {Also applies to: 71-77, 79-85, 87-93, 95-101
20-69: Broaden stub coverage (decimals/chains) and validate shape at compile-time.
Add a 6‑dec token and a second chain; also usesatisfiesfor stronger type checking.-const tokensStub: AssetBalance[] = [ +const tokensStub = [ @@ { balance: "6237535850425", chain_id: 8453, decimals: 18, name: "Wrapped Ether", symbol: "WETH", token_address: "0x4200000000000000000000000000000000000006", }, + { + balance: "123456", // 0.123456 in human units + chain_id: 1, + decimals: 6, + name: "USD Coin (stub)", + symbol: "USDC", + token_address: "0x0000000000000000000000000000000000000001", + }, -]; +] satisfies AssetBalance[];
51e0057 to
7b94e0f
Compare
There was a problem hiding this comment.
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 []; + });
| jwt: authToken, | ||
| teamId: project.teamId, | ||
| }); | ||
| const client = await getUserThirdwebClient({ teamId: project.teamId }); |
There was a problem hiding this comment.
💡 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.
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.
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.
🛠️ 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.
💡 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.
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.
🛠️ 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.
🛠️ 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.
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.
💡 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