@@ -241,18 +206,17 @@ function UsersPanel() {
);
}
-// ── Model Hydration ───────────────────────────────────────────
function ModelHydrationPanel() {
const models = [
- { name: 'Wan 2.2', size: '15 GB', status: 'synced', path: '/opt/models/comfy/wan2.2' },
- { name: 'Qwen-Image 2512', size: '20 GB', status: 'synced', path: '/opt/models/comfy/qwen-image-2512' },
- { name: 'Qwen3.6 35B A3B', size: '70 GB', status: 'synced', path: '/opt/models/llm/qwen3.6-35b-a3b' },
+ { name: 'Wan / Dream Weaver media stack', size: 'GPU NVMe', status: 'synced', path: '/opt/dlami/nvme/models/comfy' },
+ { name: 'Qwen3.6 35B A3B', size: 'GPU NVMe', status: 'synced', path: '/opt/dlami/nvme/models/Qwen-Qwen3.6-35B-A3B-FP8' },
+ { name: 'Oracle component codebook', size: 'local', status: 'loaded', path: 'core/oracle/oracle/oracle_runtime_codebook_merged.json' },
];
return (
);
}
-// ── Meta Integration ──────────────────────────────────────────
function MetaIntegrationPanel() {
return (
Meta Business Integration
-
- OAuth, Ad Account, and Lookalike Audience configuration.
- Never visible to broker-role users.
-
+
OAuth, Ad Account, and Lookalike Audience configuration.
OAuth not connected
diff --git a/webos/src/pillars/command/OracleBar.module.css b/webos/src/pillars/command/OracleBar.module.css
index a48402a..f78c08b 100644
--- a/webos/src/pillars/command/OracleBar.module.css
+++ b/webos/src/pillars/command/OracleBar.module.css
@@ -10,3 +10,6 @@
.clearBtn { background: none; border: none; color: var(--color-text-tertiary); cursor: pointer; font-size: 14px; padding: var(--space-1); border-radius: var(--radius-sm); }
.clearBtn:hover { color: var(--color-text-primary); }
.placeholderCard { height: 120px; border-radius: var(--radius-xl); }
+.resultActions { display: flex; justify-content: flex-end; }
+.openOracleBtn { border: 1px solid rgba(124,58,237,0.34); border-radius: 999px; background: rgba(124,58,237,0.12); color: var(--color-text-primary); padding: var(--space-2) var(--space-4); cursor: pointer; font-weight: var(--font-semibold); box-shadow: 0 0 20px rgba(124,58,237,0.12); }
+.openOracleBtn:hover { border-color: rgba(124,58,237,0.62); background: rgba(124,58,237,0.20); }
diff --git a/webos/src/pillars/command/OracleBar.tsx b/webos/src/pillars/command/OracleBar.tsx
index 8a9160a..3eb3a38 100644
--- a/webos/src/pillars/command/OracleBar.tsx
+++ b/webos/src/pillars/command/OracleBar.tsx
@@ -1,10 +1,38 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
+import { useNavigate } from 'react-router-dom';
+import { api } from '@/shared/lib/apiClient';
+import { unwrapArray, unwrapObject } from '@/shared/lib/apiShape';
import { OracleResultCard } from './OracleResultCard';
import styles from './OracleBar.module.css';
type QueryState = 'idle' | 'thinking' | 'result';
type OracleEnvelope = { status?: string; data?: unknown; detail?: string; message?: string; error?: string };
+type OracleRow = Record
;
+
+function normalizeOracleResult(payload: unknown) {
+ const root = unwrapObject>(payload) ?? {};
+ const rows = unwrapArray(root, ['rows', 'dataRows']);
+ const components = unwrapArray>(root, ['components']);
+ const firstComponent = components[0];
+ const componentRows = rows.length > 0 ? rows : unwrapArray(firstComponent, ['rows', 'dataRows']);
+ const columns = Array.isArray(root.columns)
+ ? root.columns.filter((item): item is string => typeof item === 'string')
+ : componentRows[0]
+ ? Object.keys(componentRows[0])
+ : [];
+
+ return {
+ ...root,
+ title: root.title ?? firstComponent?.title,
+ summary: root.summary ?? firstComponent?.summary,
+ rows: componentRows,
+ columns,
+ rowCount: typeof root.rowCount === 'number' ? root.rowCount : componentRows.length,
+ componentType: root.componentType ?? firstComponent?.type ?? firstComponent?.componentType,
+ sourceTables: Array.isArray(root.sourceTables) ? root.sourceTables : [],
+ };
+}
/**
* OracleBar
@@ -25,7 +53,74 @@ const SUGGESTIONS = [
'What is the average QD score by project?',
];
+const NUMBER_WORDS: Record = {
+ one: 1,
+ two: 2,
+ three: 3,
+ four: 4,
+ five: 5,
+ six: 6,
+ seven: 7,
+ eight: 8,
+ nine: 9,
+ ten: 10,
+ eleven: 11,
+ twelve: 12,
+ fifteen: 15,
+ twenty: 20,
+ thirty: 30,
+ fifty: 50,
+};
+
+const ORACLE_CHAT_STORAGE_KEY = 'velocity-oracle-chat-sessions-v1';
+
+function createChatSessionFromCommand(prompt: string, result: any) {
+ const now = new Date().toISOString();
+ const title = prompt.trim().length > 58 ? `${prompt.trim().slice(0, 55)}...` : prompt.trim();
+ const session = {
+ id: `oracle_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
+ title: title || 'Oracle Chat',
+ createdAt: now,
+ updatedAt: now,
+ turns: [{
+ id: `turn_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
+ prompt,
+ createdAt: now,
+ status: result?.title === 'Oracle query failed' ? 'failed' : 'done',
+ result,
+ error: result?.title === 'Oracle query failed' ? result?.summary : undefined,
+ }],
+ };
+
+ try {
+ const current = JSON.parse(localStorage.getItem(ORACLE_CHAT_STORAGE_KEY) ?? '[]');
+ const sessions = Array.isArray(current) ? current : [];
+ localStorage.setItem(ORACLE_CHAT_STORAGE_KEY, JSON.stringify([session, ...sessions].slice(0, 30)));
+ } catch {
+ localStorage.setItem(ORACLE_CHAT_STORAGE_KEY, JSON.stringify([session]));
+ }
+}
+
+function deriveRowLimit(prompt: string): number {
+ const lowered = prompt.toLowerCase();
+ const numericMatch = lowered.match(/\b(?:top|last|first|lowest|highest|best|worst)\s+(\d{1,3})\b|\b(\d{1,3})\s+(?:clients|leads|properties|projects|records|rows)\b/);
+ const numericLimit = Number(numericMatch?.[1] ?? numericMatch?.[2]);
+ if (Number.isFinite(numericLimit) && numericLimit > 0) {
+ return Math.min(100, Math.max(1, numericLimit));
+ }
+
+ for (const [word, value] of Object.entries(NUMBER_WORDS)) {
+ const phrase = new RegExp(`\\b(?:top|last|first|lowest|highest|best|worst)\\s+${word}\\b|\\b${word}\\s+(?:clients|leads|properties|projects|records|rows)\\b`);
+ if (phrase.test(lowered)) {
+ return value;
+ }
+ }
+
+ return 25;
+}
+
export function OracleBar() {
+ const navigate = useNavigate();
const [queryState, setQueryState] = useState('idle');
const [query, setQuery] = useState('');
const [isFocused, setIsFocused] = useState(false);
@@ -45,25 +140,25 @@ export function OracleBar() {
setResult(null);
try {
- const resp = await fetch('/api/oracle/query', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ prompt: query, context: { surface: 'command_pillar' } }),
+ const payload = await api.post('/oracle/query', {
+ prompt: query,
+ row_limit: deriveRowLimit(query),
+ context: { surface: 'command_pillar' },
});
- const payload = await resp.json().catch(() => ({})) as OracleEnvelope;
- if (!resp.ok) {
- throw new Error(payload.detail || payload.message || payload.error || `HTTP ${resp.status}`);
- }
- setResult(payload.data ?? payload);
+ const normalized = normalizeOracleResult(payload.data ?? payload);
+ setResult(normalized);
+ createChatSessionFromCommand(query, normalized);
setQueryState('result');
} catch (error) {
- setResult({
+ const failed = {
title: 'Oracle query failed',
summary: error instanceof Error ? error.message : 'Unable to run Oracle query.',
rows: [],
columns: [],
warnings: ['Check the Oracle API route and backend logs.'],
- });
+ };
+ setResult(failed);
+ createChatSessionFromCommand(query, failed);
setQueryState('result');
}
}, [query, queryState]);
@@ -173,7 +268,14 @@ export function OracleBar() {
{/* ── Result card ──────────────────────────────────── */}
{queryState === 'result' && result && (
-
+ <>
+
+
+
+
+ >
)}
diff --git a/webos/src/pillars/command/OracleChatConsole.module.css b/webos/src/pillars/command/OracleChatConsole.module.css
new file mode 100644
index 0000000..5175918
--- /dev/null
+++ b/webos/src/pillars/command/OracleChatConsole.module.css
@@ -0,0 +1,337 @@
+.shell {
+ display: grid;
+ grid-template-columns: 280px minmax(0, 1fr);
+ min-height: 540px;
+ border: var(--glass-border);
+ border-radius: 28px;
+ overflow: hidden;
+ background:
+ radial-gradient(circle at 20% 0%, rgba(124, 58, 237, 0.16), transparent 34%),
+ linear-gradient(145deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.025));
+ box-shadow: 0 30px 90px rgba(0, 0, 0, 0.34), 0 0 44px rgba(124, 58, 237, 0.08);
+}
+
+.rail {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ padding: var(--space-5);
+ border-right: var(--glass-border);
+ background: rgba(0, 0, 0, 0.16);
+}
+
+.eyebrow {
+ margin: 0 0 var(--space-1);
+ color: var(--color-violet-light);
+ font-size: 10px;
+ font-weight: var(--font-bold);
+ letter-spacing: 0.28em;
+ text-transform: uppercase;
+}
+
+.title {
+ margin: 0;
+ color: var(--color-text-primary);
+ font-size: var(--text-xl);
+ font-weight: var(--font-bold);
+}
+
+.caption {
+ margin: var(--space-1) 0 0;
+ color: var(--color-text-tertiary);
+ font-size: var(--text-xs);
+ line-height: 1.55;
+}
+
+.railButton,
+.sessionButton,
+.searchInput,
+.shareButton {
+ width: 100%;
+ border: var(--glass-border);
+ border-radius: var(--radius-xl);
+ background: rgba(255, 255, 255, 0.045);
+ color: var(--color-text-secondary);
+}
+
+.railButton,
+.shareButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ min-height: 44px;
+ cursor: pointer;
+ font-weight: var(--font-semibold);
+ transition: border-color var(--duration-fast), box-shadow var(--duration-fast), color var(--duration-fast), background var(--duration-fast);
+}
+
+.railButton:hover,
+.shareButton:hover {
+ border-color: rgba(124, 58, 237, 0.48);
+ background: rgba(124, 58, 237, 0.13);
+ color: var(--color-text-primary);
+ box-shadow: 0 0 26px rgba(124, 58, 237, 0.16);
+}
+
+.primaryButton {
+ color: white;
+ border-color: rgba(124, 58, 237, 0.55);
+ background: linear-gradient(135deg, rgba(124, 58, 237, 0.85), rgba(59, 130, 246, 0.55));
+}
+
+.searchInput {
+ min-height: 42px;
+ padding: 0 var(--space-4);
+ outline: none;
+ font: inherit;
+}
+
+.searchInput:focus {
+ border-color: rgba(124, 58, 237, 0.55);
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.16);
+}
+
+.sectionLabel {
+ margin: var(--space-2) 0 0;
+ color: var(--color-text-tertiary);
+ font-size: 10px;
+ font-weight: var(--font-bold);
+ letter-spacing: 0.22em;
+ text-transform: uppercase;
+}
+
+.sessions {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ overflow: auto;
+ min-height: 0;
+}
+
+.sessionButton {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-3);
+ cursor: pointer;
+ text-align: left;
+}
+
+.sessionButton:hover,
+.sessionActive {
+ border-color: rgba(59, 130, 246, 0.42);
+ background: rgba(59, 130, 246, 0.11);
+ box-shadow: 0 0 28px rgba(59, 130, 246, 0.14);
+}
+
+.sessionGlyph {
+ width: 34px;
+ height: 34px;
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ border-radius: 50%;
+ border: 1px solid rgba(124, 58, 237, 0.35);
+ background: rgba(124, 58, 237, 0.16);
+ color: var(--color-text-primary);
+ font-weight: var(--font-bold);
+}
+
+.sessionMeta {
+ min-width: 0;
+}
+
+.sessionTitle {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--color-text-primary);
+ font-size: var(--text-sm);
+ font-weight: var(--font-semibold);
+}
+
+.sessionSub {
+ margin-top: 3px;
+ color: var(--color-text-tertiary);
+ font-size: 11px;
+}
+
+.emptySessions {
+ padding: var(--space-4);
+ color: var(--color-text-tertiary);
+ font-size: var(--text-sm);
+}
+
+.main {
+ position: relative;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ min-width: 0;
+}
+
+.topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+ padding: var(--space-5) var(--space-6);
+ border-bottom: var(--glass-border);
+}
+
+.topbarTitle {
+ margin: 0;
+ color: var(--color-text-primary);
+ font-size: var(--text-lg);
+ font-weight: var(--font-bold);
+}
+
+.topbarSub {
+ margin: 3px 0 0;
+ color: var(--color-text-tertiary);
+ font-size: var(--text-xs);
+}
+
+.statusPill {
+ padding: var(--space-2) var(--space-3);
+ border-radius: 999px;
+ border: 1px solid rgba(16, 185, 129, 0.24);
+ background: rgba(16, 185, 129, 0.09);
+ color: #86efac;
+ font-size: 11px;
+ font-weight: var(--font-bold);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.thread {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-5);
+ padding: var(--space-6);
+ overflow: auto;
+}
+
+.emptyCanvas {
+ margin: auto;
+ max-width: 430px;
+ text-align: center;
+ color: var(--color-text-secondary);
+}
+
+.emptyIcon {
+ width: 72px;
+ height: 72px;
+ display: grid;
+ place-items: center;
+ margin: 0 auto var(--space-4);
+ border-radius: 24px;
+ border: 1px solid rgba(124, 58, 237, 0.22);
+ background: rgba(124, 58, 237, 0.10);
+ box-shadow: 0 0 34px rgba(124, 58, 237, 0.16);
+ color: var(--color-violet-light);
+ font-size: 26px;
+}
+
+.emptyCanvas h3 {
+ margin: 0 0 var(--space-2);
+ color: var(--color-text-primary);
+}
+
+.turn {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.userBubble {
+ align-self: flex-end;
+ max-width: min(780px, 88%);
+ padding: var(--space-3) var(--space-4);
+ border-radius: 20px 20px 6px 20px;
+ background: linear-gradient(135deg, rgba(124, 58, 237, 0.28), rgba(59, 130, 246, 0.16));
+ border: 1px solid rgba(124, 58, 237, 0.28);
+ color: var(--color-text-primary);
+}
+
+.thinkingCard {
+ min-height: 120px;
+ border-radius: var(--radius-xl);
+}
+
+.composer {
+ padding: var(--space-5) var(--space-6);
+ border-top: var(--glass-border);
+ background: rgba(0, 0, 0, 0.14);
+}
+
+.composerInner {
+ display: flex;
+ align-items: flex-end;
+ gap: var(--space-3);
+ padding: var(--space-3);
+ border: 1px solid rgba(124, 58, 237, 0.28);
+ border-radius: 24px;
+ background: rgba(255, 255, 255, 0.055);
+ box-shadow: 0 0 34px rgba(124, 58, 237, 0.12);
+}
+
+.composerInput {
+ flex: 1;
+ min-height: 38px;
+ max-height: 130px;
+ resize: vertical;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--color-text-primary);
+ font: inherit;
+ line-height: 1.5;
+}
+
+.composerInput::placeholder {
+ color: var(--color-text-tertiary);
+}
+
+.sendButton {
+ width: 42px;
+ height: 42px;
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ border: 0;
+ border-radius: 16px;
+ background: linear-gradient(135deg, var(--color-violet), #2563eb);
+ color: white;
+ cursor: pointer;
+ font-size: 18px;
+ box-shadow: 0 0 26px rgba(124, 58, 237, 0.36);
+}
+
+.sendButton:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+ box-shadow: none;
+}
+
+.notice {
+ margin-top: var(--space-2);
+ color: var(--color-text-tertiary);
+ font-size: 11px;
+}
+
+@media (max-width: 980px) {
+ .shell {
+ grid-template-columns: 1fr;
+ }
+
+ .rail {
+ border-right: 0;
+ border-bottom: var(--glass-border);
+ }
+
+ .sessions {
+ max-height: 220px;
+ }
+}
diff --git a/webos/src/pillars/command/OracleChatConsole.tsx b/webos/src/pillars/command/OracleChatConsole.tsx
new file mode 100644
index 0000000..f064a3b
--- /dev/null
+++ b/webos/src/pillars/command/OracleChatConsole.tsx
@@ -0,0 +1,341 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { motion } from 'framer-motion';
+import { api } from '../../shared/lib/apiClient';
+import { unwrapArray, unwrapObject } from '../../shared/lib/apiShape';
+import { OracleResultCard } from './OracleResultCard';
+import styles from './OracleChatConsole.module.css';
+
+type OracleRow = Record
;
+
+interface OracleResult {
+ title?: string;
+ summary?: string;
+ columns?: string[];
+ rows?: OracleRow[];
+ rowCount?: number;
+ componentType?: string;
+ warnings?: string[];
+ sourceTables?: string[];
+}
+
+interface OracleTurn {
+ id: string;
+ prompt: string;
+ createdAt: string;
+ status: 'thinking' | 'done' | 'failed';
+ result?: OracleResult;
+ error?: string;
+}
+
+interface OracleSession {
+ id: string;
+ title: string;
+ createdAt: string;
+ updatedAt: string;
+ turns: OracleTurn[];
+}
+
+const STORAGE_KEY = 'velocity-oracle-chat-sessions-v1';
+
+function makeId(prefix: string) {
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+}
+
+function createSession(title = 'New Oracle Chat'): OracleSession {
+ const now = new Date().toISOString();
+ return {
+ id: makeId('oracle'),
+ title,
+ createdAt: now,
+ updatedAt: now,
+ turns: [],
+ };
+}
+
+function deriveTitle(prompt: string) {
+ const clean = prompt.trim().replace(/\s+/g, ' ');
+ return clean.length > 58 ? `${clean.slice(0, 55)}...` : clean || 'Oracle Chat';
+}
+
+function normalizeResult(raw: unknown, prompt: string): OracleResult {
+ const body = unwrapObject>(raw) ?? {};
+ const data = unwrapObject>(body.data ?? body.result ?? body.payload ?? body) ?? {};
+ const components = unwrapArray>(data.components ?? data.componentPlan ?? data.canvasComponents);
+ const firstComponent = unwrapObject>(components[0]) ?? {};
+ const rows = unwrapArray(data.rows ?? data.dataRows ?? firstComponent.dataRows ?? firstComponent.rows);
+ const columns = unwrapArray(data.columns ?? firstComponent.columns);
+ const sourceTables = unwrapArray(data.sourceTables ?? data.source_tables ?? firstComponent.sourceTables);
+ const warnings = unwrapArray(data.warnings ?? firstComponent.warnings);
+
+ return {
+ title: String(data.title ?? firstComponent.title ?? `Oracle result for "${prompt}"`),
+ summary: String(data.summary ?? firstComponent.summary ?? data.sqlSummary ?? ''),
+ rows,
+ columns: columns.length ? columns.map(String) : rows[0] ? Object.keys(rows[0]) : [],
+ rowCount: Number(data.rowCount ?? data.row_count ?? rows.length),
+ componentType: String(data.componentType ?? data.component_type ?? firstComponent.type ?? firstComponent.componentType ?? 'table'),
+ warnings: warnings.map(String),
+ sourceTables: sourceTables.map(String),
+ };
+}
+
+function deriveRowLimit(prompt: string) {
+ const numeric = prompt.match(/\b(?:top|last|first|lowest|highest)?\s*(\d{1,3})\b/i);
+ if (numeric) return Math.min(Math.max(Number(numeric[1]), 1), 100);
+ const words: Record = {
+ one: 1,
+ two: 2,
+ three: 3,
+ four: 4,
+ five: 5,
+ six: 6,
+ seven: 7,
+ eight: 8,
+ nine: 9,
+ ten: 10,
+ };
+ const word = prompt.toLowerCase().match(/\b(one|two|three|four|five|six|seven|eight|nine|ten)\b/);
+ return word ? words[word[1]] : 10;
+}
+
+function relativeTime(value: string) {
+ const delta = Date.now() - new Date(value).getTime();
+ const minutes = Math.max(1, Math.floor(delta / 60000));
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ return `${Math.floor(hours / 24)}d ago`;
+}
+
+function loadSessions(): OracleSession[] {
+ try {
+ const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]');
+ return Array.isArray(parsed) && parsed.length ? parsed : [createSession('Oracle Main Canvas')];
+ } catch {
+ return [createSession('Oracle Main Canvas')];
+ }
+}
+
+export default function OracleChatConsole() {
+ const [sessions, setSessions] = useState(loadSessions);
+ const [activeId, setActiveId] = useState(() => sessions[0]?.id ?? '');
+ const [prompt, setPrompt] = useState('');
+ const [search, setSearch] = useState('');
+ const [shareNote, setShareNote] = useState('');
+ const inputRef = useRef(null);
+
+ const activeSession = useMemo(
+ () => sessions.find((session) => session.id === activeId) ?? sessions[0],
+ [activeId, sessions],
+ );
+
+ const filteredSessions = useMemo(() => {
+ const term = search.trim().toLowerCase();
+ const sorted = [...sessions].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
+ if (!term) return sorted;
+ return sorted.filter((session) =>
+ session.title.toLowerCase().includes(term)
+ || session.turns.some((turn) => turn.prompt.toLowerCase().includes(term)),
+ );
+ }, [search, sessions]);
+
+ useEffect(() => {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions.slice(0, 30)));
+ }, [sessions]);
+
+ const startNewChat = useCallback(() => {
+ const session = createSession();
+ setSessions((current) => [session, ...current]);
+ setActiveId(session.id);
+ setPrompt('');
+ setTimeout(() => inputRef.current?.focus(), 50);
+ }, []);
+
+ const submitPrompt = useCallback(async () => {
+ const clean = prompt.trim();
+ if (!clean || !activeSession) return;
+
+ const turnId = makeId('turn');
+ const now = new Date().toISOString();
+ setPrompt('');
+ setShareNote('');
+
+ setSessions((current) => current.map((session) => {
+ if (session.id !== activeSession.id) return session;
+ const title = session.turns.length === 0 && session.title === 'New Oracle Chat'
+ ? deriveTitle(clean)
+ : session.title;
+ return {
+ ...session,
+ title,
+ updatedAt: now,
+ turns: [
+ ...session.turns,
+ { id: turnId, prompt: clean, createdAt: now, status: 'thinking' },
+ ],
+ };
+ }));
+
+ try {
+ const response = await api.post('/oracle/query', {
+ prompt: clean,
+ row_limit: deriveRowLimit(clean),
+ context: {
+ surface: 'velocity_os_oracle_chat',
+ session_id: activeSession.id,
+ },
+ });
+ const result = normalizeResult(response, clean);
+ setSessions((current) => current.map((session) => {
+ if (session.id !== activeSession.id) return session;
+ return {
+ ...session,
+ updatedAt: new Date().toISOString(),
+ turns: session.turns.map((turn) => (
+ turn.id === turnId ? { ...turn, status: 'done', result } : turn
+ )),
+ };
+ }));
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Oracle request failed';
+ setSessions((current) => current.map((session) => {
+ if (session.id !== activeSession.id) return session;
+ return {
+ ...session,
+ updatedAt: new Date().toISOString(),
+ turns: session.turns.map((turn) => (
+ turn.id === turnId ? { ...turn, status: 'failed', error: message } : turn
+ )),
+ };
+ }));
+ }
+ }, [activeSession, prompt]);
+
+ const shareSession = useCallback(async () => {
+ if (!activeSession) return;
+ const payload = JSON.stringify(activeSession, null, 2);
+ try {
+ await navigator.clipboard.writeText(payload);
+ setShareNote('Chat copied for sharing');
+ } catch {
+ setShareNote('Share export prepared in browser memory');
+ }
+ }, [activeSession]);
+
+ return (
+
+
+
+
+
+
+
{activeSession?.title ?? 'Oracle Canvas'}
+
Natural CRM intelligence over live Velocity data
+
+
+
+ {shareNote &&
{shareNote}
}
+
+
+
+
+ {!activeSession?.turns.length && (
+
+
✦
+
Start with a business question
+
Example: “Which clients have not been contacted in 7 days but have high QD?”
+
+ )}
+
+ {activeSession?.turns.map((turn) => (
+
+ {turn.prompt}
+ {turn.status === 'thinking' && }
+ {turn.status === 'failed' && (
+
+ )}
+ {turn.status === 'done' && turn.result && (
+
+ )}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/webos/src/pillars/command/OracleWorkspacePage.module.css b/webos/src/pillars/command/OracleWorkspacePage.module.css
new file mode 100644
index 0000000..66753a3
--- /dev/null
+++ b/webos/src/pillars/command/OracleWorkspacePage.module.css
@@ -0,0 +1,14 @@
+.root {
+ min-height: 100%;
+ padding: var(--space-8);
+}
+
+.shellWrap {
+ min-height: calc(100vh - 96px);
+}
+
+@media (max-width: 900px) {
+ .root {
+ padding: var(--space-4);
+ }
+}
diff --git a/webos/src/pillars/command/OracleWorkspacePage.tsx b/webos/src/pillars/command/OracleWorkspacePage.tsx
new file mode 100644
index 0000000..23f85a9
--- /dev/null
+++ b/webos/src/pillars/command/OracleWorkspacePage.tsx
@@ -0,0 +1,12 @@
+import OracleChatConsole from './OracleChatConsole';
+import styles from './OracleWorkspacePage.module.css';
+
+export default function OracleWorkspacePage() {
+ return (
+
+ );
+}
diff --git a/webos/src/pillars/pipeline/ShowroomMode.module.css b/webos/src/pillars/pipeline/ShowroomMode.module.css
index 0f18f09..c997beb 100644
--- a/webos/src/pillars/pipeline/ShowroomMode.module.css
+++ b/webos/src/pillars/pipeline/ShowroomMode.module.css
@@ -7,7 +7,11 @@
.liveContent { display: flex; flex-direction: column; gap: var(--space-5); flex: 1; }
.twoCol { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-5); }
/* Silhouette */
-.vizPanel { display: flex; align-items: center; justify-content: center; min-height: 240px; border-radius: var(--radius-xl); }
+.vizPanel { display: flex; flex-direction: column; align-items: stretch; justify-content: center; gap: var(--space-4); min-height: 320px; border-radius: var(--radius-xl); padding: var(--space-4); }
+.cameraFrame { flex: 1; min-height: 240px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-lg); overflow: hidden; background: radial-gradient(circle at 50% 30%, rgba(124,58,237,0.18), transparent 42%), rgba(0,0,0,0.28); border: var(--glass-border); }
+.cameraVideo { width: 100%; height: 100%; object-fit: cover; display: block; }
+.cameraActions { display: flex; align-items: center; gap: var(--space-3); justify-content: space-between; flex-wrap: wrap; }
+.cameraError { font-size: var(--text-xs); color: var(--color-amber); max-width: 70%; }
.silhouetteWrap { display: flex; flex-direction: column; align-items: center; gap: var(--space-3); }
.silhouette { width: 120px; height: 180px; opacity: 0.7; }
.zoneLabel { font-size: var(--text-xs); color: var(--color-text-tertiary); }
diff --git a/webos/src/pillars/pipeline/ShowroomMode.tsx b/webos/src/pillars/pipeline/ShowroomMode.tsx
index fc5641f..c7880b4 100644
--- a/webos/src/pillars/pipeline/ShowroomMode.tsx
+++ b/webos/src/pillars/pipeline/ShowroomMode.tsx
@@ -29,7 +29,10 @@ export default function ShowroomMode() {
const navigate = useNavigate();
const [phase, setPhase] = useState('live');
const [elapsed, setElapsed] = useState(0); // seconds
+ const [cameraStream, setCameraStream] = useState(null);
+ const [cameraError, setCameraError] = useState(null);
const timerRef = useRef | null>(null);
+ const videoRef = useRef(null);
const {
isShowroomActive,
@@ -45,8 +48,15 @@ export default function ShowroomMode() {
timerRef.current = setInterval(() => setElapsed(s => s + 1), 1000);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
+ cameraStream?.getTracks().forEach((track) => track.stop());
};
- }, []);
+ }, [cameraStream]);
+
+ useEffect(() => {
+ if (videoRef.current && cameraStream) {
+ videoRef.current.srcObject = cameraStream;
+ }
+ }, [cameraStream]);
// End session → summary
const handleEndSession = () => {
@@ -57,9 +67,28 @@ export default function ShowroomMode() {
const handleExitShowroom = () => {
clearPendingAlert();
+ cameraStream?.getTracks().forEach((track) => track.stop());
navigate('/pipeline');
};
+ const handleStartCamera = async () => {
+ try {
+ setCameraError(null);
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: { facingMode: 'environment' },
+ audio: false,
+ });
+ setCameraStream(stream);
+ } catch (error) {
+ setCameraError(error instanceof Error ? error.message : 'Camera permission was denied.');
+ }
+ };
+
+ const handleStopCamera = () => {
+ cameraStream?.getTracks().forEach((track) => track.stop());
+ setCameraStream(null);
+ };
+
const formatTime = (s: number) =>
`${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
@@ -96,7 +125,19 @@ export default function ShowroomMode() {
{/* Left: Anonymized silhouette visualization */}
-
+
+ {cameraStream ? (
+
+ ) : (
+
+ )}
+
+
+
+ {cameraError && {cameraError}}
+
{/* Right: QD Engagement */}
diff --git a/webos/src/pillars/pipeline/client360/Client360.tsx b/webos/src/pillars/pipeline/client360/Client360.tsx
index 766f548..66d701e 100644
--- a/webos/src/pillars/pipeline/client360/Client360.tsx
+++ b/webos/src/pillars/pipeline/client360/Client360.tsx
@@ -35,9 +35,14 @@ export default function Client360() {
const [activeTab, setActiveTab] = useState
('conversations');
const { client, isLoading, error } = useClient360(personId!);
+
+ useEffect(() => {
+ setActiveTab('conversations');
+ }, [personId]);
+
const backToPipeline = () => {
void queryClient.invalidateQueries({ queryKey: ['kanban'] });
- navigate('/pipeline', { replace: true });
+ navigate('/pipeline');
};
if (isLoading) return ;
@@ -168,7 +173,7 @@ export default function Client360() {
(`/crm/client-360/${personId}`);
return mapClient360(normalizeSnapshot(payload));
},
- staleTime: 30_000,
+ staleTime: 0,
+ refetchOnMount: 'always',
enabled: !!personId,
});
return { client: query.data, isLoading: query.isLoading, error: query.error };
@@ -30,7 +31,8 @@ export function useConversations(personId: string) {
const payload = await api.get(`/crm/client-360/${personId}`);
return mapConversationEvents(normalizeSnapshot(payload));
},
- staleTime: 10_000,
+ staleTime: 0,
+ refetchOnMount: 'always',
enabled: !!personId,
});
@@ -51,7 +53,8 @@ export function useClientProperties(personId: string) {
const payload = await api.get(`/crm/client-360/${personId}`);
return mapPropertyInterests(normalizeSnapshot(payload));
},
- staleTime: 60_000,
+ staleTime: 0,
+ refetchOnMount: 'always',
enabled: !!personId,
});
return { properties: query.data ?? [], isLoading: query.isLoading };
@@ -68,7 +71,8 @@ export function useClientTasks(personId: string) {
const payload = await api.get(`/crm/client-360/${personId}`);
return mapTasks(normalizeSnapshot(payload));
},
- staleTime: 30_000,
+ staleTime: 0,
+ refetchOnMount: 'always',
enabled: !!personId,
});
diff --git a/webos/src/shared/hooks/useKanban.ts b/webos/src/shared/hooks/useKanban.ts
index d807a70..6f2ef6c 100644
--- a/webos/src/shared/hooks/useKanban.ts
+++ b/webos/src/shared/hooks/useKanban.ts
@@ -11,9 +11,9 @@ export function useKanban() {
queryKey: ['kanban'],
queryFn: async () => {
const payload = await api.get('/crm/pipeline/kanban?limit=250&offset=0');
- return unwrapArray(payload).map(normalizeStage);
+ return unwrapArray(payload, ['stages', 'kanban', 'columns', 'board']).map(normalizeStage);
},
- staleTime: 30_000,
+ staleTime: 0,
refetchInterval: 60_000,
refetchOnMount: 'always',
refetchOnWindowFocus: true,
@@ -51,30 +51,49 @@ interface RawKanbanStage {
stage?: string;
status?: string;
label?: string;
+ name?: string;
emoji?: string;
leads?: unknown;
items?: unknown;
data?: unknown;
+ records?: unknown;
+ rows?: unknown;
}
function normalizeStage(stage: RawKanbanStage): KanbanStage {
const id = String(stage.id ?? stage.stage ?? stage.status ?? 'new');
- const leads = unwrapArray(stage.leads ?? stage.items ?? stage.data).map(normalizeLead);
+ const leads = unwrapArray(stage, ['leads', 'items', 'data', 'records', 'rows']).map(normalizeLead);
return {
id,
- label: String(stage.label ?? id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())),
+ label: String(stage.label ?? stage.name ?? id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())),
emoji: String(stage.emoji ?? id.slice(0, 1).toUpperCase()),
leads,
};
}
-function normalizeLead(lead: Partial): KanbanLead {
+interface RawKanbanLead extends Partial {
+ person_id?: string;
+ lead_id?: string;
+ client_id?: string;
+ full_name?: string;
+ client_name?: string;
+ project_name?: string;
+ projects?: string;
+ qd_score?: number | string;
+ score?: number | string;
+ last_contact_relative?: string;
+ last_contact_channel?: string;
+ channel?: string;
+}
+
+function normalizeLead(lead: RawKanbanLead): KanbanLead {
return {
...lead,
- id: String(lead.id),
- name: String(lead.name ?? 'Unnamed Client'),
- qdScore: Number.isFinite(Number(lead.qdScore)) ? Number(lead.qdScore) : 0,
- lastContactRelative: String(lead.lastContactRelative ?? 'No contact yet'),
- lastContactChannel: String(lead.lastContactChannel ?? 'crm'),
+ id: String(lead.id ?? lead.person_id ?? lead.lead_id ?? lead.client_id ?? crypto.randomUUID()),
+ name: String(lead.name ?? lead.full_name ?? lead.client_name ?? 'Unnamed Client'),
+ location: lead.location ?? lead.project_name ?? lead.projects,
+ qdScore: Number.isFinite(Number(lead.qdScore ?? lead.qd_score ?? lead.score)) ? Number(lead.qdScore ?? lead.qd_score ?? lead.score) : 0,
+ lastContactRelative: String(lead.lastContactRelative ?? lead.last_contact_relative ?? 'No contact yet'),
+ lastContactChannel: String(lead.lastContactChannel ?? lead.last_contact_channel ?? lead.channel ?? 'crm'),
};
}
diff --git a/webos/src/shared/layout/AuthenticatedShell.tsx b/webos/src/shared/layout/AuthenticatedShell.tsx
index 87da3ce..edc8cd0 100644
--- a/webos/src/shared/layout/AuthenticatedShell.tsx
+++ b/webos/src/shared/layout/AuthenticatedShell.tsx
@@ -19,8 +19,9 @@ export function AuthenticatedShell() {
// Determine spatial direction for pillar transitions
const getPillarIndex = (path: string): number => {
if (path.startsWith('/command')) return 0;
- if (path.startsWith('/pipeline')) return 1;
- if (path.startsWith('/studio')) return 2;
+ if (path.startsWith('/oracle')) return 1;
+ if (path.startsWith('/pipeline')) return 2;
+ if (path.startsWith('/studio')) return 3;
return -1;
};
diff --git a/webos/src/shared/layout/NavRail.tsx b/webos/src/shared/layout/NavRail.tsx
index cacf4fd..cdb71af 100644
--- a/webos/src/shared/layout/NavRail.tsx
+++ b/webos/src/shared/layout/NavRail.tsx
@@ -18,6 +18,12 @@ const PILLARS = [
sublabel: 'Morning Briefing',
icon: CommandIcon,
},
+ {
+ path: '/oracle',
+ label: 'Oracle',
+ sublabel: 'AI Canvas',
+ icon: OracleIcon,
+ },
{
path: '/pipeline',
label: 'Pipeline',
@@ -117,6 +123,27 @@ function CommandIcon({ active }: { active: boolean }) {
);
}
+function OracleIcon({ active }: { active: boolean }) {
+ const c = active ? 'var(--color-violet-light)' : 'currentColor';
+ return (
+
+ );
+}
+
function PipelineIcon({ active }: { active: boolean }) {
const c = active ? 'var(--color-violet-light)' : 'currentColor';
return (
diff --git a/webos/src/shared/lib/apiShape.ts b/webos/src/shared/lib/apiShape.ts
index e51ea85..e392dfa 100644
--- a/webos/src/shared/lib/apiShape.ts
+++ b/webos/src/shared/lib/apiShape.ts
@@ -18,6 +18,14 @@ export function unwrapArray(payload: unknown, keys: string[] = []): T[] {
if (Array.isArray(candidate)) return candidate as T[];
}
+ for (const key of ['data', 'result', 'payload', 'body']) {
+ const candidate = payload[key];
+ if (isRecord(candidate)) {
+ const nested = unwrapArray(candidate, keys);
+ if (nested.length > 0) return nested;
+ }
+ }
+
return [];
}
diff --git a/webos/src/shared/lib/velocitySession.ts b/webos/src/shared/lib/velocitySession.ts
index 582d8a1..65c5a85 100644
--- a/webos/src/shared/lib/velocitySession.ts
+++ b/webos/src/shared/lib/velocitySession.ts
@@ -1,10 +1,25 @@
export const VELOCITY_TOKEN_KEY = 'velocity-api-token';
+const ZUSTAND_AUTH_KEY = 'velocity-auth';
+
+function getPersistedAuthToken(): string | null {
+ const raw = window.localStorage.getItem(ZUSTAND_AUTH_KEY);
+ if (!raw) {
+ return null;
+ }
+
+ try {
+ const parsed = JSON.parse(raw) as { state?: { token?: unknown } };
+ return typeof parsed.state?.token === 'string' && parsed.state.token.length > 0 ? parsed.state.token : null;
+ } catch {
+ return null;
+ }
+}
export function getVelocityToken(): string | null {
if (typeof window === 'undefined') {
return null;
}
- return window.localStorage.getItem(VELOCITY_TOKEN_KEY);
+ return window.localStorage.getItem(VELOCITY_TOKEN_KEY) ?? getPersistedAuthToken();
}
export function setVelocityToken(token: string) {
diff --git a/webos/vite.config.ts b/webos/vite.config.ts
index bcd887d..dbb2533 100644
--- a/webos/vite.config.ts
+++ b/webos/vite.config.ts
@@ -50,6 +50,13 @@ export default defineConfig({
outDir: 'dist',
sourcemap: false, // no source maps in production build
minify: 'esbuild',
+ modulePreload: {
+ resolveDependencies(_url, deps) {
+ // Three.js is only needed after entering Studio/property 3D routes.
+ // Do not preload the heavy R3F chunk on the Command/Pipeline landing path.
+ return deps.filter((dep) => !dep.includes('vendor-three'));
+ },
+ },
rollupOptions: {
output: {
// Content-hash filenames for immutable caching