feat(dogfood-wave1): Studio retries, Chat error, LLM gateway, kill snapshot, topbar cleanup (#14)
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled

This commit was merged in pull request #14.
This commit is contained in:
2026-06-15 14:30:04 +00:00
parent 601429ea89
commit d5a9c81b9c
17 changed files with 513 additions and 185 deletions
+2 -19
View File
@@ -28,26 +28,13 @@ export default function Agent() {
role: "agent",
ok: true,
text:
`Hi ${userEmail.split("@")[0]}. I'm the FlowMaster Command Assistant. I understand a fixed set of commands and run them against EA2 — navigate, list processes, start a process, message a teammate, remember a note, recall notes. Memory is text-only with keyword recall; an LLM backend is a separate component, off by default.`,
`Hi ${userEmail.split("@")[0]}. I'm the FlowMaster Command Assistant. Tell me what to do in plain English — start a process, list what's published, message a teammate, jump to a hub, remember a note. I run on EA2 and the FlowMaster LLM gateway.`,
},
]);
const [draft, setDraft] = useState("");
const [working, setWorking] = useState(false);
const [llmState, setLlmState] = useState<"unknown" | "ready" | "off">("unknown");
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
fetch("/internal/canvas-llm/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: "probe" }], max_tokens: 1 }),
})
.then((r) => { if (!cancelled) setLlmState(r.status === 503 ? "off" : r.ok ? "ready" : "off"); })
.catch(() => { if (!cancelled) setLlmState("off"); });
return () => { cancelled = true; };
}, []);
useEffect(() => {
setTimeout(() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }), 50);
}, [turns]);
@@ -78,11 +65,7 @@ export default function Agent() {
<header className="agent-side-head">
<div className="mc-hero-eyebrow"><Bot size={12} /> FlowMaster Command Assistant</div>
<h2 className="mc-hero-title">Tell the cockpit what to do</h2>
<p className="agent-side-sub">Deterministic command router with EA2-backed text memory.</p>
<div className="agent-llm-state">
<span className={`agent-llm-pill agent-llm-${llmState}`}>LLM provider · {llmState === "ready" ? "ON" : llmState === "off" ? "OFF" : "…"}</span>
{llmState === "off" && <span className="agent-llm-hint">Natural language replies are not enabled in this environment.</span>}
</div>
<p className="agent-side-sub">Tools + plain-language replies, both backed by EA2.</p>
</header>
<div className="agent-tool-list">
<div className="agent-tool-label">CAPABILITIES</div>
+17 -4
View File
@@ -52,9 +52,11 @@ export default function Chat() {
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const [threadsErr, setThreadsErr] = useState<string | null>(null);
const reloadThreads = () => {
let cancelled = false;
setLoadingThreads(true);
setThreadsErr(null);
chatApi
.listThreads(me)
.then((rows) => {
@@ -62,12 +64,16 @@ export default function Chat() {
setThreads(rows);
if (!activeKey && rows.length > 0) setActiveKey(rows[0]._key);
})
.catch((err) => pushToast("err", `Threads failed: ${err.message}`))
.catch((err) => {
setThreadsErr(err.message);
pushToast("err", `Threads failed: ${err.message}`);
})
.finally(() => !cancelled && setLoadingThreads(false));
return () => {
cancelled = true;
};
}, []);
};
useEffect(() => reloadThreads(), []);
useEffect(() => {
if (!activeKey) return;
@@ -202,7 +208,14 @@ export default function Chat() {
<div className="chat-unread-summary">{unreadCount} unread thread{unreadCount === 1 ? "" : "s"}</div>
)}
{loadingThreads && <div className="chat-empty">Loading threads</div>}
{!loadingThreads && threads.length === 0 && <div className="chat-empty">No conversations yet. Start one above.</div>}
{!loadingThreads && threadsErr && (
<div className="chat-empty chat-err">
<strong>Could not load threads.</strong>
<div>{threadsErr}</div>
<button className="btn btn-ghost btn-sm" onClick={() => reloadThreads()}>Try again</button>
</div>
)}
{!loadingThreads && !threadsErr && threads.length === 0 && <div className="chat-empty">No conversations yet. Start one above.</div>}
{threads.map((t) => {
const preview = previews[t._key];
const lastFromOther = preview && preview.author_email !== me;
+1 -1
View File
@@ -47,7 +47,7 @@ describe("Login Scene", () => {
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
await waitFor(() => {
expect(mockLoginAs).toHaveBeenCalledWith('test@example.com', 'password123', { method: 'password' });
expect(mockSetScene).toHaveBeenCalledWith('landing');
expect(mockSetScene).toHaveBeenCalledWith('mission');
});
});
+3 -3
View File
@@ -56,7 +56,7 @@ export default function Login() {
setError(null);
try {
await loginAs(email, password, { method: "password" });
setScene("landing");
setScene("mission");
} catch (err) {
setError((err as Error).message);
} finally {
@@ -77,7 +77,7 @@ export default function Login() {
setError(null);
try {
await loginAs(email, undefined, { method: "dev" });
setScene("landing");
setScene("mission");
} catch (err) {
setError((err as Error).message);
} finally {
@@ -237,7 +237,7 @@ export default function Login() {
setError(null);
try {
await loginAs(p.email, undefined, { method: "dev" });
setScene("landing");
setScene("mission");
} catch (err) {
setError((err as Error).message);
} finally {
+8 -13
View File
@@ -21,8 +21,8 @@ export default function Settings() {
const setTheme = useApp((s) => s.setTheme);
const consoleOpen = useApp((s) => s.consoleOpen);
const setConsoleOpen = useApp((s) => s.setConsoleOpen);
const mode = useApp((s) => s.mode);
const setMode = useApp((s) => s.setMode);
const refreshLive = useApp((s) => s.refreshLive);
const pushToast = useApp((s) => s.pushToast);
@@ -108,20 +108,15 @@ export default function Settings() {
</section>
<section className="studio-panel">
<h3 className="panel-h"><Pulse size={12} /> Data mode & polling</h3>
<h3 className="panel-h"><Pulse size={12} /> Live data</h3>
<div className="i-field">
<span>Current mode</span>
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span>
<span>Backend</span>
<span className="mono">EA2 · live always</span>
</div>
<div className="settings-row">
<button className="btn btn-primary" onClick={() => setMode(mode === "live" ? "snapshot" : "live")} disabled={signingIn}>
<Refresh size={12} /> {mode === "live" ? "Switch to snapshot" : "Switch to live"}
<button className="btn btn-ghost" onClick={refreshLive} disabled={signingIn}>
<Refresh size={12} /> Refresh now
</button>
{mode === "live" && (
<button className="btn btn-ghost" onClick={refreshLive}>
<Refresh size={12} /> Refresh now
</button>
)}
</div>
<label className="studio-field" style={{ marginTop: 12 }}>
<span>Auto-refresh every (seconds)</span>
@@ -134,7 +129,7 @@ export default function Settings() {
onChange={(e) => setPollEverySec(Number(e.target.value) || 8)}
/>
</label>
<div className="settings-hint">Polling only runs in LIVE mode. Currently {mode === "live" ? "polling" : "paused"}.</div>
<div className="settings-hint">Polling is always active in this build (live-only).</div>
</section>
<section className="studio-panel">