From 2a131b1c1a521cef8b230ebac11fd6bd01de6fee Mon Sep 17 00:00:00 2001 From: teleaziz Date: Sat, 27 Jun 2026 00:14:05 +0400 Subject: [PATCH] Fix Assets library chat toggle and sidebar image model chat controls --- .changeset/assets-sidebar-image-model-menu.md | 5 + packages/core/src/client/AgentPanel.tsx | 4 + .../assets/app/components/layout/Layout.tsx | 22 +++- .../assets/app/hooks/use-image-model-menu.ts | 74 ++++++++++++ templates/assets/app/routes/_index.tsx | 70 +---------- templates/assets/app/routes/library.tsx | 113 ++++++++++++------ 6 files changed, 182 insertions(+), 106 deletions(-) create mode 100644 .changeset/assets-sidebar-image-model-menu.md create mode 100644 templates/assets/app/hooks/use-image-model-menu.ts diff --git a/.changeset/assets-sidebar-image-model-menu.md b/.changeset/assets-sidebar-image-model-menu.md new file mode 100644 index 0000000000..de23500d41 --- /dev/null +++ b/.changeset/assets-sidebar-image-model-menu.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Expose the optional image model menu through AgentSidebar so app sidebars can show secondary generation model controls. diff --git a/packages/core/src/client/AgentPanel.tsx b/packages/core/src/client/AgentPanel.tsx index 2e32651740..1e6c5726a3 100644 --- a/packages/core/src/client/AgentPanel.tsx +++ b/packages/core/src/client/AgentPanel.tsx @@ -2340,6 +2340,8 @@ export interface AgentSidebarProps { dynamicSuggestions?: AssistantChatProps["dynamicSuggestions"]; /** Optional controls rendered in the chat composer toolbar. */ composerToolbarSlot?: AssistantChatProps["composerToolbarSlot"]; + /** Optional secondary model menu shown inside the chat composer model picker. */ + imageModelMenu?: AssistantChatProps["imageModelMenu"]; /** Optional content rendered at the bottom of the chat thread. */ threadFooterSlot?: AssistantChatProps["threadFooterSlot"]; /** Initial sidebar width in pixels. Mount-only; user resize and a saved @@ -2389,6 +2391,7 @@ export function AgentSidebar({ suggestions, dynamicSuggestions, composerToolbarSlot, + imageModelMenu, threadFooterSlot, defaultSidebarWidth, sidebarWidth, @@ -2924,6 +2927,7 @@ export function AgentSidebar({ suggestions={suggestions} dynamicSuggestions={dynamicSuggestions} composerToolbarSlot={composerToolbarSlot} + imageModelMenu={imageModelMenu} threadFooterSlot={threadFooterSlot} missingApiKeySetupLayout="sidebar" onCollapse={() => setOpenPersisted(false)} diff --git a/templates/assets/app/components/layout/Layout.tsx b/templates/assets/app/components/layout/Layout.tsx index 1a5956a57f..318ef694b0 100644 --- a/templates/assets/app/components/layout/Layout.tsx +++ b/templates/assets/app/components/layout/Layout.tsx @@ -9,11 +9,16 @@ import { useT, } from "@agent-native/core/client"; import { InvitationBanner } from "@agent-native/core/client/org"; +import { + EMBED_MODE_QUERY_PARAM, + EMBED_TOKEN_QUERY_PARAM, +} from "@agent-native/core/shared"; import { IconMenu2 } from "@tabler/icons-react"; import { useState, useEffect } from "react"; import { useLocation, useNavigate } from "react-router"; import { GenerationResults } from "@/components/generation/GenerationResults"; +import { useImageModelMenu } from "@/hooks/use-image-model-menu"; import { useNavigationState } from "@/hooks/use-navigation-state"; import { ASSETS_CHAT_STORAGE_KEY } from "@/lib/chat"; import { cn } from "@/lib/utils"; @@ -35,11 +40,22 @@ function isEmbeddedWindow() { } } +function searchParamsEnableEmbeddedMode(search: string): boolean { + const params = new URLSearchParams(search); + const embedMode = params.get(EMBED_MODE_QUERY_PARAM); + return ( + params.has(EMBED_TOKEN_QUERY_PARAM) || + embedMode === "1" || + embedMode === "true" + ); +} + export function Layout({ children }: LayoutProps) { useNavigationState(); const location = useLocation(); const navigate = useNavigate(); const t = useT(); + const imageModelMenu = useImageModelMenu(); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const isCreateRoute = location.pathname === "/" || location.pathname.startsWith("/chat/"); @@ -64,7 +80,10 @@ export function Layout({ children }: LayoutProps) { location.pathname === "/extensions" || location.pathname.startsWith("/extensions/"); const chromeless = - (isPicker && (isEmbeddedWindow() || isEmbedAuthActive())) || + (isPicker && + (searchParamsEnableEmbeddedMode(location.search) || + isEmbeddedWindow() || + isEmbedAuthActive())) || location.pathname.endsWith("/embed"); if (chromeless) { @@ -143,6 +162,7 @@ export function Layout({ children }: LayoutProps) { threadFooterSlot={({ threadId }) => ( )} + imageModelMenu={imageModelMenu} > {appFrame} diff --git a/templates/assets/app/hooks/use-image-model-menu.ts b/templates/assets/app/hooks/use-image-model-menu.ts new file mode 100644 index 0000000000..78effb8d34 --- /dev/null +++ b/templates/assets/app/hooks/use-image-model-menu.ts @@ -0,0 +1,74 @@ +import { + readClientAppState, + useT, + writeClientAppState, +} from "@agent-native/core/client"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +// The composer's model picker shows the chat LLM (Claude/OpenAI/Gemini). The +// Assets app also drives a separate image model, so expose it as a secondary +// menu wherever Assets chat is mounted. +const IMAGE_MODEL_STATE_KEY = "imageGenerationModel"; +const DEFAULT_IMAGE_MODEL = "gemini-3.1-flash-image"; +const IMAGE_MODEL_OPTIONS = [ + { + value: "gemini-3-pro-image", + modelName: "Gemini 3 Pro", + descriptorKey: "create.modelBestQuality", + }, + { + value: "gemini-3.1-flash-image", + modelName: "Gemini 3.1 Flash", + descriptorKey: "create.modelFast", + }, + { value: "gemini-2.5-flash-image", modelName: "Gemini 2.5 Flash" }, +] as const; + +export function useImageModelMenu() { + const t = useT(); + const [imageModel, setImageModel] = useState(DEFAULT_IMAGE_MODEL); + + // Hydrate the saved image-model default so the picker reflects the user's + // last choice across sessions. + useEffect(() => { + let cancelled = false; + void readClientAppState<{ model?: string }>(IMAGE_MODEL_STATE_KEY) + .then((state) => { + const stored = state?.model; + if ( + !cancelled && + stored && + IMAGE_MODEL_OPTIONS.some((option) => option.value === stored) + ) { + setImageModel(stored); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + const handleImageModelChange = useCallback((value: string) => { + setImageModel(value); + void writeClientAppState(IMAGE_MODEL_STATE_KEY, { model: value }).catch( + () => {}, + ); + }, []); + + return useMemo( + () => ({ + value: imageModel, + options: IMAGE_MODEL_OPTIONS.map((option) => ({ + value: option.value, + label: + "descriptorKey" in option && option.descriptorKey + ? `${option.modelName} · ${t(option.descriptorKey)}` + : option.modelName, + })), + onChange: handleImageModelChange, + label: t("create.imageModel"), + }), + [handleImageModelChange, imageModel, t], + ); +} diff --git a/templates/assets/app/routes/_index.tsx b/templates/assets/app/routes/_index.tsx index b2ffd28b8a..cf97e44824 100644 --- a/templates/assets/app/routes/_index.tsx +++ b/templates/assets/app/routes/_index.tsx @@ -2,40 +2,17 @@ import { AgentChatSurface, getBrowserTabId, markAgentChatHomeHandoff, - readClientAppState, sendToAgentChat, useT, - writeClientAppState, } from "@agent-native/core/client"; import { IconPhoto, IconSparkles, IconVideo } from "@tabler/icons-react"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect } from "react"; import { useNavigate, useParams } from "react-router"; import { GenerationResults } from "@/components/generation/GenerationResults"; +import { useImageModelMenu } from "@/hooks/use-image-model-menu"; import { ASSETS_CHAT_STORAGE_KEY } from "@/lib/chat"; -// The composer's model picker shows the chat LLM (Claude/OpenAI/Gemini). The -// Assets app also drives a separate *image* model, so we surface it in the same -// menu — otherwise "Claude" reads as the image generator, which it isn't. The -// choice persists in per-user application state so the generate-image action -// (server-side) can read it as the default model. Values must be valid -// IMAGE_MODELS ids from shared/api. -const IMAGE_MODEL_STATE_KEY = "imageGenerationModel"; -const DEFAULT_IMAGE_MODEL = "gemini-3.1-flash-image"; -const IMAGE_MODEL_OPTIONS = [ - { - value: "gemini-3-pro-image", - modelName: "Gemini 3 Pro", - descriptorKey: "create.modelBestQuality", - }, - { - value: "gemini-3.1-flash-image", - modelName: "Gemini 3.1 Flash", - descriptorKey: "create.modelFast", - }, - { value: "gemini-2.5-flash-image", modelName: "Gemini 2.5 Flash" }, -] as const; - // Empty-state starters. Clicking one prefills the composer (without sending) so // the user can finish the thought instead of staring at a chip that does // nothing. `submit: false` = prefill only; `openSidebar: false` keeps focus on @@ -81,7 +58,7 @@ export default function CreatePage() { const { threadId } = useParams(); const navigate = useNavigate(); const t = useT(); - const [imageModel, setImageModel] = useState(DEFAULT_IMAGE_MODEL); + const imageModelMenu = useImageModelMenu(); useEffect(() => { function handleChatRunning(event: Event) { @@ -96,34 +73,6 @@ export default function CreatePage() { window.removeEventListener("agentNative.chatRunning", handleChatRunning); }, []); - // Hydrate the saved image-model default so the picker reflects the user's - // last choice across sessions. - useEffect(() => { - let cancelled = false; - void readClientAppState<{ model?: string }>(IMAGE_MODEL_STATE_KEY) - .then((state) => { - const stored = state?.model; - if ( - !cancelled && - stored && - IMAGE_MODEL_OPTIONS.some((option) => option.value === stored) - ) { - setImageModel(stored); - } - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, []); - - const handleImageModelChange = useCallback((value: string) => { - setImageModel(value); - void writeClientAppState(IMAGE_MODEL_STATE_KEY, { model: value }).catch( - () => {}, - ); - }, []); - return (
( )} - imageModelMenu={{ - value: imageModel, - options: IMAGE_MODEL_OPTIONS.map((option) => ({ - value: option.value, - label: - "descriptorKey" in option && option.descriptorKey - ? `${option.modelName} · ${t(option.descriptorKey)}` - : option.modelName, - })), - onChange: handleImageModelChange, - label: t("create.imageModel"), - }} + imageModelMenu={imageModelMenu} showHeader={false} showTabBar={false} dynamicSuggestions={false} diff --git a/templates/assets/app/routes/library.tsx b/templates/assets/app/routes/library.tsx index f209566d1c..ec19adbd6a 100644 --- a/templates/assets/app/routes/library.tsx +++ b/templates/assets/app/routes/library.tsx @@ -1,4 +1,5 @@ import { + AgentToggleButton, appPath, getBrowserTabId, getEmbedAuthToken, @@ -18,6 +19,10 @@ import { createEmbeddedAppBridge, type EmbeddedAppBridge, } from "@agent-native/core/embedding"; +import { + EMBED_MODE_QUERY_PARAM, + EMBED_TOKEN_QUERY_PARAM, +} from "@agent-native/core/shared"; import { IconArrowUpRight, IconCheck, @@ -114,6 +119,7 @@ const ASPECT_RATIOS = ["16:9", "1:1", "9:16", "4:3", "3:4", "21:9"] as const; const GENERATION_COUNTS = [1, 2, 3, 4, 6] as const; const STARTER_PRESET = DEFAULT_LIBRARY_PRESETS[0]; const STARTER_LIBRARY_ID = `starter:${STARTER_PRESET.id}`; +const MCP_APP_CHAT_BRIDGE_QUERY_PARAM = "__an_mcp_chat_bridge"; const PICKER_INLINE_SELECT_CLASS = "h-7 w-auto min-w-0 max-w-full rounded-md border-0 bg-transparent px-1.5 py-1 text-xs font-medium text-muted-foreground shadow-none ring-offset-transparent transition hover:bg-accent/50 hover:text-foreground focus:ring-0 focus:ring-offset-0 sm:px-2 [&>svg]:ms-1 [&>svg]:size-3.5 [&>svg]:opacity-60"; type PickerMediaType = "image" | "video"; @@ -266,6 +272,24 @@ function normalizeCandidateRunIds(value: unknown): string[] | undefined { return ids; } +function searchParamsEnableEmbeddedLibrary(params: URLSearchParams): boolean { + const embedMode = params.get(EMBED_MODE_QUERY_PARAM); + return ( + params.has(EMBED_TOKEN_QUERY_PARAM) || + embedMode === "1" || + embedMode === "true" + ); +} + +function searchParamsRequestPicker(params: URLSearchParams): boolean { + const mcpChatBridge = params.get(MCP_APP_CHAT_BRIDGE_QUERY_PARAM); + return ( + params.get("__an_picker") === "1" || + mcpChatBridge === "1" || + mcpChatBridge === "true" + ); +} + function normalizeHostConfig(value: unknown): HostConfig { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; const record = value as Record; @@ -870,6 +894,7 @@ function LibraryShellHeader({ aria-label={t("library.kitActions")} /> ) : null} +
@@ -1892,32 +1917,34 @@ export function LibraryWorkspace({ return (
- {routeSelectedLibraryId || hasLibraries ? ( -
- setCreateOpen(true)} - /> - -
- {routeSelectedLibraryId ? ( - - ) : ( - - )} -
-
- ) : ( - setCreateOpen(true)} /> - )} +
+ setCreateOpen(true)} + /> + {routeSelectedLibraryId || hasLibraries ? ( + <> + +
+ {routeSelectedLibraryId ? ( + + ) : ( + + )} +
+ + ) : ( + setCreateOpen(true)} /> + )} +
{ const params = new URLSearchParams(searchParamsKey); return { @@ -1959,7 +1985,13 @@ export function AssetPickerSurface() { } satisfies HostConfig; }, [searchParamsKey]); const bridgeRef = useRef(null); - const embedded = useMemo(() => isEmbeddedWindow() || isEmbedAuthActive(), []); + const embedded = useMemo( + () => + searchParamsEnableEmbeddedLibrary(searchParams) || + isEmbeddedWindow() || + isEmbedAuthActive(), + [searchParams], + ); const pickerVariantScopeId = useMemo( () => typeof window === "undefined" ? null : `picker:${getBrowserTabId()}`, @@ -2862,14 +2894,17 @@ export function AssetPickerSurface() {
)} - {mediaType === "image" && selectedLibraryId && pickerVariantScopeId && ( - - )} + {mediaType === "image" && + selectedLibraryId && + !usingStarterLibrary && + pickerVariantScopeId && ( + + )} {!selectedLibraryId && (
@@ -3070,8 +3105,8 @@ export default function LibraryRoute() { const { pickerRequested, queryLibraryId } = useMemo(() => { const params = new URLSearchParams(searchParamsKey); const requested = - params.get("__an_picker") === "1" || - params.get("__an_mcp_chat_bridge") === "1"; + searchParamsRequestPicker(params) || + searchParamsEnableEmbeddedLibrary(params); return { pickerRequested: requested, queryLibraryId: requested ? null : params.get("libraryId"),