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:
@@ -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')
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
},
|
||||
|
||||
@@ -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 ===== */
|
||||
|
||||
Reference in New Issue
Block a user