port aisuite#380: slack installer pre-add, mcp oauth quarantine, automation toast, e2e fixes

Slack installer joins the workspace allow-list on managed connect; MCP interactive oauth only from explicit connects.
Run-started toast over a new app-wide /ws/events socket; Automations e2e locators scoped to the account menu.
This commit is contained in:
Rohit C Prasad
2026-07-21 12:56:36 -07:00
parent d7af8af9c6
commit 2451486609
17 changed files with 467 additions and 19 deletions
+68
View File
@@ -7,6 +7,8 @@ import {
getRecentWorkspaces,
getSessionMessages,
getSessions,
announceAutomationsChanged,
connectEvents,
getSettings,
getPersonas,
getInbox,
@@ -811,6 +813,33 @@ export function App() {
setSessionId(newId());
};
// Inbox → session: the item carries its session's workspace/agent, so open it directly.
// UX-026: 5s top-right toast when a SCHEDULED automation run starts (never for
// manual Run-now — the user is already watching). Rides the app-wide /ws/events
// stream; View run opens the run's live session.
const [runToast, setRunToast] = useState<{
title: string; sessionId: string; workspace: string; agent: string; time: string;
} | null>(null);
useEffect(() => {
const stop = connectEvents((msg) => {
if (msg.type !== "automation_run_started") return;
const d = (msg.data ?? {}) as Record<string, string>;
setRunToast({
title: d.task_title || "Automation",
sessionId: d.session_id || "",
workspace: d.workspace || "",
agent: d.agent || "cowork",
time: new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }),
});
announceAutomationsChanged(); // the Scheduled band's badge is now stale
});
return stop;
}, []);
useEffect(() => {
if (!runToast) return;
const t = window.setTimeout(() => setRunToast(null), 5000);
return () => window.clearTimeout(t);
}, [runToast]);
const openSessionFromInbox = (sid: string, ws: string, ag: string) => selectSession(sid, ws, ag);
const selectSession = async (id: string, ws: string, ag: string) => {
setSurface("session"); // selecting a conversation always returns to the conversation view
@@ -1043,6 +1072,45 @@ export function App() {
)}
{/* Desktop-only auto-update prompt (15s after boot, then every 30 min; inert in browser). */}
<UpdateBanner />
{/* UX-026: automation-start toast — quiet panel, neutral dot/drain, accent only
on the action (rev 2); auto-dismisses with the 5s drain bar. */}
{runToast && (
<div
className="fixed top-3 right-3 z-[45] w-[290px] bg-panel border border-line rounded-xl shadow-lg px-3.5 pt-3 pb-2.5"
data-testid="automation-toast"
>
<div className="flex items-center gap-2 text-[12.5px] font-semibold">
<span className="w-[7px] h-[7px] rounded-full bg-faint toast-pulse" />
Automation started
</div>
<div className="text-[12.5px] text-muted mt-0.5 ml-[15px] truncate">
{runToast.title} · {runToast.time} run
</div>
<div className="flex items-center justify-between ml-[15px] mt-1.5">
<button
className="text-[12.5px] text-accent font-medium"
data-testid="toast-view-run"
onClick={() => {
selectSession(runToast.sessionId, runToast.workspace, runToast.agent);
setRunToast(null);
}}
>
View run
</button>
<button
className="text-[12px] text-faint px-0.5"
data-testid="toast-dismiss"
title="Dismiss"
onClick={() => setRunToast(null)}
>
</button>
</div>
<div className="absolute left-3 right-3 bottom-1 h-[2px] rounded bg-line overflow-hidden">
<span className="block h-full bg-faint toast-drain" />
</div>
</div>
)}
{/* When collapsed, a thin left-edge zone peeks the nav back as a floating overlay. */}
{navCollapsed && (
<div
+31
View File
@@ -1341,6 +1341,37 @@ export function announceAutomationsChanged() {
window.dispatchEvent(new CustomEvent(AUTOMATIONS_CHANGED));
}
/** App-wide event stream (/ws/events): session-independent server pushes — today
* automation_run_started (the UX-026 toast). Quietly reconnects while the app is
* open; the returned cleanup stops it for good. */
export function connectEvents(
onEvent: (msg: { type: string; data?: Record<string, unknown> }) => void
): () => void {
let ws: WebSocket | null = null;
let timer: number | null = null;
let closed = false;
const open = () => {
if (closed) return;
ws = new WebSocket(`${wsBase()}/ws/events`);
ws.onmessage = (e) => {
try {
onEvent(JSON.parse(e.data));
} catch {
/* malformed frame — ignore */
}
};
ws.onclose = () => {
if (!closed) timer = window.setTimeout(open, 5000);
};
};
open();
return () => {
closed = true;
if (timer !== null) window.clearTimeout(timer);
ws?.close();
};
}
/** Advance the automation's seen mark — clears its unseen-runs badge (UX-023). */
export async function markAutomationSeen(id: string): Promise<{ ok: boolean }> {
const res = await fetch(`${httpBase()}/v1/automations/${id}/seen`, { method: "POST" });
+6
View File
@@ -1354,3 +1354,9 @@ html[data-theme="dark"] :not(.connector-badge) > .connector-icon[data-dark-mark]
.art-chip-meta b { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.art-chip-meta span { font-size: 11px; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.art-chip-open { margin-left: 8px; flex: none; font-size: 11.5px; color: var(--accent); font-weight: 500; }
/* ---- UX-026: automation-start toast (top-right, 5s; schedule-fired runs only) ---- */
.toast-pulse { animation: toast-pulse 1.2s ease infinite; }
@keyframes toast-pulse { 50% { opacity: .35; } }
.toast-drain { animation: toast-drain 5s linear forwards; }
@keyframes toast-drain { to { width: 0; } }