fix: studio createDraft split, llm fallback offers tools, topbar a11y pass
Three Oracle-flagged gaps:
1. Studio createDraft 500 (was masked by retries). Empirical curl
reproducer proved POST /api/ea2/flow returns 201 with the minimal
shape but intermittently 500s when config.wizard.* is in the
initial body. Split: POST creates the draft minimally, then a
follow-up PUT attaches the wizard config best-effort. Draft
creation now lands reliably.
2. LLM 'temporarily unavailable' was a dead end for the user. Now
the assistant returns ok:true with a concrete menu of tools the
user CAN use right now ('list processes', 'start <name>',
'open <hub>', 'tell <name> that ...', 'remember ...', 'recall ...').
The reply renders as a normal assistant message, not a red error.
3. Topbar UI/UX Pro Max pass:
- Added aria-label to the ⌘K and avatar buttons (icon-only a11y)
- Removed the duplicate topbar-mid chips on Mission (Mission scene
already shows family/defName/version inline). Cleaner topbar.
- Added focus-visible outlines (2px amber, 2px offset) on all
topbar action buttons + user-menu items
- Hover/active scale on the avatar (1.04 / 0.97) with 160ms ease
- Tabular numerals for the ⌘K kbd and notification badge
- prefers-reduced-motion respected
30/30 vitest pass. tsc + vite build green.
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
// Tight assistant test
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const b = await chromium.launch({ headless: true });
|
||||||
|
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
await ctx.addInitScript(() => localStorage.setItem("fm.canvas.tour.seen", String(Date.now())));
|
||||||
|
|
||||||
|
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "domcontentloaded" });
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
await p.locator(".persona-chip").filter({ hasText: /Mariana/ }).click();
|
||||||
|
await p.waitForSelector(".topbar", { timeout: 30000 });
|
||||||
|
await p.waitForTimeout(2000);
|
||||||
|
|
||||||
|
console.log("on mission, navigating to Assistant");
|
||||||
|
await p.locator(".tab").filter({ hasText: /Assistant/i }).first().click();
|
||||||
|
await p.waitForTimeout(3500);
|
||||||
|
|
||||||
|
const textarea = p.locator(".agent-composer textarea").first();
|
||||||
|
const textareaPresent = await textarea.count() > 0;
|
||||||
|
console.log("textarea present:", textareaPresent);
|
||||||
|
|
||||||
|
if (textareaPresent) {
|
||||||
|
await textarea.fill("list processes");
|
||||||
|
await textarea.press("Enter");
|
||||||
|
await p.waitForTimeout(8000);
|
||||||
|
|
||||||
|
// Try different selectors for the reply
|
||||||
|
const turns = await p.evaluate(() => {
|
||||||
|
const t = Array.from(document.querySelectorAll(".agent-turn, .agent-msg, [class*='turn'], [class*='msg']"));
|
||||||
|
return t.map(el => ({ class: el.className.slice(0, 50), text: el.textContent?.slice(0, 200) }));
|
||||||
|
});
|
||||||
|
console.log("turns:", JSON.stringify(turns, null, 2));
|
||||||
|
|
||||||
|
const sceneText = await p.evaluate(() => document.querySelector(".scene")?.innerText?.slice(-800));
|
||||||
|
console.log("scene tail:", sceneText);
|
||||||
|
}
|
||||||
|
|
||||||
|
await p.screenshot({ path: "/tmp/canvas-evidence/assistant-test.png" });
|
||||||
|
await b.close();
|
||||||
+23
-16
@@ -84,12 +84,17 @@ if (await ta.count()) {
|
|||||||
|
|
||||||
// 5. Assistant list processes
|
// 5. Assistant list processes
|
||||||
await p.locator(".tab").filter({ hasText: /Assistant/i }).first().click();
|
await p.locator(".tab").filter({ hasText: /Assistant/i }).first().click();
|
||||||
await p.waitForTimeout(2500);
|
await p.waitForTimeout(3500);
|
||||||
const agentInput = p.locator(".agent-input, input[placeholder*='Ask']").first();
|
await p.waitForSelector(".agent-composer textarea", { timeout: 10000 }).catch(() => {});
|
||||||
await agentInput.fill("list processes");
|
const agentInput = p.locator(".agent-composer textarea").first();
|
||||||
await agentInput.press("Enter");
|
if (await agentInput.count() === 0) {
|
||||||
await p.waitForTimeout(6000);
|
fail("assistant_input_visible", "no textarea");
|
||||||
const replies = await p.locator(".agent-msg-text, .agent-msg-body").allTextContents();
|
} else {
|
||||||
|
await agentInput.fill("list processes");
|
||||||
|
await agentInput.press("Enter");
|
||||||
|
await p.waitForTimeout(8000);
|
||||||
|
}
|
||||||
|
const replies = await p.locator(".agent-turn-body").allTextContents();
|
||||||
const lastReply = replies.slice(-1)[0] || "";
|
const lastReply = replies.slice(-1)[0] || "";
|
||||||
const mentionsProcess = /purchase|laptop|procurement|requisition|process/i.test(lastReply);
|
const mentionsProcess = /purchase|laptop|procurement|requisition|process/i.test(lastReply);
|
||||||
if (mentionsProcess) pass("assistant_lists_processes", lastReply.slice(0, 100));
|
if (mentionsProcess) pass("assistant_lists_processes", lastReply.slice(0, 100));
|
||||||
@@ -98,12 +103,11 @@ await p.screenshot({ path: `${OUT}/04-assistant.png` });
|
|||||||
|
|
||||||
// 6. Chat opens
|
// 6. Chat opens
|
||||||
await p.locator(".tab").filter({ hasText: /Chat/i }).first().click();
|
await p.locator(".tab").filter({ hasText: /Chat/i }).first().click();
|
||||||
await p.waitForTimeout(4000);
|
await p.waitForTimeout(5000);
|
||||||
const chatErr = await p.locator(".chat-err").count();
|
const chatErr = await p.locator(".chat-err").count();
|
||||||
const chatEmpty = await p.locator(".chat-empty").count();
|
const chatShell = await p.locator(".scene").first().innerText().catch(() => "");
|
||||||
const chatShell = await p.locator(".chat-shell, .chat-scene, .scene").first().innerText().catch(() => "");
|
|
||||||
if (chatErr > 0) fail("chat_opens_without_error", "chat-err visible");
|
if (chatErr > 0) fail("chat_opens_without_error", "chat-err visible");
|
||||||
else if (chatShell.includes("Conversations") || chatShell.includes("Talk to your team") || chatShell.includes("No conversations")) pass("chat_opens_without_error", "chat surface visible");
|
else if (chatShell.toLowerCase().includes("conversation") || chatShell.toLowerCase().includes("talk to") || chatShell.toLowerCase().includes("no conversations") || chatShell.toLowerCase().includes("inbox")) pass("chat_opens_without_error", "chat surface visible");
|
||||||
else fail("chat_opens_without_error", chatShell.slice(0, 200));
|
else fail("chat_opens_without_error", chatShell.slice(0, 200));
|
||||||
await p.screenshot({ path: `${OUT}/05-chat.png` });
|
await p.screenshot({ path: `${OUT}/05-chat.png` });
|
||||||
|
|
||||||
@@ -114,12 +118,15 @@ else fail("no_snapshot_text_anywhere", "found 'snapshot' in body");
|
|||||||
|
|
||||||
// 8. LLM gateway: ask the assistant something non-tool
|
// 8. LLM gateway: ask the assistant something non-tool
|
||||||
await p.locator(".tab").filter({ hasText: /Assistant/i }).first().click();
|
await p.locator(".tab").filter({ hasText: /Assistant/i }).first().click();
|
||||||
await p.waitForTimeout(1500);
|
await p.waitForTimeout(3000);
|
||||||
const llmInput = p.locator(".agent-input, input[placeholder*='Ask']").first();
|
const llmInput = p.locator(".agent-composer textarea").first();
|
||||||
await llmInput.fill("What is the difference between a flow and a view in EA2?");
|
if (await llmInput.count() === 0) { fail("llm_input_visible", "no textarea"); }
|
||||||
await llmInput.press("Enter");
|
else {
|
||||||
await p.waitForTimeout(8000);
|
await llmInput.fill("What is the difference between a flow and a view in EA2?");
|
||||||
const llmReplies = await p.locator(".agent-msg-text, .agent-msg-body").allTextContents();
|
await llmInput.press("Enter");
|
||||||
|
await p.waitForTimeout(10000);
|
||||||
|
}
|
||||||
|
const llmReplies = await p.locator(".agent-turn-body").allTextContents();
|
||||||
const llmLast = llmReplies.slice(-1)[0] || "";
|
const llmLast = llmReplies.slice(-1)[0] || "";
|
||||||
const llmCallsList = calls.filter(c => c.url.includes("/api/v1/llm/generate"));
|
const llmCallsList = calls.filter(c => c.url.includes("/api/v1/llm/generate"));
|
||||||
if (llmCallsList.length > 0) {
|
if (llmCallsList.length > 0) {
|
||||||
|
|||||||
+12
-24
@@ -1,6 +1,6 @@
|
|||||||
// App shell — scene switcher + top bar + command palette + console + toaster.
|
// App shell — scene switcher + top bar + command palette + console + toaster.
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useApp, scenarioById } from "./state/store";
|
import { useApp } from "./state/store";
|
||||||
import Landing from "./scenes/Landing";
|
import Landing from "./scenes/Landing";
|
||||||
import MissionControl from "./scenes/MissionControl";
|
import MissionControl from "./scenes/MissionControl";
|
||||||
import RunHistory from "./scenes/RunHistory";
|
import RunHistory from "./scenes/RunHistory";
|
||||||
@@ -27,8 +27,7 @@ export default function App() {
|
|||||||
const scene = useApp((s) => s.scene);
|
const scene = useApp((s) => s.scene);
|
||||||
const setScene = useApp((s) => s.setScene);
|
const setScene = useApp((s) => s.setScene);
|
||||||
const setCmdOpen = useApp((s) => s.setCmdOpen);
|
const setCmdOpen = useApp((s) => s.setCmdOpen);
|
||||||
const scenarioId = useApp((s) => s.scenarioId);
|
|
||||||
const sc = scenarioById(scenarioId);
|
|
||||||
const mode = useApp((s) => s.mode);
|
const mode = useApp((s) => s.mode);
|
||||||
|
|
||||||
const refreshLive = useApp((s) => s.refreshLive);
|
const refreshLive = useApp((s) => s.refreshLive);
|
||||||
@@ -165,24 +164,7 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="topbar-mid">
|
<div className="topbar-mid" aria-hidden />
|
||||||
{sc && scene === "mission" && (
|
|
||||||
<div className="topbar-context">
|
|
||||||
<span className="topbar-chip">
|
|
||||||
<span className="dot dot-running" /> {sc.family.label}
|
|
||||||
</span>
|
|
||||||
<span className="topbar-chip">
|
|
||||||
<span className="mono">{sc.defName.slice(0, 40)}</span>
|
|
||||||
</span>
|
|
||||||
<span className="topbar-chip mono">{sc.version}</span>
|
|
||||||
{sc.live ? (
|
|
||||||
<span className="tag tag-live">live · EA2</span>
|
|
||||||
) : (
|
|
||||||
<span className="tag tag-syn">blueprint</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
<button
|
<button
|
||||||
@@ -202,8 +184,13 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button className="link-btn" onClick={() => setCmdOpen(true)} title="Command palette (⌘K)">
|
<button
|
||||||
<Cmd size={13} /> <kbd>⌘K</kbd>
|
className="link-btn cmdk-btn"
|
||||||
|
onClick={() => setCmdOpen(true)}
|
||||||
|
title="Open command palette (⌘K)"
|
||||||
|
aria-label="Open command palette"
|
||||||
|
>
|
||||||
|
<Cmd size={13} aria-hidden /> <kbd aria-hidden>⌘K</kbd>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<UserMenu
|
<UserMenu
|
||||||
@@ -294,10 +281,11 @@ function UserMenu(p: UserMenuProps) {
|
|||||||
className="link-btn user-avatar"
|
className="link-btn user-avatar"
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
title={p.userEmail}
|
title={p.userEmail}
|
||||||
|
aria-label={`Account menu for ${p.userEmail}`}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
>
|
>
|
||||||
<span className="user-avatar-circle">{initials || <User size={12} />}</span>
|
<span className="user-avatar-circle" aria-hidden>{initials || <User size={12} />}</span>
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="user-menu-pop" role="menu">
|
<div className="user-menu-pop" role="menu">
|
||||||
|
|||||||
@@ -2503,3 +2503,57 @@ select.studio-input { background: var(--bp-paper); }
|
|||||||
[data-theme="dark"] .user-menu-email,
|
[data-theme="dark"] .user-menu-email,
|
||||||
[data-theme="dark"] .user-menu-item { color: #f5e7c8; }
|
[data-theme="dark"] .user-menu-item { color: #f5e7c8; }
|
||||||
[data-theme="dark"] .user-menu-item:hover:not(:disabled) { background: color-mix(in srgb, #d97a00 12%, transparent); }
|
[data-theme="dark"] .user-menu-item:hover:not(:disabled) { background: color-mix(in srgb, #d97a00 12%, transparent); }
|
||||||
|
|
||||||
|
/* Topbar action polish — UI/UX Pro Max pass */
|
||||||
|
|
||||||
|
.topbar-actions { gap: 6px; }
|
||||||
|
|
||||||
|
.topbar-actions .link-btn {
|
||||||
|
transition: background-color 180ms ease, color 180ms ease, border-color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions .link-btn:focus-visible,
|
||||||
|
.notif-btn:focus-visible,
|
||||||
|
.cmdk-btn:focus-visible,
|
||||||
|
.user-avatar:focus-visible {
|
||||||
|
outline: 2px solid var(--bp-amber, #d97a00);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmdk-btn { gap: 6px; }
|
||||||
|
.cmdk-btn kbd {
|
||||||
|
font-family: var(--bp-mono, ui-monospace, monospace);
|
||||||
|
font-size: 10px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--bp-navy) 25%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--bp-paper) 92%, var(--bp-canvas));
|
||||||
|
color: var(--bp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-btn { position: relative; }
|
||||||
|
.notif-btn:hover:not(:disabled) { color: var(--bp-amber, #d97a00); }
|
||||||
|
.notif-dot { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.user-avatar { padding: 4px !important; }
|
||||||
|
.user-avatar-circle { transition: transform 160ms ease; }
|
||||||
|
.user-avatar:hover .user-avatar-circle { transform: scale(1.04); }
|
||||||
|
.user-avatar:active .user-avatar-circle { transform: scale(0.97); }
|
||||||
|
|
||||||
|
/* user-menu items get tab-able keyboard focus */
|
||||||
|
.user-menu-item:focus-visible {
|
||||||
|
outline: 2px solid var(--bp-amber, #d97a00);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide topbar-mid visually now that it's empty (keeps grid columns) */
|
||||||
|
.topbar-mid:empty { min-width: 0; }
|
||||||
|
|
||||||
|
/* Respect prefers-reduced-motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.topbar-actions .link-btn,
|
||||||
|
.user-avatar-circle {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+12
-5
@@ -196,15 +196,22 @@ async function llmFallback(trimmed: string, ctx: ToolContext, hints: Memory[]):
|
|||||||
if (reply.ok) return { ok: true, display: reply.content };
|
if (reply.ok) return { ok: true, display: reply.content };
|
||||||
if (!reply.configured) {
|
if (!reply.configured) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: true,
|
||||||
display:
|
display:
|
||||||
`Plain-language replies are temporarily unavailable (${reply.reason}). ` +
|
`I don't have a plain-language reply ready right now (${reply.reason}). ` +
|
||||||
`I can still: navigate, list processes, start a process, message a teammate, remember / recall notes.`,
|
`What I can do for you immediately:\n` +
|
||||||
|
`• "list processes" — show every published process\n` +
|
||||||
|
`• "start <process name>" — kick one off\n` +
|
||||||
|
`• "open hr hub" / "open mission" — jump there\n` +
|
||||||
|
`• "tell <name> that ..." — message a teammate\n` +
|
||||||
|
`• "remember ..." / "recall ..." — your tenant memory vault`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: true,
|
||||||
display: `LLM provider error: ${reply.error}. I can still navigate, list/start processes, message a teammate, or remember notes.`,
|
display:
|
||||||
|
`The language model is having trouble (${reply.error.slice(0, 80)}). ` +
|
||||||
|
`Try one of: "list processes", "start <name>", "open <hub>", "tell <name> that ...", "remember ...", "recall ...".`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ export interface CreateDraftPayload {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
description: string;
|
description: string;
|
||||||
source_context: string;
|
source_context: string;
|
||||||
config: {
|
config?: {
|
||||||
wizard: {
|
wizard?: {
|
||||||
marker: string;
|
marker: string;
|
||||||
maxDepth: number;
|
maxDepth: number;
|
||||||
maxNodes: number;
|
maxNodes: number;
|
||||||
|
|||||||
+12
-5
@@ -64,6 +64,13 @@ export default function Wizard() {
|
|||||||
display_name: draft.name || "Untitled Process",
|
display_name: draft.name || "Untitled Process",
|
||||||
description: draft.description,
|
description: draft.description,
|
||||||
source_context: "EA2_DRAFT_PROCESS:process_creation",
|
source_context: "EA2_DRAFT_PROCESS:process_creation",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach the wizard config in a follow-up PUT. EA2 schema validation
|
||||||
|
// intermittently 500s when config.wizard.* is included in the initial
|
||||||
|
// POST; splitting it lets the draft itself land reliably.
|
||||||
|
wizardApi
|
||||||
|
.updateDraftConfig(res._key, {
|
||||||
config: {
|
config: {
|
||||||
wizard: {
|
wizard: {
|
||||||
marker: "EA2_DRAFT_PROCESS",
|
marker: "EA2_DRAFT_PROCESS",
|
||||||
@@ -72,12 +79,12 @@ export default function Wizard() {
|
|||||||
chat: [],
|
chat: [],
|
||||||
uploads: [],
|
uploads: [],
|
||||||
panelState: {},
|
panelState: {},
|
||||||
debug: []
|
debug: [],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
})
|
||||||
|
.catch(() => { /* best-effort; the draft is already saved */ });
|
||||||
|
|
||||||
// Auto-generate basic 5-step template
|
|
||||||
const templateNodes = [
|
const templateNodes = [
|
||||||
{ _key: "n1", kind: "step", display_name: "Capture request details", dispatch_kind: "human" },
|
{ _key: "n1", kind: "step", display_name: "Capture request details", dispatch_kind: "human" },
|
||||||
{ _key: "n2", kind: "step", display_name: "Validate requirements", dispatch_kind: "agent", agent_capability: "evaluate_business_rule" },
|
{ _key: "n2", kind: "step", display_name: "Validate requirements", dispatch_kind: "agent", agent_capability: "evaluate_business_rule" },
|
||||||
|
|||||||
Reference in New Issue
Block a user