mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-05 00:56:40 +00:00
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:
@@ -0,0 +1,44 @@
|
||||
// UX-026: the automation-start toast — top-right, 5s, schedule-fired runs only.
|
||||
// The server pushes automation_run_started over the app-wide /ws/events stream;
|
||||
// the toast names the automation, offers one View-run action (opens the run's
|
||||
// session), an ✕, and auto-dismisses via the drain bar.
|
||||
import { expect } from "@playwright/test";
|
||||
import { sendAppEvent, test } from "./fixtures";
|
||||
|
||||
const RUN_STARTED = {
|
||||
type: "automation_run_started",
|
||||
data: {
|
||||
task_id: "task-1",
|
||||
task_title: "Daily AI News",
|
||||
session_id: "run-live-1",
|
||||
workspace: "/tmp/aw",
|
||||
agent: "cowork",
|
||||
trigger: "schedule",
|
||||
},
|
||||
};
|
||||
|
||||
test("a schedule-fired run pops the toast; View run opens its session", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await sendAppEvent(page, RUN_STARTED);
|
||||
const toast = page.getByTestId("automation-toast");
|
||||
await expect(toast).toContainText("Automation started");
|
||||
await expect(toast).toContainText("Daily AI News");
|
||||
|
||||
await toast.getByTestId("toast-view-run").click();
|
||||
await expect(page.getByTestId("automation-toast")).toHaveCount(0);
|
||||
// the run's session is now the active conversation (composer visible = session surface)
|
||||
await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("the toast dismisses on ✕ and by itself after ~5s", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await sendAppEvent(page, RUN_STARTED);
|
||||
await expect(page.getByTestId("automation-toast")).toBeVisible();
|
||||
await page.getByTestId("toast-dismiss").click();
|
||||
await expect(page.getByTestId("automation-toast")).toHaveCount(0);
|
||||
|
||||
await sendAppEvent(page, { ...RUN_STARTED, data: { ...RUN_STARTED.data, task_title: "Weekly CRM digest" } });
|
||||
await expect(page.getByTestId("automation-toast")).toContainText("Weekly CRM digest");
|
||||
// auto-dismiss: gone within the 5s drain (+ slack for CI)
|
||||
await expect(page.getByTestId("automation-toast")).toHaveCount(0, { timeout: 7000 });
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { test } from "./fixtures";
|
||||
async function openAutomations(page) {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("account-row").click();
|
||||
await page.getByRole("button", { name: "Automations", exact: true }).click();
|
||||
await page.getByTestId("account-menu").getByRole("button", { name: "Automations", exact: true }).click();
|
||||
await expect(page.getByText("Recurring tasks OpenWorker runs on a schedule.")).toBeVisible();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { test } from "./fixtures";
|
||||
async function openAutomations(page) {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("account-row").click();
|
||||
await page.getByRole("button", { name: "Automations", exact: true }).click();
|
||||
await page.getByTestId("account-menu").getByRole("button", { name: "Automations", exact: true }).click();
|
||||
await expect(page.getByText("Recurring tasks OpenWorker runs on a schedule.")).toBeVisible();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ test("scheduled run session shows the run banner; Back returns to the task detai
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("account-row").click();
|
||||
await page.getByRole("button", { name: "Automations", exact: true }).click();
|
||||
await page.getByTestId("account-menu").getByRole("button", { name: "Automations", exact: true }).click();
|
||||
|
||||
// Task list → detail (runs list).
|
||||
await page.getByText("Daily AI News").first().click();
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { test as base, expect } from "@playwright/test";
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
|
||||
// The app-wide /ws/events socket each page opened (UX-026 toast et al.) — specs
|
||||
// push server events through it via sendAppEvent below.
|
||||
const eventSockets = new WeakMap<Page, { send: (data: string) => void }>();
|
||||
|
||||
/** Push an app-wide event exactly as the server would over /ws/events. Waits for
|
||||
* the GUI to have connected its socket first. */
|
||||
export async function sendAppEvent(page: Page, obj: unknown): Promise<void> {
|
||||
for (let i = 0; i < 50 && !eventSockets.get(page); i++) await page.waitForTimeout(100);
|
||||
const ws = eventSockets.get(page);
|
||||
if (!ws) throw new Error("the app never opened /ws/events");
|
||||
ws.send(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
// Hermetic API mock. Every /v1 request the GUI makes is fulfilled from the fixtures below (shapes
|
||||
// mirrored from the real backend), and the event WebSocket is a SCRIPTED FAKE AGENT (ready on
|
||||
@@ -542,6 +555,11 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
// assistant_deltas → assistant_message "Echo: <text>" → turn_done
|
||||
// · a message containing "run a tool": tool_proposed + permission_required, then the turn
|
||||
// SUSPENDS until the client's approval decision arrives (deny → skipped; else → ran)
|
||||
// App-wide event stream: register the socket so sendAppEvent can push into it.
|
||||
await page.routeWebSocket(/\/ws\/events$/, (ws) => {
|
||||
eventSockets.set(page, ws);
|
||||
});
|
||||
|
||||
await page.routeWebSocket(/\/ws\/session\//, (ws) => {
|
||||
const send = (type: string, data: Record<string, unknown> = {}) =>
|
||||
ws.send(JSON.stringify({ type, data }));
|
||||
|
||||
@@ -8,7 +8,7 @@ import { test, expect } from "./fixtures";
|
||||
async function openTaskDetail(page: import("@playwright/test").Page) {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("account-row").click();
|
||||
await page.getByRole("button", { name: "Automations", exact: true }).click();
|
||||
await page.getByTestId("account-menu").getByRole("button", { name: "Automations", exact: true }).click();
|
||||
await page.getByText("Daily AI News").first().click();
|
||||
await expect(page.getByRole("button", { name: /Run now/ })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
Reference in New Issue
Block a user