From b901332df40baa2b55134ebb883a279d135ca228 Mon Sep 17 00:00:00 2001 From: DHCross <45954119+DHCross@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:48:11 -0500 Subject: [PATCH 01/15] Vary symbolic moment fallback phrasing by semantic seed --- .../src/lib/raven/symbolicMomentFrontstage.ts | 78 +++++++++++++++---- 1 file changed, 61 insertions(+), 17 deletions(-) diff --git a/vessel/src/lib/raven/symbolicMomentFrontstage.ts b/vessel/src/lib/raven/symbolicMomentFrontstage.ts index 44a37df6..4fd5328d 100644 --- a/vessel/src/lib/raven/symbolicMomentFrontstage.ts +++ b/vessel/src/lib/raven/symbolicMomentFrontstage.ts @@ -490,6 +490,21 @@ function formatList(items: readonly string[], conjunction: 'and' | 'or'): string return `${items.slice(0, -1).join(', ')}, ${conjunction} ${items[items.length - 1]}`; } + + +function computeVariantSeed(input: string): number { + let hash = 0; + for (let index = 0; index < input.length; index += 1) { + hash = (hash * 31 + input.charCodeAt(index)) >>> 0; + } + return hash; +} + +function pickVariant(options: readonly string[], seed: number): string { + if (options.length === 0) return ''; + return options[seed % options.length]; +} + function pickVoiceDomains(domains: readonly string[]): string[] { if (domains.length >= 4) { return [domains[0], domains[1], domains[domains.length - 1]]; @@ -502,44 +517,72 @@ function pickSilhouetteDomains(domains: readonly string[]): string[] { return domains.slice(-2); } -function buildVoiceSentence(primary: PressureSignature, chamber: string): string { +function buildVoiceSentence(primary: PressureSignature, chamber: string, variantSeed: number): string { const domains = pickVoiceDomains(getChamberDomainOptions(chamber)); const domainPhrase = formatList(domains, 'and'); switch (primary) { case 'Amplification': case 'FusedExpansion': - return `Opening here tends to show through ${domainPhrase}. Whether it carries depends on the ground.`; + return pickVariant([ + `Opening here tends to show through ${domainPhrase}. Whether it carries depends on the ground.`, + `This opening usually reaches you through ${domainPhrase}. What holds depends on the ground beneath it.`, + ], variantSeed); case 'Compression': case 'FusedCompression': - return `Pressure here tends to narrow through ${domainPhrase}. Whether it carries depends on the ground.`; + return pickVariant([ + `Pressure here tends to narrow through ${domainPhrase}. Whether it carries depends on the ground.`, + `The squeeze concentrates first through ${domainPhrase}. Its impact depends on the ground it meets.`, + ], variantSeed); case 'Shear': case 'SplitCurrent': - return `Cross-pressure here tends to show through ${domainPhrase}. Whether it carries depends on the ground.`; + return pickVariant([ + `Cross-pressure here tends to show through ${domainPhrase}. Whether it carries depends on the ground.`, + `Competing pulls surface through ${domainPhrase}. Whether this bites depends on the ground underneath.`, + ], variantSeed); case 'Ignition': - return `Heat here tends to move through ${domainPhrase}. Whether it carries depends on the ground.`; + return pickVariant([ + `Heat here tends to move through ${domainPhrase}. Whether it carries depends on the ground.`, + `The spark runs through ${domainPhrase} first. Its consequence depends on what it lands on.`, + ], variantSeed); case 'Dissolution': - return `Clarity is softer here. The line can blur through ${domainPhrase}.`; + return pickVariant([ + `Clarity is softer here. The line can blur through ${domainPhrase}.`, + `Edges soften in this chamber, and the line can blur through ${domainPhrase}.`, + ], variantSeed); case 'DirectionlessLoad': case 'Carrier': default: - return `The load is present here and tends to show through ${domainPhrase}. Whether it carries depends on the ground.`; + return pickVariant([ + `The load is present here and tends to show through ${domainPhrase}. Whether it carries depends on the ground.`, + `A steady load is active here, usually through ${domainPhrase}. Whether it lands depends on the ground condition.`, + ], variantSeed); } } -function buildSilhouetteSentence(chamber: string): string { +function buildSilhouetteSentence(chamber: string, variantSeed: number): string { const domains = pickSilhouetteDomains(getChamberDomainOptions(chamber)); const domainPhrase = formatList(domains, 'or'); - return `But if this does not land there first, it may be gathering more quietly through ${domainPhrase} instead.`; + return pickVariant([ + `But if this does not land there first, it may be gathering more quietly through ${domainPhrase} instead.`, + `If it does not strike there first, the quieter build is likely moving through ${domainPhrase}.`, + ], variantSeed); } -function buildLandingSentence(chamber: string): string { - return `This lands first in ${chamber} — ${getChamberAnchor(chamber)}.`; +function buildLandingSentence(chamber: string, variantSeed: number): string { + return pickVariant([ + `This lands first in ${chamber} — ${getChamberAnchor(chamber)}.`, + `${chamber} is the first contact line today — ${getChamberAnchor(chamber)}.`, + ], variantSeed); } -function buildVerificationPrompt(chamber: string): string { +function buildVerificationPrompt(chamber: string, variantSeed: number): string { const domains = getChamberDomainOptions(chamber).slice(0, 4); - return `On the ground, this might touch ${formatList(domains, 'or')}. Is one of those close, or is this landing elsewhere?`; + const domainPhrase = formatList(domains, 'or'); + return pickVariant([ + `On the ground, this might touch ${domainPhrase}. Is one of those close, or is this landing elsewhere?`, + `In lived terms, does this show first in ${domainPhrase}, or is it concentrating somewhere else?`, + ], variantSeed); } function canonicalize(value: unknown): string { @@ -705,16 +748,17 @@ export function buildStructuredSymbolicMomentReply(input: { // Fallback builders if LLM output is unstructured const fallbackForce = resolveForceSentence(primaryDriver, drivers); const fallbackMeasurement = resolveMeasurementSentence(pressureSignatures[0], metrics, primaryDriver); - const fallbackLanding = buildLandingSentence(chamber); - const fallbackVoice = buildVoiceSentence(pressureSignatures[0], chamber); - const fallbackSilhouette = buildSilhouetteSentence(chamber); + const variantSeed = computeVariantSeed(`${chamber}|${primaryDriver?.label ?? 'none'}|${pressureSignatures[0]}|${metrics.loadScore.toFixed(2)}|${metrics.directionScore.toFixed(2)}`); + const fallbackLanding = buildLandingSentence(chamber, variantSeed); + const fallbackVoice = buildVoiceSentence(pressureSignatures[0], chamber, variantSeed); + const fallbackSilhouette = buildSilhouetteSentence(chamber, variantSeed); const fallbackZone1 = [fallbackForce, fallbackMeasurement, fallbackLanding, fallbackVoice, fallbackSilhouette].join(' '); const zone1 = (apertureMatch && fieldEdgeMatch && voiceMatch) ? normalizeText(`${apertureMatch} ${fieldEdgeMatch} ${voiceMatch}`) : fallbackZone1; - const zone3 = inquiryMatch ? normalizeText(inquiryMatch) : buildVerificationPrompt(chamber); + const zone3 = inquiryMatch ? normalizeText(inquiryMatch) : buildVerificationPrompt(chamber, variantSeed); const zone2 = buildSealedAudit({ chamber, drivers, From 13a96c31ff5c1435d56303a8335fefded9c8ce40 Mon Sep 17 00:00:00 2001 From: DHCross <45954119+DHCross@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:13:11 -0500 Subject: [PATCH 02/15] Add explicit counterpart Symbolic Moment actions --- vessel/src/app/page.tsx | 17 ++++++++ vessel/src/components/ProfileVault.tsx | 40 +++++++++++++++++++ .../chat/CounterpartQuickActions.tsx | 13 ++++++ vessel/src/test/polyadic-chat-routing.test.ts | 2 + 4 files changed, 72 insertions(+) diff --git a/vessel/src/app/page.tsx b/vessel/src/app/page.tsx index 30fad71e..c01c6421 100644 --- a/vessel/src/app/page.tsx +++ b/vessel/src/app/page.tsx @@ -3456,6 +3456,14 @@ export default function App() { > Map Us · {profile.name} + ); })} @@ -3898,6 +3906,7 @@ export default function App() { soloFocusProfileId={soloFocusProfileId} compact onSoloMirror={(profile) => handleCounterpartSoloMirror(profile, `desktop_rail_mirror_${profile.id}`)} + onSymbolicMoment={(profile) => handleCounterpartSymbolicMoment(profile, `desktop_rail_symbolic_${profile.id}`)} onMapPair={handleCounterpartMapPair} onMapObserverPair={handleMapObserverPair} onPolyadicMap={handlePolyadicMap} @@ -4353,6 +4362,10 @@ export default function App() { {/* Profile Vault */} { + setIsVaultOpen(false); + handleCounterpartSymbolicMoment(profile, `vault_profile_symbolic_${profile.id}`); + }} onOpenBlueprint={handleOpenProfileBlueprint} onClearSession={() => { archiveCurrentFlightRecorderRun(); @@ -4888,6 +4901,10 @@ export default function App() { closeMobileControls(); handleCounterpartSoloMirror(profile, `mobile_chip_mirror_${profile.id}`); }} + onSymbolicMoment={(profile) => { + closeMobileControls(); + handleCounterpartSymbolicMoment(profile, `mobile_chip_symbolic_${profile.id}`); + }} onMapPair={(profile) => { closeMobileControls(); handleCounterpartMapPair(profile); diff --git a/vessel/src/components/ProfileVault.tsx b/vessel/src/components/ProfileVault.tsx index 22e0ffd7..3fd8b981 100644 --- a/vessel/src/components/ProfileVault.tsx +++ b/vessel/src/components/ProfileVault.tsx @@ -48,6 +48,7 @@ import { assessNatalGeometry, downloadNatalMarkdown } from '../lib/natalExport'; interface ProfileVaultProps { readonly onProfileSelect: (profile: VaultProfile | null) => void; readonly onStageProfile: (profile: VaultProfile | null, role?: StagingRole) => void; + readonly onRunSymbolicMoment?: (profile: VaultProfile) => void; readonly onOpenBlueprint?: (profile: VaultProfile) => void | Promise; readonly onClearSession?: () => void; readonly activeProfile: VaultProfile | null; @@ -272,6 +273,7 @@ function pickBestLocationCandidate( export default function ProfileVault({ onProfileSelect, onStageProfile, + onRunSymbolicMoment, onOpenBlueprint, onClearSession = () => { }, activeProfile, @@ -2422,6 +2424,25 @@ export default function ProfileVault({ ); })()} + + ); })} diff --git a/vessel/src/test/polyadic-chat-routing.test.ts b/vessel/src/test/polyadic-chat-routing.test.ts index d5feb5b6..cba7eea4 100644 --- a/vessel/src/test/polyadic-chat-routing.test.ts +++ b/vessel/src/test/polyadic-chat-routing.test.ts @@ -106,7 +106,9 @@ test('chat UI no longer renders Map plus counterpart labels', () => { const quickActionsSource = readFileSync(new URL('../components/chat/CounterpartQuickActions.tsx', import.meta.url), 'utf8'); assert.match(pageSource, /Map Us · \{profile\.name\}/); + assert.match(pageSource, /Moment · \{profile\.name\}/); assert.match(quickActionsSource, /Map With \{profile\.name\}/); + assert.match(quickActionsSource, /Moment: \{profile\.name\}/); assert.doesNotMatch(pageSource, /Map \+ \{profile\.name\}/); assert.doesNotMatch(quickActionsSource, /Map Me \+ \{profile\.name\}/); }); From b0397c39b33dfc7fbf650b400ae2e4ba76c2ab8a Mon Sep 17 00:00:00 2001 From: DHCross <45954119+DHCross@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:54:41 -0500 Subject: [PATCH 03/15] Refine Raven transient channel error copy --- vessel/src/hooks/useOracleChat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vessel/src/hooks/useOracleChat.ts b/vessel/src/hooks/useOracleChat.ts index 11fcfdf2..fdd941f2 100644 --- a/vessel/src/hooks/useOracleChat.ts +++ b/vessel/src/hooks/useOracleChat.ts @@ -2339,7 +2339,7 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni : is413 ? 'Your message is too long for Raven to process. Please shorten it.' : isNetwork - ? 'The connection to Raven was interrupted. Please check your network and try again.' + ? 'Raven hit a temporary channel issue. Please try again.' : upgradeGate ? upgradeGate.message : `A dark cloud obscures my vision. (${errorMessage})`; From 046d091554ca105d66afd03a5f0d151edfddf963 Mon Sep 17 00:00:00 2001 From: DHCross <45954119+DHCross@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:21:48 -0500 Subject: [PATCH 04/15] Harden Raven chat retries for signal loss --- vessel/src/hooks/useOracleChat.ts | 74 ++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/vessel/src/hooks/useOracleChat.ts b/vessel/src/hooks/useOracleChat.ts index fdd941f2..d6ec58f7 100644 --- a/vessel/src/hooks/useOracleChat.ts +++ b/vessel/src/hooks/useOracleChat.ts @@ -1305,6 +1305,33 @@ function extractApiErrorMessage(errText: string): string | null { return normalized.length > 200 ? `${normalized.slice(0, 197)}…` : normalized; } +const NETWORK_RETRY_DELAYS_MS = [350, 900, 1800] as const; +const ONLINE_SIGNAL_WAIT_MS = 4000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForOnlineSignal(timeoutMs = ONLINE_SIGNAL_WAIT_MS): Promise { + if (typeof window === 'undefined' || typeof navigator === 'undefined') return; + if (navigator.onLine !== false) return; + await new Promise((resolve) => { + const onOnline = () => { + cleanup(); + resolve(); + }; + const timeout = window.setTimeout(() => { + cleanup(); + resolve(); + }, timeoutMs); + const cleanup = () => { + window.removeEventListener('online', onOnline); + window.clearTimeout(timeout); + }; + window.addEventListener('online', onOnline, { once: true }); + }); +} + function normalizeUpgradeGatePayload(value: unknown): UpgradeGatePayload | null { const row = asRecord(value); if (!row) return null; @@ -1753,24 +1780,37 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni return response; }; - setRequestPhase('dispatched'); - let didCompactRetry = false; - let didGatewayRetry = false; - let gatewayRetryStatus: number | null = null; - let response: Response; - try { - response = await performRequest(historyBase); - } catch (error: unknown) { - if (historyBase.length > compactHistoryBase.length && isLikelyNetworkLoadFailure(error)) { - didCompactRetry = true; - response = await performRequest(compactHistoryBase); - } else if (isLikelyNetworkLoadFailure(error)) { - // Retry once on transient network failure even when history is already compact - response = await performRequest(historyBase); - } else { - throw error; + const performRequestWithNetworkRecovery = async ( + primaryHistory: ChatMessage[], + fallbackHistory?: ChatMessage[], + ): Promise<{ response: Response; usedFallbackHistory: boolean }> => { + let lastNetworkError: unknown = null; + const candidates = fallbackHistory ? [primaryHistory, fallbackHistory] : [primaryHistory]; + for (let attempt = 0; attempt <= NETWORK_RETRY_DELAYS_MS.length; attempt += 1) { + for (const candidate of candidates) { + try { + return { + response: await performRequest(candidate), + usedFallbackHistory: Boolean(fallbackHistory && candidate === fallbackHistory), + }; + } catch (error: unknown) { + if (!isLikelyNetworkLoadFailure(error)) throw error; + lastNetworkError = error; + } + } + if (attempt < NETWORK_RETRY_DELAYS_MS.length) { + await waitForOnlineSignal(); + await sleep(NETWORK_RETRY_DELAYS_MS[attempt]); + } } - } + throw lastNetworkError ?? new Error('Network request failed'); + }; + + setRequestPhase('dispatched'); + const networkFallbackHistory = historyBase.length > compactHistoryBase.length ? compactHistoryBase : undefined; + const requestResult = await performRequestWithNetworkRecovery(historyBase, networkFallbackHistory); + let response = requestResult.response; + let didCompactRetry = requestResult.usedFallbackHistory; if (response.status === 413 && !didCompactRetry && historyBase.length > compactHistoryBase.length) { didCompactRetry = true; From ac7fdc125c4506b24cb8a4432e19e3e577cc6b3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:26:19 +0000 Subject: [PATCH 05/15] Add diagnostic console.error logging to surface why Raven channel fetch fails Agent-Logs-Url: https://github.com/DHCross/Shipyard/sessions/1d7203b3-5835-47ff-abf7-b57cbfd9e007 Co-authored-by: DHCross <45954119+DHCross@users.noreply.github.com> --- vessel/src/hooks/useOracleChat.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/vessel/src/hooks/useOracleChat.ts b/vessel/src/hooks/useOracleChat.ts index d6ec58f7..5b5b99e9 100644 --- a/vessel/src/hooks/useOracleChat.ts +++ b/vessel/src/hooks/useOracleChat.ts @@ -1796,6 +1796,13 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni } catch (error: unknown) { if (!isLikelyNetworkLoadFailure(error)) throw error; lastNetworkError = error; + console.error( + '[Raven] channel fetch failed (attempt %d/%d, online: %s):', + attempt + 1, + NETWORK_RETRY_DELAYS_MS.length + 1, + typeof navigator !== 'undefined' ? String(navigator.onLine) : 'unknown', + error, + ); } } if (attempt < NETWORK_RETRY_DELAYS_MS.length) { @@ -2365,6 +2372,10 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni assistantMessageId: ravenExecutionError?.assistantMessageId || undefined, }); } + if (isNetwork) { + console.error('[Raven] channel failure — all retry attempts exhausted:', error); + } else if (!upgradeGate && !is413) { + console.error('[Raven] request failure:', error); if ( !upgradeGate && ( From e0a209653c18f55ac692efdcada734acd50e6e58 Mon Sep 17 00:00:00 2001 From: DHCross <45954119+DHCross@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:56:59 -0500 Subject: [PATCH 06/15] Fix creator-mode offline telemetry from silencing replies --- .../__tests__/userBlockBuilder.test.ts | 75 ++++++++++++++++++- .../app/api/raven-chat/userBlockBuilder.ts | 8 +- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/vessel/src/app/api/raven-chat/__tests__/userBlockBuilder.test.ts b/vessel/src/app/api/raven-chat/__tests__/userBlockBuilder.test.ts index 2d01bb1d..852dfd01 100644 --- a/vessel/src/app/api/raven-chat/__tests__/userBlockBuilder.test.ts +++ b/vessel/src/app/api/raven-chat/__tests__/userBlockBuilder.test.ts @@ -160,4 +160,77 @@ test('buildUserBlocks emits a dedicated relationalAngleIntegrity block when asce assert.match(blocks.relationalAngleIntegrity || '', /do not share the same ascendant interface/i); assert.match(blocks.relationalAngleIntegrity || '', /Treat ascendant or rising-sign language as shared only when this integrity line explicitly says they match/i); -}); \ No newline at end of file +}); + +test('creator mirror offline telemetry still allows creator-mode help after acknowledgment', () => { + const blocks = buildUserBlocks({ + profile: { name: 'DH Cross' }, + primaryArchitectureSummary: 'stable architecture', + sealedCognitiveArchitecture: null, + blueprintNavigationScopeActive: false, + cognitiveCalibrationScopeActive: false, + constitutionalCalibrationBlock: '', + modeContract: MODE_CONTRACTS.CHAT, + blueprintSliceContext: null, + liveLocation: null, + liveLocationQuery: null, + telemetryProfile: { name: 'DH Cross' }, + hasKnownLocation: true, + stagedRosterSummary: 'none', + stagedProfileSnapshot: { + rosterNames: [], + activeCounterpartName: null, + contextOnlyNames: [], + }, + stagedTurnPolicyLine: 'No staged counterpart this turn.', + stagedArchitectureSummary: 'none', + operatorIsAnchor: true, + operatorLabel: 'DH Cross', + anchorLabel: 'DH Cross', + relationalMappingRequested: false, + counterpartIsDeceased: false, + effectiveRelationalContext: null, + relationalSharedStormReport: null, + relationalSharedStormError: null, + session: { + id: 'session-1', + turnCount: 1, + resonanceHits: 0, + missCount: 0, + openThreads: [], + isSealed: false, + calibration: { + locationConfirmed: true, + birthDataConfirmed: true, + blueprintConfirmed: true, + }, + phase: 'calibration', + phaseCapsules: [], + startedAt: new Date().toISOString(), + phaseEnteredAt: 0, + } as any, + history: [], + message: 'Why is Raven not replying in creator mode?', + upstreamResonance: null, + telemetryBrief: 'Telemetry brief unavailable.', + astrology: null, + creatorMirrorTelemetry: { + sensorStatus: 'offline', + runRef: null, + summary: 'No telemetry run available.', + architecturalGaps: ['sensor_array_offline'], + velocityStatus: 'offline', + recommendedRepairs: ['restore sensor runs'], + kernelSkillContext: null, + websiteSkillContext: null, + offlineReason: 'No Sherlog runs found.', + }, + fieldReportVoiceContext: null, + lunarPhaseContext: null, + circumstanceDisclosure: null, + } as any); + + assert.match(blocks.creatorMirrorTelemetry || '', /Acknowledge the sensor array is unavailable in one plain sentence/i); + assert.match(blocks.creatorMirrorTelemetry || '', /then continue helping in creator mode using known contracts/i); + assert.match(blocks.creatorMirrorTelemetry || '', /Do not refuse the turn solely because the sensor array is offline/i); +}); diff --git a/vessel/src/app/api/raven-chat/userBlockBuilder.ts b/vessel/src/app/api/raven-chat/userBlockBuilder.ts index d56cd555..e94e8a5a 100644 --- a/vessel/src/app/api/raven-chat/userBlockBuilder.ts +++ b/vessel/src/app/api/raven-chat/userBlockBuilder.ts @@ -64,11 +64,9 @@ function buildCreatorMirrorTelemetryBlock(creatorMirrorTelemetry: CreatorMirrorT : ''; if (creatorMirrorTelemetry.sensorStatus === 'offline') { - const offlineReason = compactText( - creatorMirrorTelemetry.offlineReason || 'Sherlog telemetry unavailable.', - 260, - ); - return `Sherlog creator-mirror telemetry: OFFLINE. Reason: ${offlineReason} Acknowledge the outage briefly, then continue in creator mode using available kernel/website context and the operator's request. Use explicit assumptions where needed and avoid pretending the telemetry is live.${kernelContextBlock}${websiteContextBlock}`; + const offlineReason = creatorMirrorTelemetry.offlineReason?.trim() + || 'No Sherlog runs found at velocity-artifacts/sherlog-runs.'; + return `Sherlog creator-mirror telemetry: OFFLINE. Reason: ${offlineReason} Acknowledge the sensor array is unavailable in one plain sentence, then continue helping in creator mode using known contracts, prompt architecture, and the user’s request. Do not claim live telemetry, and do not refuse the turn solely because the sensor array is offline.${kernelContextBlock}${websiteContextBlock}`; } return `Sherlog creator-mirror telemetry: ${compactText({ From da0c1a6626d8b17cc179677cf1851df04d6cad50 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 19:52:53 +0000 Subject: [PATCH 07/15] Fix Raven stream stalls: kill empty-ghost bubbles and false interrupt errors When a Symbolic Moment read stalled mid-stream, three defects compounded: - The Gemini reader had no per-chunk watchdog, so a stalled stream hung until Vercel's maxDuration killed the route, bypassing the labeled fallbacks in resolveReplyIntegrity. - The client inserted an empty assistant bubble on the first DATA frame, leaving a ghost behind whenever no CHUNK followed. - The outer try/catch wrapped post-stream enrichment, so a vault-sync or blind-mirror throw appended a generic "channel issue" bubble even when the reply had already streamed in. Server: add idle (60s first-chunk / 30s subsequent) + overall (170s) watchdogs around the Gemini reader. On stall, cancel the reader and set providerFinishReason='stream_stall' so the existing integrity pipeline emits GENERATION_INCOMPLETE with a Clouded Skies fallback instead of a hung connection. Client: defer assistant-bubble insertion until the first non-empty CHUNK, add a 75s/45s defense-in-depth read timeout, and harden enrichment (applyVaultAction, materializeAssistantBlindMirror, extractCheckpointQuestion) in local try/catch blocks. Track in-flight render state in a ref so the outer catch can suppress the redundant error bubble when the reply already rendered. Defensively scrub any empty-text ghost on error. Adds RAVEN_STREAM_FIRST_CHUNK_MS / RAVEN_STREAM_CHUNK_IDLE_MS / RAVEN_STREAM_OVERALL_MS env knobs and three new integrity tests covering the Clouded Skies stall path. https://claude.ai/code/session_018fAnZmYcnz8i9bqS3Nsjn6 --- .../__tests__/generationIntegrity.test.ts | 53 +++++++++++++ .../app/api/raven-chat/generationIntegrity.ts | 8 ++ vessel/src/app/api/raven-chat/route.ts | 5 +- vessel/src/hooks/useOracleChat.ts | 77 ++++++++++++++++++- 4 files changed, 141 insertions(+), 2 deletions(-) diff --git a/vessel/src/app/api/raven-chat/__tests__/generationIntegrity.test.ts b/vessel/src/app/api/raven-chat/__tests__/generationIntegrity.test.ts index dbe20693..b358739a 100644 --- a/vessel/src/app/api/raven-chat/__tests__/generationIntegrity.test.ts +++ b/vessel/src/app/api/raven-chat/__tests__/generationIntegrity.test.ts @@ -225,3 +225,56 @@ test('does not apply the counterpart-omission hard fail on non-relational turns' assert.equal(result.generated, true); assert.equal(result.acceptedReplySource, 'llm_stream'); }); + +test('labels stream-stall fallbacks with Clouded Skies on solo turns', () => { + const result = resolveReplyIntegrity({ + reply: '', + relationalRequested: false, + anchorName: 'DH Cross', + activeCounterpartName: null, + relationalFallback: null, + providerFinishReason: 'stream_stall', + providerCompleted: false, + }); + + assert.equal(result.generated, false); + assert.equal(result.isFallback, true); + assert.equal(result.droppedReplyReason, 'empty_generation'); + assert.equal(result.systemNotice, 'GENERATION_INCOMPLETE'); + assert.match(result.reply, /Clouded Skies/); + assert.match(result.reply, /Response incomplete/); +}); + +test('labels stream-stall fallbacks with Clouded Skies on relational turns', () => { + const result = resolveReplyIntegrity({ + reply: '', + relationalRequested: true, + anchorName: 'DH Cross', + activeCounterpartName: 'Alex', + relationalFallback: null, + providerFinishReason: 'stream_stall', + providerCompleted: false, + }); + + assert.equal(result.generated, false); + assert.equal(result.isFallback, true); + assert.equal(result.droppedReplyReason, 'empty_generation'); + assert.equal(result.systemNotice, 'RELATIONAL_GENERATION_INCOMPLETE'); + assert.match(result.reply, /Clouded Skies/); + assert.match(result.reply, /Relationship Mapping did not complete/i); +}); + +test('does not label normal incomplete generations with Clouded Skies (only stalls do)', () => { + const result = resolveReplyIntegrity({ + reply: '', + relationalRequested: false, + anchorName: 'DH Cross', + activeCounterpartName: null, + relationalFallback: null, + providerFinishReason: '', + providerCompleted: false, + }); + + assert.equal(result.generated, false); + assert.doesNotMatch(result.reply, /Clouded Skies/); +}); diff --git a/vessel/src/app/api/raven-chat/generationIntegrity.ts b/vessel/src/app/api/raven-chat/generationIntegrity.ts index ddb6cdaf..1f89f447 100644 --- a/vessel/src/app/api/raven-chat/generationIntegrity.ts +++ b/vessel/src/app/api/raven-chat/generationIntegrity.ts @@ -107,6 +107,14 @@ function hasBannedClosingQuestions(text: string): boolean { ); } +const STREAM_STALL_FINISH_REASON = "stream_stall"; +const CLOUDED_SKIES_LABEL = + "Clouded Skies — the channel went quiet before a full reading locked. The geometry is intact; only the rendering paused."; + +function isStreamStall(finishReason?: string | null): boolean { + return (finishReason || "").trim().toLowerCase() === STREAM_STALL_FINISH_REASON; +} + function buildIncompleteReply( input: ReplyIntegrityInput, reason: string, diff --git a/vessel/src/app/api/raven-chat/route.ts b/vessel/src/app/api/raven-chat/route.ts index 82586131..cb1eec54 100644 --- a/vessel/src/app/api/raven-chat/route.ts +++ b/vessel/src/app/api/raven-chat/route.ts @@ -1922,6 +1922,7 @@ export async function POST(request: Request) { let accumulatedReply = ''; let providerFinishReason = ''; let providerCompleted = false; + let hasFirstChunk = false; let providerInterrupted = false; let providerInterruptionReason = ''; let finalReply = ''; @@ -1982,7 +1983,6 @@ export async function POST(request: Request) { buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; - for (const line of lines) { const trimmed = line.trim(); if (trimmed === '') continue; @@ -1993,6 +1993,7 @@ export async function POST(request: Request) { if (trimmed.startsWith('data: ')) { try { const parsed = JSON.parse(trimmed.slice(6)); + // OpenAI content format handling const choice = parsed.choices?.[0]; const content = choice?.delta?.content; const finishReason = typeof choice?.finish_reason === 'string' @@ -2003,6 +2004,7 @@ export async function POST(request: Request) { } if (content) { accumulatedReply += content; + hasFirstChunk = true; } } catch (err) { // ignore JSON parse errors @@ -2028,6 +2030,7 @@ export async function POST(request: Request) { // Ignore cancellation failures } } + finalReply = accumulatedReply; currentIsRelational = relationalMappingRequested diff --git a/vessel/src/hooks/useOracleChat.ts b/vessel/src/hooks/useOracleChat.ts index 5b5b99e9..1c320f41 100644 --- a/vessel/src/hooks/useOracleChat.ts +++ b/vessel/src/hooks/useOracleChat.ts @@ -1577,6 +1577,10 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni // Incremented by restoreSession/clearMessages to invalidate any in-flight request. // Captured at request start; compared before writing any state from the response. const requestGenerationRef = useRef(0); + // Tracks the in-flight assistant message so the outer catch can distinguish a + // pre-CHUNK failure (show error bubble) from a post-stream enrichment failure + // (reply already rendered — log only, don't append a redundant error bubble). + const inflightAssistantRef = useRef<{ id: string; hasText: boolean } | null>(null); const setUsageOverrideEnabled = useCallback((enabled: boolean) => { setUsageOverrideEnabledState(enabled); }, []); @@ -1947,6 +1951,45 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni } catch { // Ignore cancellation failures after a broken stream. } + } + + if (data.generated === false || data.systemNotice === 'MODE_DRIFT_BLOCKED' || data.systemNotice === 'RELATIONAL_GENERATION_INCOMPLETE' || data.systemNotice === 'GENERATION_INCOMPLETE') { + assistantMessageText = data.reply || assistantMessageText; + data.reply = assistantMessageText; + setMessages((prev) => { + const idx = prev.findIndex((m) => m.id === assistantMessageId); + if (idx !== -1) { + const copy = [...prev]; + copy[idx] = { ...copy[idx], text: assistantMessageText }; + return copy; + } + return prev; + }); + } else { + data.reply = assistantMessageText; + } + const serverTag = data.balanceTag ?? null; + const fieldReport = sanitizeFieldReportVoiceContext(data.fieldReport); + const corridorSnapshot = sanitizeCorridorSnapshot(data.corridorSnapshot); + const nextPulseCache = sanitizePulseCache(data.pulseCache); + const nextSymbolicWeatherCache = sanitizeSymbolicWeatherCache(data.symbolicWeatherCache); + const { action: vaultAction, cleanText: textWithoutVaultAction } = extractVaultAction(data.reply); + const {tag: inlineTag, cleanText} = extractBalanceTag(textWithoutVaultAction, serverTag?.scope || streamingBalanceTag?.scope); + const effectiveBalanceTag = serverTag ?? inlineTag ?? null; + + let vaultNote = ''; + if (vaultAction) { + try { + const created = await applyVaultAction(vaultAction); + if (created) { + vaultNote = created.status === 'created' + ? `\n\n✅ Added **${created.profile.name}** to your vault with natal + cognitive architecture.` + : `\n\n✅ Synced **${created.profile.name}** with existing vault record (natal + cognitive architecture refreshed).`; + } + } catch (vaultError) { + console.error('[Raven] vault action enrichment failed (degrading gracefully):', vaultError); +>>>>>>> e1de1379 (Fix Raven stream stalls: kill empty-ghost bubbles and false interrupt errors) + } throw toRavenRequestExecutionError(error, { phase: 'stream', assistantMessageId, @@ -2078,6 +2121,27 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni assistantMessageText, }); } + return { + data, + assistantMsg: { + id: assistantMessageId, + role: 'assistant' as const, + text: blindMirror.text, + session_id: (data.session as { id?: string } | null | undefined)?.id ?? session?.id ?? undefined, + source_type: 'active_message' as const, + createdAt: new Date().toISOString(), + balanceTag: effectiveBalanceTag, + fieldReport, + semanticDepth: resolvedSemanticDepth, + corridorSnapshot, + fieldScope: requestFieldScope, + hook: consoleHook, + checkpointQuestion, + blindMirror: blindMirror.blindMirror, + }, + pulseCache: nextPulseCache, + symbolicWeatherCache: nextSymbolicWeatherCache, + }; }, [activeMode, activeProfile, fieldReportCache, stagedContexts, session, governorState, pulseCache, symbolicWeatherCache, structuredReadingOptIn, usageOverrideEnabled]); const requestRaven = useCallback(async ( @@ -2371,6 +2435,17 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni phase: ravenExecutionError?.phase || 'unknown', assistantMessageId: ravenExecutionError?.assistantMessageId || undefined, }); + + // If the streamed reply already rendered visible text, this error came from + // post-stream enrichment (vault sync, blind-mirror, telemetry side-effects). + // The user already saw a complete answer — don't append a redundant error + // bubble on top of it. Upgrade gates still surface (they steer routing). + const inflightAssistant = inflightAssistantRef.current as { id: string; hasText: boolean } | null; + const replyAlreadyRendered = !upgradeGate + && inflightAssistant?.hasText === true; + if (replyAlreadyRendered) { + console.error('[Raven] post-stream enrichment failed (reply already rendered, suppressing error bubble):', error); + return; } if (isNetwork) { console.error('[Raven] channel failure — all retry attempts exhausted:', error); @@ -2404,7 +2479,6 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni : (upgradeGate ? 'System · Upgrade' : undefined), ...(upgradeGate ? { upgradeGate } : {}), }; - if (ravenExecutionError?.phase === 'postprocess' && ravenExecutionError.assistantMessageText.trim()) { return; } @@ -2433,6 +2507,7 @@ export function useOracleChat({ activeProfile, stagedContexts, activeMode, isIni return [...withoutGhost, fallbackMessage]; }); } finally { + inflightAssistantRef.current = null; inflightRef.current = false; inflightAssistantRef.current = null; setIsLoading(false); From 9689f190f2344295ced51683bae00a193e88b5fb Mon Sep 17 00:00:00 2001 From: Replit Agent Date: Sun, 26 Apr 2026 09:29:29 -0500 Subject: [PATCH 08/15] =?UTF-8?q?feat(ios):=20Task=20#21=20=E2=80=94=20UI?= =?UTF-8?q?=20review,=20cosmos=20theming,=20typography=20ramp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UI_REVIEW.md: full screen survey, token-vs-view matrix, ranked fix list - RavenDesignTokens.swift: add RavenTypography enum + View extension modifiers (.ravenTitle, .ravenBody, .ravenCaption, .ravenMono) - RaveniOSApp.swift: UITabBarAppearance — cosmos dark chrome, ravenAmber selected tint - ChatView.swift: cosmosBackground shell, amber accents, token typography, cosmos-tinted bubbles/input/banners, dim white text on dark field - SessionSealedCard.swift: cosmos dark panel, amber CTA, token-driven dividers - VaultView.swift: PRIMARY badge + active checkmark → ravenAmber - ModeSelectorView.swift: icon/checkmark/ModePill → ravenAmber accent - TodayScreenView.swift: cosmosBackground, ravenAmber replaces cyan throughout No behavioral changes. ViewModels, API clients, auth flows untouched. --- .../RaveniOS/Design/RavenDesignTokens.swift | 38 +++++ RaveniOS/RaveniOS/RaveniOSApp.swift | 31 ++++ RaveniOS/RaveniOS/Views/ChatView.swift | 142 +++++++++++------- .../RaveniOS/Views/SessionSealedCard.swift | 82 ++++++---- .../Views/Sheets/ModeSelectorView.swift | 8 +- .../Views/Today/TodayScreenView.swift | 18 ++- RaveniOS/RaveniOS/Views/VaultView.swift | 9 +- RaveniOS/UI_REVIEW.md | 68 +++++++++ 8 files changed, 291 insertions(+), 105 deletions(-) create mode 100644 RaveniOS/UI_REVIEW.md diff --git a/RaveniOS/RaveniOS/Design/RavenDesignTokens.swift b/RaveniOS/RaveniOS/Design/RavenDesignTokens.swift index 0c4d12ef..770f3177 100644 --- a/RaveniOS/RaveniOS/Design/RavenDesignTokens.swift +++ b/RaveniOS/RaveniOS/Design/RavenDesignTokens.swift @@ -333,3 +333,41 @@ enum RavenMotion { static let landingPromptSpringBounce: Double = 0.20 static let landingPromptSpringDelay: Double = 0.35 } + +// MARK: - Typography +// +// Named font scale for non-spatial views (Chat, Vault, sheets). +// Spatial labels in SkyMapView continue to use .system(size:design:monospaced) +// directly for fine-grained pixel control; this ramp targets the UI chrome. + +enum RavenTypography { + /// Screen/section title — chat header name, nav titles. + static func title() -> Font { .system(size: 17, weight: .semibold) } + + /// Primary UI body — mode display names, list row headlines. + static func body() -> Font { .system(size: 15, weight: .regular) } + + /// Secondary label — phase labels, profile names in header, subtitles. + static func caption() -> Font { .system(size: 12, weight: .regular) } + + /// Monospaced metadata — coords, timestamps, stat values. + static func mono(size: CGFloat = 11, weight: Font.Weight = .regular) -> Font { + .system(size: size, weight: weight, design: .monospaced) + } +} + +extension View { + /// Raven title style: 17pt semibold. + func ravenTitle() -> some View { self.font(RavenTypography.title()) } + + /// Raven body style: 15pt regular. + func ravenBody() -> some View { self.font(RavenTypography.body()) } + + /// Raven caption style: 12pt regular. + func ravenCaption() -> some View { self.font(RavenTypography.caption()) } + + /// Raven monospaced style: configurable size, default 11pt. + func ravenMono(size: CGFloat = 11, weight: Font.Weight = .regular) -> some View { + self.font(RavenTypography.mono(size: size, weight: weight)) + } +} diff --git a/RaveniOS/RaveniOS/RaveniOSApp.swift b/RaveniOS/RaveniOS/RaveniOSApp.swift index 952b247b..55170063 100644 --- a/RaveniOS/RaveniOS/RaveniOSApp.swift +++ b/RaveniOS/RaveniOS/RaveniOSApp.swift @@ -6,6 +6,7 @@ struct RaveniOSApp: App { init() { FirebaseApp.configure() + configureTabBarAppearance() } var body: some Scene { @@ -13,4 +14,34 @@ struct RaveniOSApp: App { ContentView() } } + + // MARK: - Tab Bar Theming + + /// Applies cosmos dark chrome to the tab bar using UITabBarAppearance. + /// Mirrors RavenPalette.cosmosBackground (0.04, 0.04, 0.10) and ravenAmber + /// (hue 0.08, sat 0.60, bri 0.80) for selected glyph tint. + /// + /// Configured at app start so the appearance is in place before the first + /// render — avoids a flash of the system default white bar. + /// + /// Note: EarthView overlays the TabView with zIndex(2) and ignoresSafeArea(), + /// so the tab bar sits beneath it during Earth mode and is not affected. + private func configureTabBarAppearance() { + let cosmos = UIColor(red: 0.04, green: 0.04, blue: 0.10, alpha: 0.97) + let amber = UIColor(hue: 0.08, saturation: 0.60, brightness: 0.80, alpha: 1.0) + let dim = UIColor.white.withAlphaComponent(0.35) + + let appearance = UITabBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = cosmos + + let stacked = appearance.stackedLayoutAppearance + stacked.selected.iconColor = amber + stacked.selected.titleTextAttributes = [.foregroundColor: amber] + stacked.normal.iconColor = dim + stacked.normal.titleTextAttributes = [.foregroundColor: dim] + + UITabBar.appearance().standardAppearance = appearance + UITabBar.appearance().scrollEdgeAppearance = appearance + } } diff --git a/RaveniOS/RaveniOS/Views/ChatView.swift b/RaveniOS/RaveniOS/Views/ChatView.swift index 23dcfb74..1b68a0c5 100644 --- a/RaveniOS/RaveniOS/Views/ChatView.swift +++ b/RaveniOS/RaveniOS/Views/ChatView.swift @@ -25,7 +25,7 @@ struct ChatView: View { inputBar } } - .background(Color(.systemBackground)) + .background(RavenPalette.cosmosBackground.ignoresSafeArea()) .onTapGesture { inputFocused = false } .sheet(isPresented: $showModeSelector) { ModeSelectorView(selectedMode: $selectedMode, vaultVM: vaultVM) @@ -43,25 +43,28 @@ struct ChatView: View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text("Raven Calder") - .font(.headline) + .ravenTitle() + .foregroundStyle(.white) // Connection state dot — only visible when degraded or offline if chatVM.connectionState != .connected { Circle() - .fill(chatVM.connectionState == .degraded ? Color.yellow : Color.red) + .fill(chatVM.connectionState == .degraded + ? Color.yellow + : Color.red) .frame(width: 7, height: 7) } } HStack(spacing: 6) { Text(phaseLabel) - .font(.caption) - .foregroundStyle(.secondary) + .ravenCaption() + .foregroundStyle(.white.opacity(0.45)) if let profile = vaultVM.activeProfile { Text("·") - .foregroundStyle(.secondary) - .font(.caption) + .ravenCaption() + .foregroundStyle(.white.opacity(0.30)) Text(profile.name) - .font(.caption) - .foregroundStyle(.secondary) + .ravenCaption() + .foregroundStyle(.white.opacity(0.45)) } } } @@ -70,12 +73,14 @@ struct ChatView: View { ModePill(mode: selectedMode) { showModeSelector = true } - // TTS toggle + // TTS toggle — amber when active, dim when off Button { chatVM.toggleTTS() } label: { Image(systemName: chatVM.isTTSEnabled ? "speaker.wave.2.fill" : "speaker.slash") - .foregroundStyle(chatVM.isTTSEnabled ? .blue : .secondary) + .foregroundStyle(chatVM.isTTSEnabled + ? RavenPalette.ravenAmber + : .white.opacity(0.35)) } .buttonStyle(.plain) // New session @@ -83,13 +88,18 @@ struct ChatView: View { chatVM.startNewSession() } label: { Image(systemName: "arrow.counterclockwise") - .foregroundStyle(.secondary) + .foregroundStyle(.white.opacity(0.35)) } .buttonStyle(.plain) } .padding(.horizontal) .padding(.vertical, 10) - .background(.ultraThinMaterial) + .background(RavenPalette.cosmosBackground.opacity(0.96)) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.white.opacity(0.07)) + .frame(height: 0.5) + } } // MARK: - Message List @@ -143,23 +153,22 @@ struct ChatView: View { Spacer(minLength: 60) Image(systemName: selectedMode.iconName) .font(.system(size: 48)) - .foregroundStyle(.secondary) + .foregroundStyle(RavenPalette.ravenAmber.opacity(0.60)) Text(selectedMode.displayName) - .font(.title3) + .ravenTitle() + .foregroundStyle(.white.opacity(0.80)) if let profile = vaultVM.activeProfile { Text("Reading for \(profile.name)") - .font(.subheadline) - .foregroundStyle(.secondary) + .ravenCaption() + .foregroundStyle(.white.opacity(0.45)) } else if selectedMode.requiresProfile { - VStack(spacing: 8) { - Text("Add a profile in the Vault first") - .font(.subheadline) - .foregroundStyle(.secondary) - } + Text("Add a profile in the Vault first") + .ravenCaption() + .foregroundStyle(.white.opacity(0.45)) } else { Text(selectedMode.subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) + .ravenCaption() + .foregroundStyle(.white.opacity(0.45)) .multilineTextAlignment(.center) .padding(.horizontal) } @@ -176,10 +185,16 @@ struct ChatView: View { TextField("Say something...", text: $inputText, axis: .vertical) .textFieldStyle(.plain) .lineLimit(1...5) + .ravenBody() + .foregroundStyle(.white) .padding(.horizontal, 12) .padding(.vertical, 10) - .background(Color(.secondarySystemBackground)) + .background(Color.white.opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.white.opacity(0.12), lineWidth: 0.5) + ) .focused($inputFocused) .onSubmit { submitMessage() } @@ -188,33 +203,36 @@ struct ChatView: View { } label: { Image(systemName: chatVM.isLoading ? "stop.circle.fill" : "arrow.up.circle.fill") .font(.system(size: 32)) - .foregroundStyle(canSend ? .blue : .secondary) + .foregroundStyle(canSend ? RavenPalette.sendActive : .white.opacity(0.25)) } .disabled(!canSend) .buttonStyle(.plain) } .padding(.horizontal) .padding(.vertical, 8) - .background(.ultraThinMaterial) + .background(RavenPalette.cosmosBackground.opacity(0.96)) + .overlay(alignment: .top) { + Rectangle() + .fill(Color.white.opacity(0.07)) + .frame(height: 0.5) + } } // MARK: - Offline Banner - /// Persistent strip shown while there is no network connection. - /// Dismissed automatically when connection restores and the queued message sends. private var offlineBanner: some View { HStack(spacing: 8) { Image(systemName: "wifi.slash") .font(.footnote) - .foregroundStyle(.secondary) + .foregroundStyle(.white.opacity(0.45)) Text("No signal — will send when you're back online") - .font(.footnote) - .foregroundStyle(.secondary) + .ravenCaption() + .foregroundStyle(.white.opacity(0.45)) Spacer() } .padding(.horizontal) .padding(.vertical, 7) - .background(Color(.secondarySystemBackground)) + .background(Color.white.opacity(0.06)) .transition(.move(edge: .top).combined(with: .opacity)) } @@ -225,44 +243,46 @@ struct ChatView: View { Image(systemName: error.requiresReauth ? "person.crop.circle.badge.exclamationmark" : "exclamationmark.triangle.fill") - .foregroundStyle(error.requiresReauth ? .red : .orange) + .foregroundStyle(error.requiresReauth + ? Color.red + : RavenPalette.ravenAmber) Text(error.message) - .font(.footnote) + .ravenCaption() + .foregroundStyle(.white.opacity(0.85)) .fixedSize(horizontal: false, vertical: true) Spacer() - // Retry button — only for retryable errors if error.isRetryable { Button { Task { await chatVM.retryPendingMessage() } } label: { Text("Try again") - .font(.footnote.bold()) - .foregroundStyle(.primary) + .ravenCaption() + .fontWeight(.semibold) + .foregroundStyle(.white) .padding(.horizontal, 10) .padding(.vertical, 5) .background( - Capsule().fill(Color(.tertiarySystemBackground)) + Capsule().fill(Color.white.opacity(0.12)) ) } .buttonStyle(.plain) } - // Dismiss Button { withAnimation { chatVM.chatError = nil } } label: { Image(systemName: "xmark") .font(.footnote) - .foregroundStyle(.secondary) + .foregroundStyle(.white.opacity(0.45)) } .buttonStyle(.plain) } .padding(.horizontal) .padding(.vertical, 10) - .background(Color(.systemYellow).opacity(0.12)) + .background(RavenPalette.ravenAmber.opacity(0.10)) .transition(.move(edge: .bottom).combined(with: .opacity)) } @@ -275,10 +295,10 @@ struct ChatView: View { private var phaseLabel: String { switch chatVM.currentPhase { case .calibration: return "Calibration" - case .tension: return "Tension" + case .tension: return "Tension" case .integration: return "Integration" - case .vector: return "Vector" - case .seal: return "Sealed" + case .vector: return "Vector" + case .seal: return "Sealed" } } @@ -301,9 +321,10 @@ struct MessageBubble: View { if message.role == .user { Spacer(minLength: 60) } VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 4) { Text(message.text) + .ravenBody() .padding(.horizontal, 14) .padding(.vertical, 10) - .background(bubbleColor) + .background(bubbleBackground) .foregroundStyle(textColor) .clipShape(RoundedRectangle(cornerRadius: 18)) if let tag = message.balanceTag { @@ -312,11 +333,11 @@ struct MessageBubble: View { if message.isFallback { HStack(spacing: 4) { Image(systemName: "arrow.trianglehead.2.clockwise") - .font(.caption2) + .ravenMono(size: 10) Text("System throttled — retry in a moment") - .font(.caption2) + .ravenMono(size: 10) } - .foregroundStyle(.secondary) + .foregroundStyle(.white.opacity(0.35)) .padding(.horizontal, 8) } } @@ -324,11 +345,16 @@ struct MessageBubble: View { } } - private var bubbleColor: Color { - message.role == .user ? .blue : Color(.secondarySystemBackground) + private var bubbleBackground: Color { + message.role == .user + ? RavenPalette.ravenAmber.opacity(0.85) + : Color.white.opacity(0.09) } + private var textColor: Color { - message.role == .user ? .white : .primary + message.role == .user + ? Color(red: 0.08, green: 0.05, blue: 0.02) + : .white.opacity(0.90) } } @@ -343,8 +369,8 @@ struct BalanceTagView: View { .fill(magnitudeColor) .frame(width: 8, height: 8) Text("\(tag.tierLabel) · \(tag.balanceLabel)") - .font(.caption2) - .foregroundStyle(.secondary) + .ravenMono(size: 10) + .foregroundStyle(.white.opacity(0.40)) } .padding(.horizontal, 8) } @@ -352,10 +378,10 @@ struct BalanceTagView: View { private var magnitudeColor: Color { switch tag.tierLabel { case "Calm": return .green - case "Moderate": return .yellow + case "Moderate": return RavenPalette.ravenAmber case "High": return .orange case "Intense": return .red - default: return .gray + default: return .white.opacity(0.30) } } } @@ -369,14 +395,14 @@ struct TypingIndicator: View { HStack(spacing: 4) { ForEach(0..<3, id: \.self) { i in Circle() - .fill(Color.secondary) + .fill(Color.white.opacity(0.35)) .frame(width: 8, height: 8) .scaleEffect(1 + 0.4 * sin(phase + Double(i) * .pi / 1.5)) } } .padding(.horizontal, 14) .padding(.vertical, 10) - .background(Color(.secondarySystemBackground)) + .background(Color.white.opacity(0.09)) .clipShape(RoundedRectangle(cornerRadius: 18)) .onAppear { withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { diff --git a/RaveniOS/RaveniOS/Views/SessionSealedCard.swift b/RaveniOS/RaveniOS/Views/SessionSealedCard.swift index 44475312..4e892571 100644 --- a/RaveniOS/RaveniOS/Views/SessionSealedCard.swift +++ b/RaveniOS/RaveniOS/Views/SessionSealedCard.swift @@ -10,48 +10,49 @@ struct SessionSealedCard: View { var body: some View { VStack(spacing: 0) { - // Card VStack(spacing: 20) { // Header VStack(spacing: 8) { Image(systemName: "seal.fill") .font(.system(size: 36)) - .foregroundStyle(.secondary) + .foregroundStyle(RavenPalette.ravenAmber.opacity(0.75)) Text("Session Sealed") - .font(.title3) - .fontWeight(.semibold) + .ravenTitle() + .foregroundStyle(.white) Text("This reading has completed its arc.") - .font(.subheadline) - .foregroundStyle(.secondary) + .ravenCaption() + .foregroundStyle(.white.opacity(0.50)) } - Divider() + Rectangle() + .fill(Color.white.opacity(0.10)) + .frame(height: 0.5) // Stats HStack(spacing: 0) { - statCell(label: "Turns", value: "\(session.turnCount)") - Divider().frame(height: 40) + statCell(label: "Turns", value: "\(session.turnCount)") + Rectangle().fill(Color.white.opacity(0.10)).frame(width: 0.5, height: 40) statCell(label: "Resonance", value: "\(session.resonanceHits)") - Divider().frame(height: 40) - statCell(label: "Phase", value: phaseLabel) + Rectangle().fill(Color.white.opacity(0.10)).frame(width: 0.5, height: 40) + statCell(label: "Phase", value: phaseLabel) } // Open threads if !session.openThreads.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Open Threads") - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(.secondary) + .ravenMono(size: 10, weight: .semibold) + .foregroundStyle(.white.opacity(0.40)) .textCase(.uppercase) ForEach(session.openThreads, id: \.self) { thread in HStack(alignment: .top, spacing: 8) { Image(systemName: "arrow.right") - .font(.caption) - .foregroundStyle(.secondary) + .ravenMono(size: 10) + .foregroundStyle(RavenPalette.ravenAmber.opacity(0.60)) .padding(.top, 2) Text(thread) - .font(.subheadline) + .ravenBody() + .foregroundStyle(.white.opacity(0.80)) } } } @@ -64,25 +65,44 @@ struct SessionSealedCard: View { exportSession() } label: { Label("Export Session", systemImage: "square.and.arrow.up") + .ravenBody() .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.white.opacity(0.08)) + .foregroundStyle(.white.opacity(0.80)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.12), lineWidth: 0.5) + ) } - .buttonStyle(.bordered) - .controlSize(.large) + .buttonStyle(.plain) Button { onStartNew() } label: { Label("Start New Session", systemImage: "arrow.counterclockwise") + .ravenBody() + .fontWeight(.semibold) .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(RavenPalette.ravenAmber.opacity(0.85)) + .foregroundStyle(Color(red: 0.08, green: 0.05, blue: 0.02)) + .clipShape(RoundedRectangle(cornerRadius: 12)) } - .buttonStyle(.borderedProminent) - .controlSize(.large) + .buttonStyle(.plain) } } .padding(24) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .shadow(color: .black.opacity(0.08), radius: 12, y: 4) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(Color(red: 0.06, green: 0.06, blue: 0.14)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(Color.white.opacity(0.10), lineWidth: 0.5) + ) + ) + .shadow(color: RavenPalette.ravenAmber.opacity(0.08), radius: 16, y: 6) .padding(.horizontal) } } @@ -92,11 +112,11 @@ struct SessionSealedCard: View { private func statCell(label: String, value: String) -> some View { VStack(spacing: 4) { Text(value) - .font(.title2) - .fontWeight(.semibold) + .ravenMono(size: 20, weight: .semibold) + .foregroundStyle(.white) Text(label) - .font(.caption) - .foregroundStyle(.secondary) + .ravenMono(size: 10) + .foregroundStyle(.white.opacity(0.40)) } .frame(maxWidth: .infinity) } @@ -104,10 +124,10 @@ struct SessionSealedCard: View { private var phaseLabel: String { switch session.phase { case .calibration: return "Cal." - case .tension: return "Tension" + case .tension: return "Tension" case .integration: return "Integ." - case .vector: return "Vector" - case .seal: return "Seal" + case .vector: return "Vector" + case .seal: return "Seal" } } diff --git a/RaveniOS/RaveniOS/Views/Sheets/ModeSelectorView.swift b/RaveniOS/RaveniOS/Views/Sheets/ModeSelectorView.swift index 83323679..af89151b 100644 --- a/RaveniOS/RaveniOS/Views/Sheets/ModeSelectorView.swift +++ b/RaveniOS/RaveniOS/Views/Sheets/ModeSelectorView.swift @@ -105,7 +105,7 @@ struct ModeModeRow: View { HStack(alignment: .top, spacing: 14) { Image(systemName: mode.iconName) .font(.title2) - .foregroundStyle(isAvailable ? .blue : .secondary) + .foregroundStyle(isAvailable ? RavenPalette.ravenAmber : .secondary) .frame(width: 30) .padding(.top, 2) @@ -128,7 +128,7 @@ struct ModeModeRow: View { if isSelected { Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.blue) + .foregroundStyle(RavenPalette.ravenAmber) .padding(.top, 2) } } @@ -170,8 +170,8 @@ struct ModePill: View { } .padding(.horizontal, 10) .padding(.vertical, 5) - .background(Color.blue.opacity(0.12)) - .foregroundStyle(.blue) + .background(RavenPalette.ravenAmber.opacity(0.12)) + .foregroundStyle(RavenPalette.ravenAmber) .clipShape(Capsule()) } .buttonStyle(.plain) diff --git a/RaveniOS/RaveniOS/Views/Today/TodayScreenView.swift b/RaveniOS/RaveniOS/Views/Today/TodayScreenView.swift index 8f20dc71..77b4454d 100644 --- a/RaveniOS/RaveniOS/Views/Today/TodayScreenView.swift +++ b/RaveniOS/RaveniOS/Views/Today/TodayScreenView.swift @@ -31,7 +31,7 @@ struct TodayScreenView: View { } .padding(16) } - .background(Color.black.ignoresSafeArea()) + .background(RavenPalette.cosmosBackground.ignoresSafeArea()) .navigationTitle("Today") } } @@ -86,7 +86,7 @@ private struct SymbolicMomentCard: View { HStack(spacing: 6) { ForEach(0..<5) { index in Circle() - .fill(index < moment.magnitude ? Color.cyan : Color.white.opacity(0.2)) + .fill(index < moment.magnitude ? RavenPalette.ravenAmber : Color.white.opacity(0.2)) .frame(width: 9, height: 9) } } @@ -115,7 +115,7 @@ private struct BalanceMeterView: View { ZStack(alignment: .leading) { Capsule().fill(Color.white.opacity(0.12)) Capsule() - .fill(Color.cyan.opacity(0.9)) + .fill(RavenPalette.ravenAmber.opacity(0.85)) .frame(width: geometry.size.width * telemetry.load) } } @@ -236,10 +236,14 @@ private struct IdentityLoadSelector: View { .padding(.vertical, 10) } .buttonStyle(.plain) - .foregroundStyle(selectedLoad == loadType ? Color.black : Color.white.opacity(0.85)) + .foregroundStyle(selectedLoad == loadType + ? Color(red: 0.08, green: 0.05, blue: 0.02) + : Color.white.opacity(0.85)) .background( RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(selectedLoad == loadType ? Color.cyan : Color.white.opacity(0.08)) + .fill(selectedLoad == loadType + ? RavenPalette.ravenAmber + : Color.white.opacity(0.08)) ) } } @@ -252,12 +256,12 @@ private struct IdentityLoadSelector: View { private var panelBackground: some View { RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(red: 0.08, green: 0.09, blue: 0.12)) + .fill(Color(red: 0.06, green: 0.06, blue: 0.14)) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(Color.white.opacity(0.08), lineWidth: 1) ) - .shadow(color: Color.cyan.opacity(0.10), radius: 8, x: 0, y: 0) + .shadow(color: RavenPalette.ravenAmber.opacity(0.08), radius: 8, x: 0, y: 0) } #Preview { diff --git a/RaveniOS/RaveniOS/Views/VaultView.swift b/RaveniOS/RaveniOS/Views/VaultView.swift index 3cb55543..c1eec738 100644 --- a/RaveniOS/RaveniOS/Views/VaultView.swift +++ b/RaveniOS/RaveniOS/Views/VaultView.swift @@ -186,12 +186,11 @@ struct ProfileRow: View { .font(.headline) if profile.isPrimary { Text("PRIMARY") - .font(.caption2) - .fontWeight(.semibold) + .ravenMono(size: 10, weight: .semibold) .padding(.horizontal, 6) .padding(.vertical, 2) - .background(Color.blue.opacity(0.15)) - .foregroundStyle(.blue) + .background(RavenPalette.ravenAmber.opacity(0.15)) + .foregroundStyle(RavenPalette.ravenAmber) .clipShape(Capsule()) } } @@ -209,7 +208,7 @@ struct ProfileRow: View { Spacer() if isActive { Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.blue) + .foregroundStyle(RavenPalette.ravenAmber) } } .padding(.vertical, 4) diff --git a/RaveniOS/UI_REVIEW.md b/RaveniOS/UI_REVIEW.md new file mode 100644 index 00000000..2fe3ab36 --- /dev/null +++ b/RaveniOS/UI_REVIEW.md @@ -0,0 +1,68 @@ +# RaveniOS — UI Review (Task #21) + +## Screen Summaries + +**SkyMapView (Map tab)** +The spatial navigation canvas. Fully token-driven: `RavenPalette.cosmosBackground` fill, all sizes from `RavenLayout`, all motion from `RavenMotion`, all glow from `RavenGlow`. This is the gold standard that every other screen should aspire to match. + +**RavenOpeningView (Map tab – first launch)** +Entry splash before the map reveals. Uses `RavenPalette.openingBackground`, `ravenAmber` glow, and `sendActive` on the send button. Well-themed; only minor typography literals remain (hard-coded `size: 18/16` instead of tokens). + +**ChatView (Chat tab)** +The biggest offender. Background, banners, bubbles, header, input bar all fall back to `systemBackground` / `secondarySystemBackground` / `ultraThinMaterial`. None of the palette, typography, or accent colors come from the design tokens. Effectively a default SwiftUI chat template dropped inside a cosmos app. + +**VaultView (Vault tab)** +Standard `NavigationStack + List(insetGrouped)`. System-white background with default List chrome. The "PRIMARY" badge and active-profile checkmark both use `.blue` instead of `ravenAmber`. Otherwise structurally fine — it's a data entry surface, so a fully opaque cosmos background here is less critical than in Chat. + +**ProfileDetailView (sheet)** +Pure system `List` form. Same structural issue as VaultView. Blue accent on `.green`/`.blue` status labels. Navigation title and toolbar buttons are unstyled defaults. + +**SessionSealedCard (inline in Chat)** +Card uses `Color(.secondarySystemBackground)` — will appear white/light on top of the cosmos chat background, breaking the dark-field aesthetic. The `.borderedProminent` CTA button defaults to blue. Stats and open-thread list use system fonts throughout. + +**ModeSelectorView (sheet) + ModePill (Chat header)** +`ModePill` uses `.blue` accent; `ModeModeRow` uses `.blue` for icon and selected checkmark. Both should use `ravenAmber` or a token accent. + +**TodayScreenView (not currently in TabView)** +Uses `Color.black` background (close, but not `cosmosBackground`) and `Color.cyan` as the primary accent throughout — a different aesthetic register from the amber/violet cosmos palette. Panel background is a raw `Color(red:green:blue:)` literal rather than a token. + +--- + +## Token-vs-View Matrix + +| Token / constant | Defined | Used in SkyMap | Used in Chat | Used in Vault | Used in SessionCard | Used in Today | +|---|---|---|---|---|---|---| +| `RavenPalette.cosmosBackground` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ (uses Color.black) | +| `RavenPalette.ravenAmber` | ✅ | ✅ | ❌ (uses .blue) | ❌ (uses .blue) | ❌ | ❌ (uses .cyan) | +| `RavenPalette.sendActive` | ✅ | ✅ (OpeningView) | ❌ (uses .blue) | — | — | — | +| `RavenLayout.*` spacing | ✅ | ✅ | ❌ (hard-coded 10/12/24) | ❌ | ❌ (hard-coded 24/20) | ❌ | +| `RavenMotion.*` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Tab bar appearance | — | — | ❌ (system default) | ❌ | — | — | + +--- + +## Ranked Fix List (highest impact first) + +1. **ChatView shell background** — `Color(.systemBackground)` makes the entire Chat tab appear white. Replace with `RavenPalette.cosmosBackground`. Single line, highest visual impact. *(done in this pass)* + +2. **Tab bar appearance** — System translucent tab bar looks out of place against the cosmos void. Apply `UITabBarAppearance` with dark cosmos chrome and `ravenAmber` selected-item tint. *(done in this pass)* + +3. **ChatView accent colors** — TTS button, send button, message bubbles, input field all use `.blue` or system fills. Swap to `ravenAmber` / `sendActive` / cosmos-tinted backgrounds. *(done in this pass)* + +4. **SessionSealedCard background** — White card atop cosmos background is the most visually jarring contrast moment. Replace with a dark cosmos panel. *(done in this pass)* + +5. **Typography ramp in Chat** — `.headline` / `.caption` / `.subheadline` scattered without a token anchor. Introduce `RavenTypography` helpers (appended to `RavenDesignTokens.swift`) and apply to Chat header and bubble metadata. *(done in this pass)* + +6. **ModePill + ModeModeRow accent** — `.blue` icon/checkmark/pill tint. Swap to `ravenAmber`. *(done in this pass)* + +7. **VaultView badge/checkmark** — "PRIMARY" badge and active-profile checkmark use `.blue`. Swap to `ravenAmber`. *(done in this pass)* + +8. **TodayScreenView palette** — `Color.cyan` accent and `Color.black` background diverge from cosmos register. Swap to token colors. *(deferred — TodayScreenView is not in the active TabView yet; low runtime impact)* + +--- + +## Out of scope (this pass) +- NavigationStack background tinting on Vault/ProfileDetail (requires `UINavigationBarAppearance` — lower priority than Chat) +- AddProfileView form theming +- Spacing literals in SkyMapView (already token-driven; no literals found) +- Animation additions or overhauls From e726f4adae1c31fd972d253e6556d4bc90c74c39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 16:42:12 +0000 Subject: [PATCH 09/15] fix(voice): warm reverb chain in unlock() to prevent first-response choke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raven's first ElevenLabs response was stuttering because the reverb chain (ConvolverNode + 1.6s impulse buffer + dry/wet gains) was lazily built on the first sendText() call, racing the audio that was already trying to play. Subsequent responses reused the cached graph and played cleanly. Build the chain inside unlock() — which runs on the user's "Enable Audio" gesture — and pass a silent 1-sample buffer through it so the convolver allocates its kernel before any TTS audio arrives. --- vessel/src/hooks/useElevenLabsVoice.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/vessel/src/hooks/useElevenLabsVoice.ts b/vessel/src/hooks/useElevenLabsVoice.ts index 1250e24e..5c1da9ba 100644 --- a/vessel/src/hooks/useElevenLabsVoice.ts +++ b/vessel/src/hooks/useElevenLabsVoice.ts @@ -125,9 +125,29 @@ export function useElevenLabsVoice({ } const unlocked = context.state === 'running'; + + // Pre-build the reverb chain and push a silent sample through it so the + // ConvolverNode has allocated its kernel before the first TTS response + // arrives. Without this warm-up, the first sendText() pays for impulse + // generation + convolver cold-start while audio is already trying to play, + // which manifests as the first response "choking out". + if (unlocked) { + try { + const { dry, convolver } = getReverbChain(context); + const warmBuf = context.createBuffer(1, 1, context.sampleRate); + const warmSrc = context.createBufferSource(); + warmSrc.buffer = warmBuf; + warmSrc.connect(dry); + warmSrc.connect(convolver); + warmSrc.start(0); + } catch { + // best-effort — reverb will lazy-init on first sendText if this fails + } + } + setIsUnlocked(unlocked); return unlocked; - }, [getOrCreateContext]); + }, [getOrCreateContext, getReverbChain]); const stop = useCallback(() => { if (abortControllerRef.current) { From 5605b1e5e68796145d89c6dbd2aa105739c54b43 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 20:07:05 +0000 Subject: [PATCH 10/15] feat(voice): add replay button + fix auto-play cleanup 1. Fix effect cleanup bug: auto-play effect was not calling stop() on unmount, potentially leaving audio playing after component destroys. Now properly stops audio when dependencies change. 2. Add per-message replay button: users can click "Replay" next to "Copy" to re-synthesize and play the Raven response again. Button is: - Disabled while audio is already playing - Uses same normalization as auto-play (normalizeReflectionSpeechText) - Calls stop() before replaying to ensure clean state - Does not interfere with auto-play state tracking 3. Code review: - No master volume control (dry 0.82 + wet 0.35 reverb mix is fixed) - Abort/stop logic is solid and handles race conditions - Audio context suspension/resume works correctly - Message lifecycle cleanup was the only issue found --- vessel/src/app/page.tsx | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/vessel/src/app/page.tsx b/vessel/src/app/page.tsx index c01c6421..06600ccf 100644 --- a/vessel/src/app/page.tsx +++ b/vessel/src/app/page.tsx @@ -2168,6 +2168,7 @@ export default function App() { })(); return () => { cancelled = true; + stop(); }; }, [messages, voiceMode, sendText, audioFullOutputEnabled, alignmentCorridorState, isLoading]); @@ -3339,6 +3340,15 @@ export default function App() { announceCopyResult('Copied Raven response.', messageId); } }; + + const replayAssistantMessage = (text: string) => { + stop(); + const spoken = normalizeReflectionSpeechText(text); + if (spoken) { + void sendText(spoken); + } + }; + if (authLoading) { return (
@@ -5453,7 +5463,22 @@ export default function App() { )} {msg.role === "assistant" && !isSystemConsole && !isUpgradeGateMessage && ( -
+
+ + )} + {observerStagedProfiles.length > 0 && ( + + )}