From 6e8b96d1353aa88695d1869590cb68d0e5f0197c Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 30 Jul 2026 20:04:45 -0400 Subject: [PATCH] =?UTF-8?q?fix(app):=20embed=20round-trip=20fixes=20?= =?UTF-8?q?=E2=80=94=20origin,=20dark=20bg,=20key=20fallback,=20CLI=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four issues found via live testing of the embedded Chat/AIUI panel (Archipelago phase 02-07 follow-up), each root-caused rather than patched over: 1. Loading overlay never dismissed: archyBridge.init() used window.location.origin (this iframe's OWN origin) as the target for postMessage calls TO the parent, instead of the parent's actual origin. Silently correct only when AIUI is served same-origin as its host (production's /aiui/ proxy) — broken the moment AIUI runs on a different origin than its embedding page (any dev setup with a separate AIUI dev server). The 'ready' message, and every permissions/theme/context/action response after it, was being dropped by the browser. Fixed by deriving the parent's real origin from document.referrer (archyBridge.ts). 2. White/black background instead of the branded look: initTheme() decides light/dark from localStorage or the OS's prefers-color- scheme, with no awareness of being embedded — App.vue now forces dark immediately on mount when embedded (before any handshake completes) and useArchy.ts's theme-update callback now applies Archy's reported mode too. Separately, body had no background-color at all, so ChatPage.vue's embedded `background: transparent` fell through to the browser's white UA default; main.css now paints body to match the active theme. And ChatPage.vue's embedded branch was opting out of the same background-image treatment the standalone dark app uses — it now shares that exact styling instead of a flat fallback color, matching the standalone look precisely. 3. Dead end when no AI provider credential is available: useAI.ts now emits a narrow, one-shot needsApiKey signal (401/403, "api key", "unauthorized", or a proxy-unreachable failure — deliberately not every transient error) that ChatWindow.vue watches to auto-open Settings, so the user lands on the fix instead of a silent/dead chat. 4. claude-proxy.ts's CLI fallback spawned a hardcoded ~/.local/bin/claude path, breaking with ENOENT on any machine where the CLI lives elsewhere (e.g. an nvm install). Now resolves via `command -v claude` first (an optional CLAUDE_BIN env override, then the historical path, then the bare command name as a last resort so spawn() itself can still try PATH), and surfaces an actionable in-UI error naming three ways to fix it when none resolve. Verified: full send→spawn→response round trip against the local proxy (both directly and through vite's /api/claude proxy), vue-tsc clean, vitest 332/335 passing (3 pre-existing unrelated failures, confirmed present before this commit too), production build clean with both chatExpanded/mobileChat flags and the new background rule present in the built assets. Co-Authored-By: Claude Fable 5 --- packages/app/server/claude-proxy.ts | 37 +++++++++++++++++-- packages/app/src/App.vue | 15 +++++++- .../app/src/components/chat/ChatWindow.vue | 15 +++++++- packages/app/src/composables/useAI.ts | 31 ++++++++++++++++ packages/app/src/composables/useArchy.ts | 8 +++- packages/app/src/pages/ChatPage.vue | 10 ++--- packages/app/src/services/archyBridge.ts | 28 +++++++++++++- packages/app/src/styles/main.css | 11 ++++++ 8 files changed, 142 insertions(+), 13 deletions(-) diff --git a/packages/app/server/claude-proxy.ts b/packages/app/server/claude-proxy.ts index 99c5cfff..f101d6a2 100644 --- a/packages/app/server/claude-proxy.ts +++ b/packages/app/server/claude-proxy.ts @@ -32,7 +32,33 @@ function loadEnv() { loadEnv() const PORT = 3141 -const CLAUDE_BIN = resolve(process.env.HOME ?? '', '.local/bin/claude') + +/** + * Resolve the `claude` CLI binary. The old hardcoded `~/.local/bin/claude` + * broke on any machine/install where the CLI lives elsewhere (e.g. an nvm + * Node install's own bin dir) — ENOENT on spawn with no obvious fix short of + * a manual symlink. Prefer a real PATH lookup, matching how a user would + * actually run `claude` themselves; fall back to the historical path for + * anyone relying on it, then to the bare command name so spawn() still gets + * a chance to resolve it via PATH at process-start time even if neither + * check above found it (e.g. PATH changes after this proxy boots). + */ +function resolveClaudeBin(): string { + if (process.env.CLAUDE_BIN && existsSync(process.env.CLAUDE_BIN)) { + return process.env.CLAUDE_BIN + } + try { + const found = execSync('command -v claude', { encoding: 'utf8' }).trim() + if (found) return found + } catch { + /* not resolvable via PATH right now — fall through */ + } + const legacy = resolve(process.env.HOME ?? '', '.local/bin/claude') + if (existsSync(legacy)) return legacy + return 'claude' +} + +const CLAUDE_BIN = resolveClaudeBin() const APP_URL = process.env.APP_URL ?? 'http://localhost:5173' /** API key (sk-ant-api03-...) or OAuth token (sk-ant-oat...) from Max subscription */ @@ -495,12 +521,17 @@ const server = createServer((req, res) => { if (msg) console.error('[proxy] stderr:', msg) }) - proc.on('error', (err) => { + proc.on('error', (err: NodeJS.ErrnoException) => { console.error('[proxy] spawn error:', err) if (!clientDisconnected) { + const message = err.code === 'ENOENT' + ? `Claude CLI not found (tried "${CLAUDE_BIN}"). Install it (npm i -g @anthropic-ai/claude-code), ` + + `make sure it's on PATH, or set CLAUDE_BIN to its full path in .env.local. ` + + `Alternatively, configure ANTHROPIC_API_KEY or ANTHROPIC_TOKEN in .env.local to skip the CLI entirely.` + : `Spawn error: ${err.message}` const errData = { type: 'error', - error: { message: `Spawn error: ${err.message}` }, + error: { message }, } res.write(`data: ${JSON.stringify(errData)}\n\n`) res.write('data: [DONE]\n\n') diff --git a/packages/app/src/App.vue b/packages/app/src/App.vue index 639151f7..e3865c52 100644 --- a/packages/app/src/App.vue +++ b/packages/app/src/App.vue @@ -31,9 +31,13 @@ import { setSessionKey, } from '@/utils/crypto' -const { currentTheme, initTheme } = useTheme() +const { currentTheme, initTheme, setTheme } = useTheme() const archy = useArchy() +// Captured before mount (main.ts), independent of the archyBridge handshake — +// forcing dark theme must not wait on any postMessage round trip completing. +const isEmbeddedFlag = !!(window as unknown as Record).__AIUI_EMBEDDED__ + const windowWidth = ref(window.innerWidth) const isMobile = computed(() => windowWidth.value < 1024) const { viewportHeight, isKeyboardOpen } = useVisualViewport() @@ -81,6 +85,15 @@ async function handlePassphraseSubmit(passphrase: string) { onMounted(() => { initTheme() + + // Archy is always dark-themed and AIUI's embedded chat is meant to match it + // exactly — never the browser/OS light-mode default or a stale + // localStorage('aiui-theme', 'light') from a prior standalone visit. This + // must not depend on the archyBridge 'ready'/theme handshake completing + // (that round trip can be slow or fail outright), so it runs unconditionally + // right after initTheme(), overriding whatever it just decided. + if (isEmbeddedFlag) setTheme('dark') + window.addEventListener('resize', onResize) // Initialize Archy bridge when running embedded in Archipelago diff --git a/packages/app/src/components/chat/ChatWindow.vue b/packages/app/src/components/chat/ChatWindow.vue index 384b9c75..d287a99d 100644 --- a/packages/app/src/components/chat/ChatWindow.vue +++ b/packages/app/src/components/chat/ChatWindow.vue @@ -185,7 +185,7 @@ defineEmits<{ }>() const chatStore = useChatStore() -const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel } = useAI() +const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel, needsApiKey } = useAI() const { updatePanelFromText, panelOpen, panelFilms, panelTitle, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel() import { useCodeContext } from '@/composables/useCodeContext' import { useVisualViewport } from '@/composables/useVisualViewport' @@ -197,6 +197,19 @@ const messageListRef = ref(null) const chatSearchRef = ref | null>(null) const showSettings = ref(false) +// A send/regenerate/edit failure that looks like a missing or invalid API +// key (see useAI.ts's looksLikeMissingApiKey) opens Settings automatically +// instead of leaving the user stuck on a silent/dead error with no obvious +// next step. One-shot: reset immediately after acting so a later retry that +// fails the same way can re-trigger it (the user may have closed Settings +// without fixing anything). +watch(needsApiKey, (needs) => { + if (needs) { + showSettings.value = true + needsApiKey.value = false + } +}) + // Scroll position memory per conversation const scrollPositions = new Map() diff --git a/packages/app/src/composables/useAI.ts b/packages/app/src/composables/useAI.ts index 31493c03..8826dfa2 100644 --- a/packages/app/src/composables/useAI.ts +++ b/packages/app/src/composables/useAI.ts @@ -119,6 +119,32 @@ const activeProvider = ref('claude') const activeModel = ref('claude-haiku-4.5') +// One-shot signal a send/regenerate/edit failure looked like a missing or +// invalid API key (or an unreachable proxy) rather than a transient/server +// error — consumed by ChatWindow.vue to auto-open Settings so the user isn't +// left in a dead end with no obvious next step. Deliberately narrow (401/403, +// explicit "api key"/"unauthorized" text, or a connection-level failure to +// reach the proxy at all) so a rate-limited or momentarily-flaky provider +// response does NOT send the user to Settings for a problem Settings can't +// fix. Reset to false by the consumer immediately after acting on it, so it +// behaves as a pulse rather than sticky state (each new failure can re-fire). +const needsApiKey = ref(false) + +function looksLikeMissingApiKey(err: string): boolean { + const lower = err.toLowerCase() + return ( + /\b(401|403)\b/.test(err) || + lower.includes('api key') || + lower.includes('x-api-key') || + lower.includes('unauthorized') || + lower.includes('authentication_error') || + lower.includes('failed to fetch') || + lower.includes('econnrefused') || + lower.includes(' 502') || + lower.includes(' 503') + ) +} + const availableProviders = computed(() => { const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [ { @@ -611,6 +637,7 @@ export function useAI() { const onError = (err: string) => { console.error(`[AIUI ${provider}]`, err) chatStore.appendToLastMessage(cid, `⚠ ${err}`) + if (provider !== 'mock' && looksLikeMissingApiKey(err)) needsApiKey.value = true } const genParams = getConversationParams(chatStore) @@ -628,6 +655,7 @@ export function useAI() { const msg = err instanceof Error ? err.message : String(err) console.error(`[AIUI] Connection error:`, err) chatStore.appendToLastMessage(cid, `\n\n⚠ Connection error: ${msg}`) + if (provider !== 'mock' && looksLikeMissingApiKey(msg)) needsApiKey.value = true } finally { currentAbort = null chatStore.isStreaming = false @@ -721,6 +749,7 @@ export function useAI() { const onError = (err: string) => { console.error(`[AIUI ${provider}]`, err) chatStore.appendToLastMessage(cid, `⚠ ${err}`) + if (provider !== 'mock' && looksLikeMissingApiKey(err)) needsApiKey.value = true } const genParams = getConversationParams(chatStore) @@ -737,6 +766,7 @@ export function useAI() { if (err instanceof DOMException && err.name === 'AbortError') return const msg = err instanceof Error ? err.message : String(err) chatStore.appendToLastMessage(cid, `\n\n⚠ Connection error: ${msg}`) + if (provider !== 'mock' && looksLikeMissingApiKey(msg)) needsApiKey.value = true } finally { currentAbort = null chatStore.isStreaming = false @@ -753,5 +783,6 @@ export function useAI() { availableProviders, setProvider, setModel, + needsApiKey, } } diff --git a/packages/app/src/composables/useArchy.ts b/packages/app/src/composables/useArchy.ts index 8779d510..32d73c13 100644 --- a/packages/app/src/composables/useArchy.ts +++ b/packages/app/src/composables/useArchy.ts @@ -1,5 +1,6 @@ import { ref, readonly } from 'vue' import { archyBridge } from '@/services/archyBridge' +import { useTheme } from '@/composables/useTheme' import { mockArchyApps, mockArchySystem, mockArchyNetwork, mockArchyWallet, mockArchyBitcoin, mockArchyFiles, @@ -109,10 +110,15 @@ export function useArchy() { }) cleanups.push(unsubPerms) - // Listen for theme updates + // Listen for theme updates — Archy always reports mode:'dark' today, but + // honor whatever it sends rather than hardcoding that assumption here; + // App.vue's mount-time isEmbeddedFlag check already forces dark + // immediately without waiting for this round trip, so this is a + // corroborating update for whenever it does arrive. const unsubTheme = archyBridge.onThemeUpdate((theme) => { accentColor.value = theme.accent applyAccentColor(theme.accent) + useTheme().setTheme(theme.mode) }) cleanups.push(unsubTheme) diff --git a/packages/app/src/pages/ChatPage.vue b/packages/app/src/pages/ChatPage.vue index 52581cc6..ece3d8eb 100644 --- a/packages/app/src/pages/ChatPage.vue +++ b/packages/app/src/pages/ChatPage.vue @@ -2,13 +2,13 @@
-
+