fix(app): embed round-trip fixes — origin, dark bg, key fallback, CLI path

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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-30 20:04:45 -04:00
co-authored by Claude Fable 5
parent 900c0b9060
commit 6e8b96d135
8 changed files with 142 additions and 13 deletions
+34 -3
View File
@@ -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')
+14 -1
View File
@@ -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<string, unknown>).__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
@@ -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<HTMLElement | null>(null)
const chatSearchRef = ref<InstanceType<typeof ChatSearch> | 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<string, number>()
+31
View File
@@ -119,6 +119,32 @@ const activeProvider = ref<Provider>('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,
}
}
+7 -1
View File
@@ -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)
+5 -5
View File
@@ -2,13 +2,13 @@
<div
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
:class="[]"
:style="isEmbedded
? { background: 'transparent' }
: isDark
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
:style="isDark
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
: isEmbedded
? { background: 'transparent' }
: { backgroundColor: '#f5f4f1' }"
>
<div v-if="isDark && !isEmbedded" class="absolute inset-0 pointer-events-none bg-black/20" />
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
<!-- Desktop layout -->
<div
+26 -2
View File
@@ -91,6 +91,29 @@ function handleMessage(event: MessageEvent) {
}
}
/**
* The parent (Archy) page's origin — NOT this iframe's own origin. This is
* what `postToParent` must pass as postMessage's target origin, and what
* `handleMessage` must validate incoming messages against. Getting this
* backwards (using `window.location.origin`, this iframe's own origin) works
* by coincidence only when Archy serves AIUI same-origin (production's
* `/aiui/` proxy), and silently breaks the entire bridge — including the
* initial 'ready' message — the moment AIUI runs on a different origin than
* its host page (e.g. a local AIUI dev server embedded via a separate Archy
* dev server port). `document.referrer` is the standard, cross-origin-safe
* way an iframed document learns its embedding parent's URL.
*/
function deriveParentOrigin(): string {
if (typeof document !== 'undefined' && document.referrer) {
try {
return new URL(document.referrer).origin
} catch {
/* fall through to same-origin assumption below */
}
}
return window.location.origin
}
export const archyBridge = {
/**
* Initialize the bridge. Call once on app mount.
@@ -99,8 +122,9 @@ export const archyBridge = {
init(origin?: string) {
if (initialized) return
initialized = true
// Use explicit origin or derive from current location (self-only)
allowedOrigin = origin ?? window.location.origin
// Use an explicit origin if the caller has one, otherwise derive the
// parent's actual origin (not this iframe's own).
allowedOrigin = origin ?? deriveParentOrigin()
window.addEventListener('message', handleMessage)
postToParent({ type: 'ready' })
},
+11
View File
@@ -57,6 +57,17 @@ body {
width: 100%;
height: 100%;
overflow: hidden;
/* Every page paints its own explicit background (bg-[#0a0a0a] / bg-[#faf9f6])
EXCEPT the embedded Chat page, which intentionally goes transparent so
Archy's own dark chrome can show behind it (Chat.vue's iframe host). With
no background-color here, "transparent" fell through to the browser's
default white canvas instead. Match the theme's own dark/light default so
nothing above this ever needs to guess. */
background-color: #0a0a0a;
}
html.light body {
background-color: #faf9f6;
}
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */