diff --git a/docs/features/workbot-command-palette/README.md b/docs/features/workbot-command-palette/README.md new file mode 100644 index 0000000000..94a2252a8b --- /dev/null +++ b/docs/features/workbot-command-palette/README.md @@ -0,0 +1,59 @@ +# Workbot: command palette + +- Feature key: `workbotCommandPalette` +- Deployments: cloud and self-hosted, off by default +- Author: @benjaminshafii + +Members press **Cmd+K** (macOS) or **Ctrl+K** (Windows, Linux), or the search +button in Workbot's header, to search and run what Workbot already does from +its visible controls: + +| Group | Entries | Enter | Cmd/Ctrl+Enter | +| --- | --- | --- | --- | +| Actions | Message Workbot, Stop the reply (while answering), New side chat, Switch chat ›, Show background tasks ›, Show files | Run | | +| Chats | Main chat and side chats (four most recent; all when searched) | Open | Open in a new tab | +| Background tasks | Running tasks of this chat (finished ones when searched) | Show the task card | Stop it | +| Go to | Home, Calendar, Documents, Computer (the ones this person has) | Open | | +| Settings | Your dashboard, Your connections (Den pages the header links to) | Open | Open in a new tab | + +Recently used entries come first. Tab opens a nested list (Switch chat, +Show background tasks); Backspace on an empty search, Shift+Tab or Esc goes +back. Ctrl+N / Ctrl+P move like the arrow keys. Esc closes and focus returns +where it was (usually the message box). + +![The palette open over the conversation](./workbot-command-palette.png) + +With the feature off nothing changes: no header button, the shortcut does +nothing, and no extra request is made. + +![Feature off: the header without the search button](./workbot-command-palette-off.png) + +## How it works + +- Den reports `commandPalette` on `GET /v1/workbot/session` from the feature; + Workbot passes it through `GET /v1/workbot/me` (off while Workbot itself is + off) to `WorkbotHost.commandPalette`. +- `ee/packages/workbot-ui/src/command-palette.tsx` renders it with Base UI + `Dialog` and inline `Autocomplete` (combobox, listbox, active descendant, + live result count). Ranking and recents are in `command-palette-search.ts` + (fuzzysort, the desktop palette's approach), tested in + `ee/apps/workbot/scripts/command-palette.test.ts`. +- No new server routes. Chats come from `GET /v1/workbot/chats` (already used + by the Chats drawer), tasks from the conversation already on screen; Stop + calls the task card's existing stop route. +- Recents are kept in this browser's local storage + (`workbot.command-palette.recents`, entry ids only). + +## Turning it off + +Safe at any moment: the next `/v1/workbot/me` read hides the button and the +shortcut. Nothing is stored on the server, and an open palette simply closes +on the next page load. + +## Proof + +`evals/proofs/workbot-command-palette.proof.test.ts` (`pnpm evals:e2e +workbot-command-palette --local`) drives the real Workbot page headless: +feature off, Cmd+K from the composer, loading, search with highlights, nested +Chats list, no results, Cmd+Enter stopping a task, Enter opening Calendar, +recents, and a 390px phone. diff --git a/docs/features/workbot-command-palette/workbot-command-palette-off.png b/docs/features/workbot-command-palette/workbot-command-palette-off.png new file mode 100644 index 0000000000..277d0c19cb Binary files /dev/null and b/docs/features/workbot-command-palette/workbot-command-palette-off.png differ diff --git a/docs/features/workbot-command-palette/workbot-command-palette.png b/docs/features/workbot-command-palette/workbot-command-palette.png new file mode 100644 index 0000000000..48b1235f12 Binary files /dev/null and b/docs/features/workbot-command-palette/workbot-command-palette.png differ diff --git a/ee/apps/den-api/src/workbot/routes.ts b/ee/apps/den-api/src/workbot/routes.ts index e60bb766ee..6aa6964658 100644 --- a/ee/apps/den-api/src/workbot/routes.ts +++ b/ee/apps/den-api/src/workbot/routes.ts @@ -76,6 +76,8 @@ const sessionSchema = z.object({ activationChecklist: z.boolean().optional(), /** The person can start side chats next to their main chat (while Workbot is on). */ sideChats: z.boolean(), + /** The Cmd+K / Ctrl+K command palette (`workbotCommandPalette`); older Dens omit it: off. */ + commandPalette: z.boolean().optional(), /** * The model Workbot's turns run on: the organization's default model. Null, and * omitted by older Dens, means the headless runner's default. @@ -400,6 +402,7 @@ export function registerWorkbotRoutes(app: Hono usageRecovery: includedModels, activationChecklist: features.activationChecklist, sideChats: true, + commandPalette: features.workbotCommandPalette, model, gatewayModels: true, stepTurns: true, diff --git a/ee/apps/workbot/package.json b/ee/apps/workbot/package.json index 44e419c52e..b0d1b3410f 100644 --- a/ee/apps/workbot/package.json +++ b/ee/apps/workbot/package.json @@ -20,7 +20,8 @@ "test:events": "tsx --conditions=development --test scripts/events.test.ts", "test:steps": "tsx --conditions=development --test scripts/steps.test.ts", "test:session-cells": "tsx --conditions=development --test scripts/session-cells.test.ts", - "test:activation": "tsx --conditions=development --test scripts/activation.test.ts" + "test:activation": "tsx --conditions=development --test scripts/activation.test.ts", + "test:command-palette": "tsx --conditions=development --test scripts/command-palette.test.ts" }, "dependencies": { "@hono/node-server": "2.0.12", diff --git a/ee/apps/workbot/scripts/command-palette.test.ts b/ee/apps/workbot/scripts/command-palette.test.ts new file mode 100644 index 0000000000..e10ba155e8 --- /dev/null +++ b/ee/apps/workbot/scripts/command-palette.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { Hono } from "hono" +import { highlightRuns, loadPaletteRecents, matchIndexes, rankPaletteItems, recordPaletteRecent, SEARCH_LIMIT, type Rankable } from "../../../packages/workbot-ui/src/command-palette-search.js" +import { createAuth, type AppEnv } from "../src/server/auth.js" +import { createDen, denSessionSchema } from "../src/server/den.js" +import { loadConfig } from "../src/server/config.js" +import { registerWorkbotRoutes } from "../src/server/routes.js" + +const PUBLIC = "http://localhost:3090" + +async function me(session: Record) { + const config = loadConfig({ WORKBOT_PUBLIC_URL: PUBLIC, WORKBOT_DEN_API_URL: "http://localhost:3091", WORKBOT_RUNNER_URL: "http://localhost:3092", WORKBOT_RUNNER_TOKEN: "r".repeat(32), WORKBOT_SESSION_SECRET: "s".repeat(32) }) + const den = createDen({ apiUrl: config.denApiUrl, publicUrl: config.publicUrl }) + const app = new Hono() + registerWorkbotRoutes(app, { config, den, sameOrigin: createAuth({ config, den }).sameOrigin, member: async (c, next) => { + const who = denSessionSchema.parse({ user: { id: "user_example", name: "Example", email: "member@example.test" }, organization: { id: "org_example", name: "Example workspace", brandAppName: null }, memberId: "member_example", canSchedule: false, ...session }) + c.set("member", { session: { id: "session_example", userId: who.user.id, organizationId: who.organization.id, tokens: { accessToken: "fixture-token", expiresAt: Date.now() + 60000 }, createdAt: Date.now() }, accessToken: "fixture-token", den: who }) + await next() + } }) + const body: unknown = await (await app.request(`${PUBLIC}/v1/workbot/me`, { headers: { origin: PUBLIC } })).json() + assert.ok(typeof body === "object" && body !== null && "commandPalette" in body) + return body.commandPalette +} + +test("the page sees the palette only when Den turns it on for a member Workbot is on for", async () => { + assert.equal(await me({ enabled: true }), false, "a Den from before the feature says nothing: off") + assert.equal(await me({ enabled: true, commandPalette: false }), false) + assert.equal(await me({ enabled: false, commandPalette: true }), false, "never while Workbot itself is off") + assert.equal(await me({ enabled: true, commandPalette: true }), true) +}) + +const items: Rankable[] = [ + { id: "action:new-side-chat", group: "actions", title: "New side chat", keywords: "start create conversation topic" }, + { id: "action:tasks", group: "actions", title: "Show background tasks", keywords: "jobs work progress" }, + { id: "chat:budget", group: "chats", title: "Budget review", keywords: "side chat conversation" }, + { id: "chat:launch", group: "chats", title: "Launch plan", keywords: "side chat conversation" }, + { id: "chat:old", group: "chats", title: "Old notes", keywords: "side chat conversation", preview: false }, + { id: "nav:calendar", group: "navigation", title: "Calendar", keywords: "week meetings schedule automations" }, + { id: "settings:connections", group: "settings", title: "Your connections", keywords: "apps google microsoft calendar connect" }, +] + +test("with nothing typed, recent entries come first, then each group in order, without search-only entries", () => { + const groups = rankPaletteItems("", items, ["nav:calendar", "gone"]) + assert.deepEqual(groups.map((group) => group.value), ["recent", "actions", "chats", "settings"]) + assert.deepEqual(groups[0]?.items.map((item) => item.id), ["nav:calendar"], "a recent id that no longer exists is skipped") + assert.ok(!groups.flatMap((group) => group.items).some((item) => item.id === "chat:old"), "older chats appear only when searched") +}) + +test("search ranks a title match above a keyword match, leads with the best group and finds search-only entries", () => { + const calendar = rankPaletteItems("calendar", items, []) + assert.equal(calendar[0]?.items[0]?.id, "nav:calendar", "the Calendar page beats the connections row that mentions calendars") + assert.equal(calendar[0]?.value, "navigation") + assert.equal(rankPaletteItems("old", items, [])[0]?.items[0]?.id, "chat:old") + assert.deepEqual(rankPaletteItems("xyzzy", items, []), []) + assert.equal(rankPaletteItems("bud", items, [])[0]?.items[0]?.id, "chat:budget") +}) + +test("a recently used entry wins a tie, and a long list stays short", () => { + const twins: Rankable[] = [{ id: "a", group: "chats", title: "Plan" }, { id: "b", group: "chats", title: "Plan" }] + assert.equal(rankPaletteItems("plan", twins, ["b"])[0]?.items[0]?.id, "b") + const many: Rankable[] = Array.from({ length: 500 }, (_, index) => ({ id: `chat:${index}`, group: "chats", title: `Weekly report ${index}` })) + assert.equal(rankPaletteItems("weekly", many, []).flatMap((group) => group.items).length, SEARCH_LIMIT) +}) + +test("matched letters are emphasized in order, as plain text runs", () => { + assert.deepEqual(highlightRuns("Budget review", matchIndexes("bud", "Budget review")), [{ text: "Bud", match: true }, { text: "get review", match: false }]) + assert.deepEqual(highlightRuns("Calendar", matchIndexes("", "Calendar")), [{ text: "Calendar", match: false }]) +}) + +test("recents keep the newest eight, once each, and survive unreadable storage", () => { + const store = new Map() + const storage = { getItem: (key: string) => store.get(key) ?? null, setItem: (key: string, value: string) => { store.set(key, value) } } + for (const id of ["a", "b", "c", "a", "d", "e", "f", "g", "h", "i"]) recordPaletteRecent(id, storage) + assert.deepEqual(loadPaletteRecents(storage), ["i", "h", "g", "f", "e", "d", "a", "c"]) + store.set("workbot.command-palette.recents", "{not json") + assert.deepEqual(loadPaletteRecents(storage), []) + const failing = { getItem: () => null, setItem: () => { throw new Error("quota") } } + assert.deepEqual(recordPaletteRecent("x", failing), ["x"], "running an action never depends on storage") +}) diff --git a/ee/apps/workbot/src/client/host.ts b/ee/apps/workbot/src/client/host.ts index e3496867fc..fd9d6382d5 100644 --- a/ee/apps/workbot/src/client/host.ts +++ b/ee/apps/workbot/src/client/host.ts @@ -27,6 +27,8 @@ export const meSchema = z.object({ canSchedule: z.boolean().default(false), /** Side chats are on for this person (while Workbot is on). */ sideChats: z.boolean().default(false), + /** The Cmd+K / Ctrl+K command palette; older servers omit it. */ + commandPalette: z.boolean().default(false), denUrl: z.string().nullable(), }) export type Me = z.infer @@ -106,6 +108,7 @@ export function createHost(me: Me, recovery: { authorization?: WorkbotHost["docu && current.userId === me.userId && current.organizationId === me.organizationId }, sideChats: me.sideChats, + commandPalette: me.commandPalette, identity: me.userId && me.organizationId ? { principalId: me.userId, organizationId: me.organizationId } : undefined, } } diff --git a/ee/apps/workbot/src/server/den.ts b/ee/apps/workbot/src/server/den.ts index 9a4b3cda66..95a21a8c9f 100644 --- a/ee/apps/workbot/src/server/den.ts +++ b/ee/apps/workbot/src/server/den.ts @@ -46,6 +46,8 @@ export const denSessionSchema = z.object({ activationChecklist: z.boolean().default(false), /** Side chats are on for this person. A Den from before side chats doesn't say: off. */ sideChats: z.boolean().default(false), + /** The Cmd+K / Ctrl+K command palette. A Den from before it doesn't say: off. */ + commandPalette: z.boolean().default(false), /** The organization's default model for Workbot's turns. Null, or a Den from before it, means the runner's default. */ model: z.string().nullable().default(null), verifyModel: z.boolean().default(false), diff --git a/ee/apps/workbot/src/server/routes.ts b/ee/apps/workbot/src/server/routes.ts index b254a88ac4..7d6aa08679 100644 --- a/ee/apps/workbot/src/server/routes.ts +++ b/ee/apps/workbot/src/server/routes.ts @@ -223,6 +223,7 @@ export function registerWorkbotRoutes(app: Hono, input: { config: Config activationChecklist: who.enabled && who.activationChecklist, canSchedule: who.enabled && who.canSchedule, sideChats: who.enabled && who.sideChats, + commandPalette: who.enabled && who.commandPalette, denUrl: config.denWebUrl ?? (await den.webUrl().catch(() => null)), }) }) diff --git a/ee/packages/workbot-ui/package.json b/ee/packages/workbot-ui/package.json index 64cb4dc397..da4d58b418 100644 --- a/ee/packages/workbot-ui/package.json +++ b/ee/packages/workbot-ui/package.json @@ -21,13 +21,14 @@ "@base-ui/react": "^1.4.1", "@novnc/novnc": "1.7.0", "@openwork/calendar": "workspace:*", + "@openwork/documents": "workspace:*", "@openwork/types": "workspace:*", "@openwork/ui": "workspace:*", "@openwork/workbook": "workspace:*", + "fuzzysort": "^3.1.0", "lucide-react": "^0.577.0", "marked": "^17.0.1", - "zod": "^4.6.6", - "@openwork/documents": "workspace:*" + "zod": "^4.6.6" }, "peerDependencies": { "@tanstack/react-query": "^5.96.2", diff --git a/ee/packages/workbot-ui/src/command-palette-search.ts b/ee/packages/workbot-ui/src/command-palette-search.ts new file mode 100644 index 0000000000..8581814aac --- /dev/null +++ b/ee/packages/workbot-ui/src/command-palette-search.ts @@ -0,0 +1,145 @@ +import fuzzysort from "fuzzysort"; + +/** + * Ranking for Workbot's command palette (feature `workbotCommandPalette`): pure, so it is tested without a page. + * The same idea as the desktop palette's search (apps/app/src/react-app/shell/command-palette-search.ts): fuzzy + * match on the title first, then keywords and detail; recently used entries win ties. + */ + +export type PaletteGroupId = "recent" | "actions" | "chats" | "tasks" | "navigation" | "settings"; + +export const PALETTE_GROUP_LABELS: Record = { + recent: "Recent", + actions: "Actions", + chats: "Chats", + tasks: "Background tasks", + navigation: "Go to", + settings: "Settings", +}; + +const GROUP_ORDER: readonly PaletteGroupId[] = ["actions", "chats", "tasks", "navigation", "settings"]; + +/** What the ranking needs from an entry. */ +export type Rankable = { + id: string; + group: Exclude; + title: string; + detail?: string; + keywords?: string; + /** Shown when nothing is typed. Entries that only appear when searched (every chat, older tasks) set false. */ + preview?: boolean; +}; + +export type RankedGroup = { value: PaletteGroupId; label: string; items: T[] }; + +/** The most entries a search lists, so a person with hundreds of chats still gets an instant, short list. */ +export const SEARCH_LIMIT = 50; +const RECENT_LIMIT = 5; + +function group(value: PaletteGroupId, items: T[]): RankedGroup { + return { value, label: PALETTE_GROUP_LABELS[value], items }; +} + +function normalize(value: string) { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + +export function rankPaletteItems(query: string, items: readonly T[], recentIds: readonly string[]): RankedGroup[] { + const trimmed = query.trim(); + if (!trimmed) { + const byId = new Map(items.map((item) => [item.id, item])); + const recent = recentIds.flatMap((id) => { + const item = byId.get(id); + return item ? [item] : []; + }).slice(0, RECENT_LIMIT); + const shown = new Set(recent.map((item) => item.id)); + return [ + group("recent", recent), + ...GROUP_ORDER.map((value) => group(value, items.filter((item) => item.group === value && item.preview !== false && !shown.has(item.id)))), + ].filter((entry) => entry.items.length > 0); + } + + const wanted = normalize(trimmed); + const tokens = wanted.split(" "); + const haystack = (item: T) => [item.title, item.keywords, item.detail].filter(Boolean).join(" "); + // Every word has to match somewhere, so "side bud" finds the "Budget" side chat but not every side chat. + const candidates = items.filter((item) => tokens.every((token) => fuzzysort.single(token, haystack(item)) !== null)); + const recent = new Set(recentIds); + const ranked = fuzzysort.go(trimmed, candidates, { + keys: [(item) => item.title, (item) => item.keywords ?? "", (item) => item.detail ?? ""], + threshold: 0.3, + limit: SEARCH_LIMIT, + scoreFn: (result) => { + const score = Math.max(result[0].score * 2, result[1].score, result[2].score * 0.7); + const title = normalize(result.obj.title); + const tier = title.startsWith(wanted) ? 4 : title.includes(wanted) ? 3 : normalize(result.obj.keywords ?? "").includes(wanted) ? 2 : 1; + return tier * 10 + (recent.has(result.obj.id) ? score * 1.1 : score); + }, + }).map((result) => result.obj); + // Tokens that matched across fields but not as one phrase still count, after the phrase matches. + const extra = tokens.length > 1 ? candidates.filter((item) => !ranked.includes(item)).slice(0, Math.max(0, SEARCH_LIMIT - ranked.length)) : []; + const ordered = [...ranked, ...extra]; + const best = ordered[0]?.group; + const order = best ? [best, ...GROUP_ORDER.filter((value) => value !== best)] : GROUP_ORDER; + return order.flatMap((value) => { + const entries = ordered.filter((item) => item.group === value); + return entries.length > 0 ? [group(value, entries)] : []; + }); +} + +/** Character positions of `query` in `text` to emphasize, or none. */ +export function matchIndexes(query: string, text: string): ReadonlySet { + const trimmed = query.trim(); + if (!trimmed) return new Set(); + const whole = fuzzysort.single(trimmed, text); + if (whole) return new Set(whole.indexes); + const marked = new Set(); + for (const token of normalize(trimmed).split(" ")) for (const index of fuzzysort.single(token, text)?.indexes ?? []) marked.add(index); + return marked; +} + +/** Split `text` into runs, each matched or not, for rendering emphasis without innerHTML. */ +export function highlightRuns(text: string, marked: ReadonlySet): Array<{ text: string; match: boolean }> { + const runs: Array<{ text: string; match: boolean }> = []; + for (let index = 0; index < text.length; index++) { + const match = marked.has(index); + const last = runs.at(-1); + if (last && last.match === match) last.text += text[index]; + else runs.push({ text: text[index] ?? "", match }); + } + return runs; +} + +export const COMMAND_PALETTE_RECENTS_KEY = "workbot.command-palette.recents"; +const RECENTS_KEPT = 8; + +type RecentsStorage = Pick; + +function browserStorage(): RecentsStorage | undefined { + try { + return typeof window === "undefined" ? undefined : window.localStorage; + } catch { + return undefined; + } +} + +export function loadPaletteRecents(storage: RecentsStorage | undefined = browserStorage()): string[] { + if (!storage) return []; + try { + const parsed: unknown = JSON.parse(storage.getItem(COMMAND_PALETTE_RECENTS_KEY) ?? "[]"); + if (!Array.isArray(parsed)) return []; + return [...new Set(parsed.filter((id): id is string => typeof id === "string"))].slice(0, RECENTS_KEPT); + } catch { + return []; + } +} + +export function recordPaletteRecent(id: string, storage: RecentsStorage | undefined = browserStorage()): string[] { + const next = [id, ...loadPaletteRecents(storage).filter((entry) => entry !== id)].slice(0, RECENTS_KEPT); + try { + storage?.setItem(COMMAND_PALETTE_RECENTS_KEY, JSON.stringify(next)); + } catch { + // Storage off or full: the action still runs; it just isn't remembered. + } + return next; +} diff --git a/ee/packages/workbot-ui/src/command-palette.tsx b/ee/packages/workbot-ui/src/command-palette.tsx new file mode 100644 index 0000000000..70566901a4 --- /dev/null +++ b/ee/packages/workbot-ui/src/command-palette.tsx @@ -0,0 +1,477 @@ +"use client"; + +import { Autocomplete } from "@base-ui/react/autocomplete"; +import { Dialog } from "@base-ui/react/dialog"; +import { + CalendarDays, + Check, + ChevronLeft, + ChevronRight, + CircleAlert, + CircleDashed, + FileText, + House, + LayoutDashboard, + ListChecks, + Lock, + MessageCircle, + MessagesSquare, + Monitor, + PenLine, + Plug, + Plus, + RotateCw, + Search, + Square, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; +import { chatTitle, sinceLabel } from "./chats"; +import { + highlightRuns, + loadPaletteRecents, + matchIndexes, + rankPaletteItems, + recordPaletteRecent, + type RankedGroup, + type Rankable, +} from "./command-palette-search"; +import { newChatId, useStopWorkbotTask, type WorkbotChats, type WorkbotTask } from "./data"; +import { useRetainedSurface } from "./host"; + +/** + * Workbot's command palette (feature `workbotCommandPalette`): Cmd+K / Ctrl+K from anywhere, or the search button in + * the header. Every entry is something Workbot already does from a visible control (DESIGN S6): switch chats, start a + * side chat, stop a reply or a background task, open a page or the files, and the Den pages the header links to. + * Base UI's Dialog and Autocomplete own focus, the combobox/listbox roles and arrow-key navigation (DESIGN P5). + */ + +export type WorkbotPaletteTab = "home" | "calendar" | "documents" | "computer"; +type Page = "root" | "chats" | "tasks"; + +type Entry = Rankable & { + icon: ReactNode; + /** Quiet trailing text: when a chat was last used, how long a task has run. */ + meta?: string; + /** Why this can't run right now, shown on the row (DESIGN P4). The row stays visible but disabled. */ + locked?: string; + /** Opens a nested page (Tab or Enter) instead of running. */ + page?: Exclude; + /** What Enter does, as the highlighted row says it. */ + verb: string; + run?: () => void; + /** Keeps the palette open after running (Try again). */ + stayOpen?: boolean; + /** Runs once the palette has closed, for actions that move focus (Write a message, Show a task). */ + thenFocus?: () => void; + /** Cmd/Ctrl+Enter. */ + alt?: { verb: string; run: () => void }; +}; + +export type WorkbotCommandPaletteProps = { + name: string; + tab: WorkbotPaletteTab; + tabs: readonly WorkbotPaletteTab[]; + onTab: (tab: WorkbotPaletteTab) => void; + /** Side chats, where they are on. */ + chats: { + current: string | null; + list: WorkbotChats | undefined; + loading: boolean; + failed: boolean; + retry: () => void; + open: (chat: string | null) => void; + /** Unsaved document drafts keep the person in this chat. */ + locked: boolean; + } | null; + busy: boolean; + onStop: (() => void) | null; + onOpenFiles: (() => void) | null; + tasks: readonly WorkbotTask[]; + onFocusComposer: (() => void) | null; + onShowTask: (taskId: string) => void; + homeHref: string; + connectionsHref: string | null; +}; + +const TAB_LABEL: Record = { home: "Home", calendar: "Calendar", documents: "Documents", computer: "Computer" }; +const TAB_ICON: Record = { + home: , + calendar: , + documents: , + computer: , +}; +const TAB_KEYWORDS: Record = { + home: "chat conversation main start", + calendar: "week meetings schedule automations", + documents: "drive files docs sheets", + computer: "browser desktop screen", +}; + +function isMac() { + return typeof navigator !== "undefined" && /mac|iphone|ipad|ipod/i.test(navigator.platform); +} + +const running = (task: WorkbotTask) => task.status === "queued" || task.status === "working" || task.status === "paused"; + +function taskState(task: WorkbotTask) { + if (task.status === "queued") return "Waiting to start"; + if (task.status === "paused") return "Picking it back up"; + if (task.status === "working") return task.update ?? "Working on it"; + return task.status === "done" ? "Done" : task.status === "failed" ? "Couldn't finish" : "Stopped"; +} + +function taskIcon(task: WorkbotTask) { + if (running(task)) return ; + if (task.status === "done") return ; + if (task.status === "failed") return ; + return ; +} + +function chatHref(chat: string | null) { + const url = new URL(window.location.href); + if (chat) url.searchParams.set("chat", chat); + else url.searchParams.delete("chat"); + return url.toString(); +} + +const openInNewTab = (href: string) => { window.open(href, "_blank", "noopener,noreferrer"); }; + +function Kbd({ children, label }: { children: ReactNode; label?: string }) { + return {children}; +} + +function Highlighted({ text, query }: { text: string; query: string }) { + const marked = matchIndexes(query, text); + if (marked.size === 0) return <>{text}; + return <>{highlightRuns(text, marked).map((run, index) => run.match + ? {run.text} + : {run.text})}; +} + +/** Where the Chats group will be, while it loads: the label and three rows of the same height. */ +function ChatsSkeleton() { + return ( +
+

Chats

+ {[44, 32, 38].map((width) => ( +
+ + +
+ ))} +
+ ); +} + +const NO_CHATS: WorkbotChats["side"] = []; + +const ICON_BUTTON = + "grid h-8 w-8 shrink-0 place-items-center rounded-full text-[var(--wb-muted)] transition-colors duration-150 hover:bg-[var(--wb-chip)] hover:text-[var(--wb-text)] focus-visible:outline-none focus-visible:shadow-[var(--wb-focus)]"; + +export function WorkbotCommandPalette(props: WorkbotCommandPaletteProps) { + const mac = useMemo(isMac, []); + const mod = mac ? "⌘" : "Ctrl"; + const [open, setOpen] = useState(false); + const surface = useRetainedSurface(open); + const [page, setPage] = useState("root"); + const [query, setQuery] = useState(""); + const [recents, setRecents] = useState(loadPaletteRecents); + const [highlightedId, setHighlightedId] = useState(null); + const input = useRef(null); + const scroller = useRef(null); + /** Where focus goes back to when the palette was opened from the keyboard (the composer, a document). */ + const returnTo = useRef(null); + /** An action's own focus move, run once the palette has closed. */ + const pendingFocus = useRef<(() => void) | null>(null); + const stopTask = useStopWorkbotTask(); + const blocked = surface.blocked; + + const close = useCallback((thenFocus?: () => void) => { + pendingFocus.current = thenFocus ?? null; + setOpen(false); + }, []); + const flushFocus = () => { + const next = pendingFocus.current; + pendingFocus.current = null; + next?.(); + }; + // A page change can unmount the header mid-close; the focus move still happens. + useEffect(() => () => { + const next = pendingFocus.current; + pendingFocus.current = null; + if (next) window.setTimeout(next, 0); + }, []); + + useEffect(() => { + if (!open) { setPage("root"); setQuery(""); setHighlightedId(null); } + else setRecents(loadPaletteRecents()); + }, [open]); + + // A new search or page starts at the top, where the best match is, after the list's own scroll-into-view settles. + useEffect(() => { + if (!open) return; + let frame = window.requestAnimationFrame(() => { frame = window.requestAnimationFrame(() => { if (scroller.current) scroller.current.scrollTop = 0; }); }); + return () => window.cancelAnimationFrame(frame); + }, [open, page, query]); + + // Cmd+K on macOS, Ctrl+K elsewhere, from anywhere on the page, including the composer. Pressed again, it closes. + useEffect(() => { + if (blocked) return; + const onKey = (event: globalThis.KeyboardEvent) => { + if (event.isComposing || event.altKey || event.shiftKey || event.key.toLowerCase() !== "k") return; + if (!(mac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey)) return; + event.preventDefault(); + setOpen((current) => { + if (!current) returnTo.current = document.activeElement instanceof HTMLElement && document.activeElement !== document.body ? document.activeElement : null; + return !current; + }); + }; + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, [blocked, mac]); + + const { chats, tasks } = props; + const sideChats = chats?.list?.side ?? NO_CHATS; + const runningTasks = tasks.filter(running); + + const chatEntries = useMemo(() => { + // Until the list arrives the Chats group is a skeleton, so rows never jump in under the pointer. + if (!chats || !chats.list) return []; + const lock = chats.locked ? "Save or close document drafts first" : undefined; + const entry = (id: string | null, title: string, updatedAt: number | undefined, preview: boolean): Entry => ({ + id: `chat:${id ?? "main"}`, + group: "chats", + title, + detail: chats.current === id ? "Open now" : lock, + meta: updatedAt ? sinceLabel(updatedAt) : undefined, + keywords: id ? "side chat conversation" : "main chat conversation home", + preview, + icon: lock && chats.current !== id ? : , + locked: chats.current === id ? "Open now" : lock, + verb: "Open", + run: () => chats.open(id), + thenFocus: props.onFocusComposer ?? undefined, + alt: { verb: "New tab", run: () => openInNewTab(chatHref(id)) }, + }); + return [ + entry(null, "Main chat", chats.list?.main?.updatedAt, chats.current !== null), + ...sideChats.map((chat, index) => entry(chat.id, chatTitle(chat.title), chat.updatedAt, index < 4 && chat.id !== chats.current)), + ]; + }, [chats, sideChats, props.onFocusComposer]); + + const taskEntries = useMemo(() => [...tasks].sort((a, b) => Number(running(b)) - Number(running(a)) || (b.startedAt ?? 0) - (a.startedAt ?? 0)).map((task) => ({ + id: `task:${task.id}`, + group: "tasks", + title: task.title, + detail: taskState(task), + meta: task.startedAt ? sinceLabel(task.startedAt) : undefined, + keywords: `background task job ${task.status}`, + preview: running(task), + icon: taskIcon(task), + verb: "Show", + run: () => { if (props.tab !== "home") props.onTab("home"); }, + thenFocus: () => props.onShowTask(task.id), + alt: running(task) ? { verb: "Stop", run: () => stopTask.mutate(task.id) } : undefined, + })), [tasks, props, stopTask]); + + const rootEntries = useMemo(() => { + const entries: Entry[] = []; + if (props.onFocusComposer) entries.push({ id: "action:write", group: "actions", title: `Message ${props.name}`, keywords: "write type ask compose focus", icon: , verb: "Write", run: () => { if (props.tab !== "home") props.onTab("home"); }, thenFocus: props.onFocusComposer }); + if (props.busy && props.onStop) entries.push({ id: "action:stop", group: "actions", title: "Stop the reply", detail: `${props.name} is answering`, keywords: "cancel halt interrupt", icon: , verb: "Stop", run: props.onStop }); + if (chats) { + const lock = chats.locked ? "Save or close document drafts first" : undefined; + entries.push({ id: "action:new-side-chat", group: "actions", title: "New side chat", detail: lock, keywords: "start create conversation topic", locked: lock, icon: lock ? : , verb: "Start", run: () => chats.open(newChatId()), thenFocus: props.onFocusComposer ?? undefined }); + entries.push({ id: "action:switch-chat", group: "actions", title: "Switch chat", detail: chats.list ? `${(sideChats.length + 1).toLocaleString()} chats` : undefined, keywords: "chats side conversations list go", icon: , page: "chats", verb: "Open" }); + if (chats.failed && !chats.list) entries.push({ id: "action:retry-chats", group: "chats", title: "Couldn't load your chats", detail: "Try again", keywords: "retry reload", icon: , verb: "Try again", run: chats.retry, stayOpen: true }); + } + if (tasks.length > 0) entries.push({ id: "action:tasks", group: "actions", title: "Show background tasks", detail: runningTasks.length > 0 ? `${runningTasks.length.toLocaleString()} running` : `${tasks.length.toLocaleString()} finished`, keywords: "jobs work progress", icon: , page: "tasks", verb: "Open" }); + if (props.onOpenFiles) entries.push({ id: "action:files", group: "actions", title: "Show files", keywords: "uploads attachments documents outputs", icon: , verb: "Show", run: props.onOpenFiles }); + for (const tab of props.tabs) { + entries.push({ id: `nav:${tab}`, group: "navigation", title: TAB_LABEL[tab], detail: props.tab === tab ? "Open now" : undefined, keywords: TAB_KEYWORDS[tab], icon: TAB_ICON[tab], locked: props.tab === tab ? "Open now" : undefined, verb: "Go", run: () => props.onTab(tab) }); + } + entries.push({ id: "settings:dashboard", group: "settings", title: "Your dashboard", detail: "OpenWork", keywords: "account profile organization workspace admin", icon: , verb: "Open", run: () => window.location.assign(props.homeHref), alt: { verb: "New tab", run: () => openInNewTab(props.homeHref) } }); + const connections = props.connectionsHref; + if (connections) entries.push({ id: "settings:connections", group: "settings", title: "Your connections", detail: "OpenWork", keywords: "apps google microsoft calendar connect accounts integrations", icon: , verb: "Open", run: () => window.location.assign(connections), alt: { verb: "New tab", run: () => openInNewTab(connections) } }); + return [...entries, ...chatEntries, ...taskEntries]; + }, [props, chats, sideChats.length, tasks.length, runningTasks.length, chatEntries, taskEntries]); + + const pageEntries = useMemo(() => page === "chats" ? [ + ...rootEntries.filter((entry) => entry.id === "action:new-side-chat"), + ...chatEntries.map((entry) => ({ ...entry, preview: true })), + ] : page === "tasks" ? taskEntries.map((entry) => ({ ...entry, preview: true })) : rootEntries, [page, rootEntries, chatEntries, taskEntries]); + const groups: RankedGroup[] = useMemo( + // The page or chat already open is not offered as a recent: it would lead nowhere. + () => rankPaletteItems(query, pageEntries, page === "root" ? recents.filter((id) => pageEntries.find((entry) => entry.id === id)?.locked === undefined) : []), + [query, pageEntries, page, recents], + ); + const highlighted = highlightedId ? pageEntries.find((entry) => entry.id === highlightedId) ?? null : null; + const count = groups.reduce((total, group) => total + group.items.length, 0); + const loadingChats = chats !== null && chats.loading && !chats.list && (page === "chats" || (page === "root" && !query.trim())); + + const goTo = (next: Page) => { setPage(next); setQuery(""); setHighlightedId(null); window.requestAnimationFrame(() => input.current?.focus()); }; + const back = () => goTo("root"); + + const choose = (entry: Entry, alt = false) => { + if (entry.locked) return; + if (!alt && entry.page) { goTo(entry.page); return; } + const action = alt ? entry.alt?.run : entry.run; + if (!action) return; + if (page === "root" && !entry.stayOpen) setRecents(recordPaletteRecent(entry.id)); + if (!entry.stayOpen) close(alt ? undefined : entry.thenFocus); + action(); + }; + + const onKeyDown = (event: KeyboardEvent) => { + const target = input.current; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + if (page !== "root") back(); + else close(); + return; + } + // Emacs-style Ctrl+N / Ctrl+P move like the arrow keys (macOS habit, also on other systems). + if (event.ctrlKey && !event.metaKey && !event.altKey && (event.key === "n" || event.key === "p") && target) { + event.preventDefault(); + event.stopPropagation(); + target.dispatchEvent(new globalThis.KeyboardEvent("keydown", { key: event.key === "n" ? "ArrowDown" : "ArrowUp", bubbles: true, cancelable: true })); + return; + } + if (event.key === "Enter" && (mac ? event.metaKey : event.ctrlKey)) { + event.preventDefault(); + event.stopPropagation(); + if (highlighted?.alt) choose(highlighted, true); + return; + } + if (event.key === "Tab") { + event.preventDefault(); + if (event.shiftKey) { if (page !== "root") back(); } + else if (highlighted?.page) goTo(highlighted.page); + return; + } + if (event.key === "Backspace" && page !== "root" && target && target.value === "" && target.selectionStart === 0) { + event.preventDefault(); + back(); + } + }; + + const placeholder = page === "chats" ? "Search chats" : page === "tasks" ? "Search background tasks" : `Search ${props.name}, chats and actions`; + const pageTitle = page === "chats" ? "Chats" : page === "tasks" ? "Background tasks" : null; + const altChord = mac ? "⌘↵" : "Ctrl+↵"; + + const row = (entry: Entry) => ( + choose(entry)} + data-workbot-palette-item={entry.id} + className="group flex h-10 w-full min-w-0 cursor-default select-none items-center gap-2.5 rounded-[10px] px-2.5 text-left text-[13px] text-[var(--wb-text)] outline-none [contain-intrinsic-size:auto_40px] [content-visibility:auto] data-[highlighted]:bg-[var(--wb-chip)] data-[disabled]:text-[var(--wb-muted)]" + > + {entry.icon} + + + {entry.detail ? {entry.detail} : null} + + {entry.meta ? {entry.meta} : null} + {entry.locked === undefined ? ( + + {entry.alt ? <>{entry.alt.verb}{altChord} : null} + {entry.verb}{entry.page ? "⇥" : "↵"} + + ) : null} + {entry.page ? : null} + + ); + + return ( + { + if (blocked) return; + if (next && details.reason === "trigger-press") returnTo.current = null; + setOpen(next); + }} + onOpenChangeComplete={(next) => { if (!next) flushFocus(); }} + > + + + + + + (pendingFocus.current ? false : returnTo.current?.isConnected ? returnTo.current : true)} + onKeyDownCapture={onKeyDown} + data-workbot-palette + className="workbot fixed left-1/2 top-2 z-50 flex max-h-[min(30rem,calc(100dvh-1rem))] w-[min(40rem,calc(100vw-1rem))] -translate-x-1/2 flex-col overflow-hidden rounded-[var(--dls-radius,16px)] bg-[var(--wb-surface)] text-[var(--wb-text)] shadow-[var(--wb-panel-shadow)] outline-none transition-opacity duration-[80ms] ease-out data-[ending-style]:opacity-0 motion-reduce:transition-none sm:top-[12vh]" + > + {pageTitle ?? "Search and actions"} + { if (details.reason === "input-change" || details.reason === "input-clear" || details.reason === "clear-press") setQuery(next); }} + onItemHighlighted={(value) => setHighlightedId(value?.id ?? null)} + itemToStringValue={(entry) => entry.title} + > +
+ {pageTitle ? ( + <> + + {pageTitle} + + ) : } + +
+
+ + {query.trim() ? ( + <> +

Nothing matches “{query.trim()}”

+

Try fewer or different words.

+ + ) : page === "tasks" ?

No background tasks in this chat yet.

: null} +
+ + {(group: RankedGroup) => ( + + {group.label} + {row} + {group.value === "actions" && loadingChats ? : null} + + )} + +
+ + {loadingChats ? "Loading chats" : count === 0 ? (query.trim() ? "No results" : "") : `${count.toLocaleString()} ${count === 1 ? "result" : "results"}`} + +
+ ↑↓Move + ↵Run + {page === "root" ? ⇥Open list : ⌫Back} + esc{page === "root" ? "Close" : "Back"} +
+
+
+
+
+ ); +} diff --git a/ee/packages/workbot-ui/src/host.ts b/ee/packages/workbot-ui/src/host.ts index f7eb3cc92d..3b16119390 100644 --- a/ee/packages/workbot-ui/src/host.ts +++ b/ee/packages/workbot-ui/src/host.ts @@ -55,6 +55,8 @@ export type WorkbotHost = { documentAuthorization?: "signin" | "blocked" | "disabled" /** The person can start side chats next to their main chat (on while Workbot is on). Off when unset. */ sideChats?: boolean + /** The Cmd+K / Ctrl+K command palette and its header button. Omitted means off. */ + commandPalette?: boolean /** Authenticated identity for permission-protected model-setting caches; absent on older hosts. */ identity?: { principalId: string; organizationId: string } } diff --git a/ee/packages/workbot-ui/src/screen.tsx b/ee/packages/workbot-ui/src/screen.tsx index b19ce9c81d..15a5dcfaa6 100644 --- a/ee/packages/workbot-ui/src/screen.tsx +++ b/ee/packages/workbot-ui/src/screen.tsx @@ -45,6 +45,7 @@ import { ComputerNavigationContext, ComputerOffer, DocumentNavigationContext, Do import { useQueryClient } from "@tanstack/react-query"; import { useNativeDictation } from "@openwork/ui/react"; import { WorkbotDictation } from "./native-dictation"; +import { WorkbotCommandPalette, type WorkbotPaletteTab } from "./command-palette"; /** * Workbot: one thread, instant. Layout and values follow the Paper file "WorkBot — one chat, set up once", @@ -235,6 +236,35 @@ function ChatScreen({ host, composerState, navigation, onDraftsChange }: { host: }, [preview, data]); const filesEnabled = data?.filesEnabled ?? false; const addFiles = uploads.add; + // The command palette (feature `workbotCommandPalette`). Off: no button, no shortcut, no extra requests. + const paletteOn = host.commandPalette === true; + const paletteChats = useWorkbotChats(paletteOn && navigation !== null); + const paletteTasks = useMemo(() => (data?.turns ?? []).flatMap((turn) => turn.tasks), [data?.turns]); + const palette = paletteOn ? ( + setTab(next)} + chats={navigation ? { + current: navigation.chat, + list: paletteChats.data, + loading: paletteChats.isPending, + failed: paletteChats.isError, + retry: () => void paletteChats.refetch(), + open: navigation.openChat, + locked: documentsDirty, + } : null} + busy={data?.status === "busy" || pending.some((entry) => !entry.failed)} + onStop={data ? () => stop.mutate() : null} + onOpenFiles={filesEnabled ? () => { if (!workspaceShell && tab !== "home") setTab("home"); setFilesOpen(true); } : null} + tasks={paletteTasks} + onFocusComposer={data ? focusComposer : null} + onShowTask={showTask} + homeHref={host.homeHref} + connectionsHref={host.connectionsHref ?? null} + /> + ) : null; const dragging = useFileDrop(filesEnabled, addFiles); useEffect(() => { @@ -326,7 +356,7 @@ function ChatScreen({ host, composerState, navigation, onDraftsChange }: { host:
- setFilesOpen(true)} /> : null} chats={host.workspaceAssistant ? : navigation ? : null} nav={host.workspaceAssistant ? null : } @@ -369,6 +399,7 @@ function ChatScreen({ host, composerState, navigation, onDraftsChange }: { host: files={null} nav={} answered={hasAnsweredTurn(data?.turns)} + search={palette} /> {leavingDocuments &&
You have unsaved document edits.
}
@@ -452,6 +483,7 @@ function ChatScreen({ host, composerState, navigation, onDraftsChange }: { host: remove={navigation && chatId && data.turns.length > 0 ? { composerState.dispose(); navigation.openChat(null); }} /> : null} nav={!chatId ? : null} answered={hasAnsweredTurn(data.turns)} + search={palette} />
@@ -604,7 +636,28 @@ function useConnectedApps() { return workbotHost().useConnectedApps(); } -type WorkbotTab = "home" | "calendar" | "documents" | "computer"; +type WorkbotTab = WorkbotPaletteTab; + +/** The pages this person has, in the header's order. */ +function paletteTabs(host: WorkbotHost): WorkbotTab[] { + return ["home", ...(host.calendar ? ["calendar" as const] : []), ...(host.documents ? ["documents" as const] : []), ...(host.computer ? ["computer" as const] : [])]; +} + +/** Puts the caret in the message box on screen (the Home composer, or the workspace chat's). */ +function focusComposer() { + for (const box of document.querySelectorAll("[data-workbot-composer] textarea")) { + if (box.offsetParent !== null && !box.disabled) { box.focus(); return; } + } +} + +/** Brings a background task's card into view and onto its own action (Stop, Try again). */ +function showTask(taskId: string) { + const card = document.querySelector(`[data-workbot-task="${CSS.escape(taskId)}"]`); + if (!card) return; + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + card.scrollIntoView({ block: "center", behavior: reduced ? "auto" : "smooth" }); + card.querySelector("button:not(:disabled)")?.focus({ preventScroll: true }); +} function tabFromLocation(): WorkbotTab { const path = typeof window !== "undefined" ? window.location.pathname.replace(/\/+$/, "") : ""; @@ -673,6 +726,8 @@ function WorkbotHeader(props: { nav?: ReactNode; /** Whether an answer to the person has finished, for the Get set up chip (shown only after the first answer). */ answered?: boolean; + /** The command palette's button (and the palette), where it is on. */ + search?: ReactNode; }) { const { name, organizationName, userName, files } = props; const apps = useConnectedApps(); @@ -688,6 +743,7 @@ function WorkbotHeader(props: {
)}
+ {props.search} {props.remove} {files} diff --git a/evals/proofs/workbot-command-palette.proof.test.ts b/evals/proofs/workbot-command-palette.proof.test.ts new file mode 100644 index 0000000000..8a48810d5f --- /dev/null +++ b/evals/proofs/workbot-command-palette.proof.test.ts @@ -0,0 +1,152 @@ +import { expect } from "vitest"; +import { spec } from "@openwork/testkit"; +import { workbotCommandPalette } from "../worlds/workbot-command-palette.ts"; + +const test = spec.world(workbotCommandPalette, { timeout: 120_000, needs: { placement: "local" }, resources: { surfaces: [], services: [] } }); + +const palette = "[data-workbot-palette]"; +const input = "[data-workbot-palette-input]"; +const composer = 'textarea[aria-label="Message Workbot"]'; +// Reads the palette without scrolling it: a locator's scroll-into-view would move the list before the screenshot. +const present = (layout: { elements: unknown[] }) => layout.elements.length > 0; + +test("a member opens the command palette with Cmd+K and runs a command without leaving the keyboard", async ({ world, user, probe, step, evidence }) => { + await user.resizeViewport({ width: 1280, height: 800, deviceScaleFactor: 1 }); + // Cmd+K where the browser runs on macOS, Ctrl+K elsewhere: the first chord that opens the palette is this system's. + let chord = "Meta+k"; + let altEnter = "Meta+Enter"; + const focused = () => probe.eventually(() => probe.dom(input), { within: 3000, label: "the palette's search box has focus", until: layout => layout.elements[0]?.focused === true }); + const open = async () => { + await user.press(chord); + await focused(); + }; + const openFirstTime = async () => { + await user.press("Meta+k"); + const opened = await probe.eventually(() => probe.dom(input), { within: 1000, label: "Cmd+K opens the palette on macOS", until: layout => layout.elements.length > 0 }).then(() => true, () => false); + if (!opened) { + chord = "Control+k"; + altEnter = "Control+Enter"; + await user.press(chord); + } + await focused(); + }; + + await step("before: with the feature off there is no search button and Cmd+K does nothing", async () => { + await user.see({ text: "On it. The venue research is still running." }); + await user.notSee({ role: "button", label: "Search and actions" }); + await user.click({ role: "textbox", label: "Message Workbot" }); + await user.press("Meta+k"); + await user.press("Control+k"); + await user.notSee({ role: "dialog" }, { timeoutMs: 1000 }); + const absent = (await probe.dom(palette)).elements.length === 0 && (await probe.dom("[data-workbot-palette-trigger]")).elements.length === 0; + evidence.recordAssertionEvidence("The palette is fully hidden while the feature is off", "No header button; Cmd/Ctrl+K opened nothing", absent); + expect(absent).toBe(true); + await user.screenshot(); + }); + + world.holdChats(); + await user.navigate(world.url); + + await step("after: the header has a search button and the page is otherwise unchanged", async () => { + await user.see({ role: "button", label: "Search and actions" }); + await user.see({ text: "On it. The venue research is still running." }); + await user.screenshot(); + }); + + await step("Cmd+K from inside the message box opens the palette, with chats still loading", async () => { + await user.click({ role: "textbox", label: "Message Workbot" }); + await openFirstTime(); + await user.see({ role: "combobox", label: /Search Workbot, chats and actions/ }); + await probe.eventually(() => probe.dom('[data-workbot-palette] [role="listbox"] [data-workbot-palette-item="task:task_research"]'), { within: 3000, label: "the running task is listed", until: present }); + expect((await probe.dom("[data-workbot-palette-loading]")).elements).toHaveLength(1); + await user.screenshot(); + world.releaseChats(); + await probe.eventually(() => probe.dom('[data-workbot-palette-item="chat:sidechatbudget00001"]'), { within: 5000, label: "chats replace the skeleton", until: present }); + const rows = (await probe.dom("[data-workbot-palette-item]")).elements.map(element => element.text); + const expected = ["Message Workbot", "New side chat", "Switch chat", "Show background tasks", "Budget review", "Research venue options", "Home", "Calendar", "Your dashboard", "Your connections"]; + const listedAll = expected.every(title => rows.some(text => text.startsWith(title))); + evidence.recordAssertionEvidence("The palette opens over the composer with real actions, chats, a running task, pages and settings", rows.join(" | "), listedAll); + expect(listedAll).toBe(true); + await user.screenshot(); + }); + + await step("Esc closes it and the caret is back in the message box", async () => { + await user.press("Escape"); + await probe.eventually(() => probe.dom(palette), { within: 3000, label: "the palette closes", until: layout => layout.elements.length === 0 }); + const focused = (await probe.dom(composer)).elements.some(element => element.focused); + evidence.recordAssertionEvidence("Focus returns to where it was", "The message box has focus after Esc", focused); + expect(focused).toBe(true); + }); + + await step("typing filters instantly, ranks the best match first and marks the matched letters", async () => { + await open(); + await user.type({ role: "combobox" }, "bud"); + await user.see({ text: "Budget review" }); + const first = (await probe.dom('[data-workbot-palette] [data-highlighted]')).elements[0]?.text ?? ""; + const marks = (await probe.dom('[data-workbot-palette-item="chat:sidechatbudget00001"] mark')).elements.map(element => element.text).join(""); + evidence.recordAssertionEvidence("The best match is highlighted and its matching letters are bold", `highlighted "${first}"; marked "${marks}"`, first.startsWith("Budget review") && marks === "Bud"); + expect(first.startsWith("Budget review")).toBe(true); + expect(marks).toBe("Bud"); + await user.screenshot(); + }); + + await step("Tab opens the nested Chats list and Backspace returns", async () => { + await user.type({ role: "combobox" }, "switch", { replace: true }); + await user.press("Tab"); + await user.see({ role: "combobox", label: "Search chats" }); + await user.see({ text: "Travel notes" }); + await user.press("ArrowDown"); + await user.screenshot(); + await user.press("Backspace"); + await user.see({ role: "combobox", label: /Search Workbot, chats and actions/ }); + }); + + await step("a search with no match says so and how to recover", async () => { + await user.type({ role: "combobox" }, "xyzzy", { replace: true }); + await user.see({ text: "Nothing matches “xyzzy”" }); + await user.see({ text: "Try fewer or different words." }); + await user.screenshot(); + }); + + await step("Cmd+Enter runs a row's second action: stopping a running background task", async () => { + await user.type({ role: "combobox" }, "venue", { replace: true }); + await user.see({ text: "Research venue options" }); + await user.press(altEnter); + await probe.eventually(() => world.stopped(), { within: 5000, label: "the task's stop request reached Workbot", until: ids => ids.length === 1 }); + evidence.recordAssertionEvidence("Cmd/Ctrl+Enter stops the highlighted task", JSON.stringify(world.stopped()), world.stopped().join() === "task_research"); + expect(world.stopped()).toEqual(["task_research"]); + }); + + await step("Enter runs a command: Calendar opens and the palette closes", async () => { + await open(); + await user.type({ role: "combobox" }, "calen"); + await user.press("Enter"); + await probe.eventually(() => probe.url(), { within: 5000, label: "Calendar opened", until: href => href.includes("/calendar") }); + expect((await probe.dom(palette)).elements).toHaveLength(0); + evidence.recordAssertionEvidence("A command runs from the keyboard", await probe.url(), (await probe.url()).includes("/calendar")); + await user.screenshot(); + }); + + await step("recently used commands come first the next time", async () => { + await open(); + const firstGroup = (await probe.dom("[data-workbot-palette-group]")).elements[0]?.text ?? ""; + evidence.recordAssertionEvidence("Recent comes first", firstGroup, firstGroup.startsWith("Recent")); + expect(firstGroup.startsWith("Recent")).toBe(true); + await user.press(chord); + await probe.eventually(() => probe.dom(palette), { within: 3000, label: "Cmd+K again closes the palette", until: layout => layout.elements.length === 0 }); + }); + + await step("on a phone the search button is in the header and the palette fits the screen", async () => { + await user.resizeViewport({ width: 390, height: 844, deviceScaleFactor: 1 }); + await user.navigate(world.url); + await user.see({ role: "button", label: "Search and actions" }); + await user.screenshot(); + await user.click({ role: "button", label: "Search and actions" }); + await probe.eventually(() => probe.dom('[data-workbot-palette] [role="listbox"]'), { within: 3000, label: "the palette opens", until: present }); + const layout = await probe.dom(palette); + const fits = (layout.elements[0]?.rect.right ?? Infinity) <= layout.viewportWidth && layout.documentWidth <= layout.viewportWidth; + evidence.recordAssertionEvidence("The palette fits a 390px screen", `right ${layout.elements[0]?.rect.right} of ${layout.viewportWidth}`, fits); + expect(fits).toBe(true); + await user.screenshot(); + }); +}); diff --git a/evals/worlds/workbot-command-palette.ts b/evals/worlds/workbot-command-palette.ts new file mode 100644 index 0000000000..50fece2def --- /dev/null +++ b/evals/worlds/workbot-command-palette.ts @@ -0,0 +1,79 @@ +import { fileURLToPath } from "node:url"; +import { createServer } from "vite"; +import type { Place, Seed } from "@openwork/env"; +import { chrome } from "@openwork/hosts"; + +/** + * The real Workbot page (Vite, the same client people load) with synthetic Workbot API answers: a member with side + * chats, one conversation that started two background tasks, and the command palette switched by `?palette=off` + * (the `workbotCommandPalette` feature as `/v1/workbot/me` reports it). Den and the runner are not involved. + */ +export async function workbotCommandPalette(_seed: Seed, { place }: { place: Place }) { + const resources = new AsyncDisposableStack(); + const stopped: string[] = []; + const chatReads: string[] = []; + let holdChats = false; + const releaseChats: (() => void)[] = []; + const release = () => { holdChats = false; for (const resolve of releaseChats.splice(0)) resolve(); }; + const now = Date.now(); + const tasks = [ + { id: "task_research", title: "Research venue options", status: "working", startedAt: now - 4 * 60_000, finishedAt: null, update: "Comparing three venues", updates: ["Comparing three venues"] }, + { id: "task_summary", title: "Summarize last quarter's notes", status: "done", startedAt: now - 3 * 3_600_000, finishedAt: now - 3 * 3_500_000, update: null, updates: [] }, + ]; + const turn = (id: string, text: string, answer: string, at: number, extra: Record = {}) => ({ id, text, sentAt: at, finishedAt: at + 2_000, status: "done", attachments: [], outputs: [], parts: [{ kind: "text", text: answer }], modelSteps: 1, error: null, ...extra }); + const turns = [ + turn("turn_one", "What should I prepare for the offsite?", "A venue shortlist and an agenda. I started both in the background.", now - 3 * 3_600_000, { tasks }), + turn("turn_two", "Thanks, keep going", "On it. The venue research is still running.", now - 5 * 60_000), + ]; + const sideChats = [ + { id: "sidechatbudget00001", title: "Budget review", createdAt: now - 86_400_000, updatedAt: now - 2 * 3_600_000 }, + { id: "sidechatlaunch00001", title: "Launch plan", createdAt: now - 2 * 86_400_000, updatedAt: now - 26 * 3_600_000 }, + { id: "sidechattravel00001", title: "Travel notes", createdAt: now - 5 * 86_400_000, updatedAt: now - 4 * 86_400_000 }, + ]; + try { + const vite = await createServer({ + configFile: fileURLToPath(new URL("../../ee/apps/workbot/vite.config.ts", import.meta.url)), + server: { host: "127.0.0.1", port: 0, strictPort: false }, + plugins: [{ name: "workbot-command-palette-fixture", configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const url = new URL(req.url ?? "/", `http://${req.headers.host}`); + if (!url.pathname.startsWith("/v1/workbot")) return next(); + const referring = new URL(req.headers.referer ?? "http://fixture"); + const json = (body: unknown, status = 200) => { res.statusCode = status; res.setHeader("content-type", "application/json"); res.end(JSON.stringify(body)); }; + if (url.pathname === "/v1/workbot/me") { + return json({ name: "Alex Example", email: "alex@example.test", userId: "user_example", organizationId: "org_example", organizationName: "Example", enabled: true, calendar: true, canSchedule: false, sideChats: true, commandPalette: referring.searchParams.get("palette") !== "off", denUrl: "https://workspace.example.test" }); + } + if (url.pathname === "/v1/workbot") return json({ available: true, name: "Workbot", organizationName: "Example", status: "idle", turns: url.searchParams.has("chat") ? [] : turns, hasEarlier: false, filesEnabled: false }); + if (url.pathname === "/v1/workbot/chats") { + chatReads.push(url.pathname); + if (holdChats) await new Promise((resolve) => releaseChats.push(resolve)); + return json({ main: { updatedAt: now - 5 * 60_000 }, side: sideChats }); + } + const stop = /^\/v1\/workbot\/tasks\/([^/]+)\/stop$/.exec(url.pathname); + if (stop && req.method === "POST") { stopped.push(decodeURIComponent(stop[1] ?? "")); return json({ ok: true }); } + if (url.pathname === "/v1/workbot/connections") return json({ connections: [] }); + if (url.pathname === "/v1/workbot/events") { res.statusCode = 200; res.setHeader("content-type", "text/event-stream"); res.end(": fixture\n\n"); return; } + if (url.pathname.endsWith("/calendar/v1/automations")) return json({ items: [], nextCursor: null }); + if (url.pathname.endsWith("/calendar/v1/automation-runs")) return json({ runs: [], nextCursor: null }); + if (url.pathname.includes("/calendar-events")) return json({ error: "needs_connection" }, 409); + return json({ error: "not_found" }, 404); + }); + } }], + }); + resources.defer(() => vite.close()); + await vite.listen(); + const url = vite.resolvedUrls?.local[0]; + if (!url) throw new Error("Workbot fixture did not start"); + resources.defer(release); + const web = resources.use(await chrome({ host: place.host(), name: "workbot-command-palette", startUrl: `${url}?palette=off`, headless: true })); + return { + web, + url, + stopped: () => [...stopped], + chatReads: () => chatReads.length, + holdChats: () => { holdChats = true; }, + releaseChats: release, + async [Symbol.asyncDispose]() { await resources.disposeAsync(); }, + }; + } catch (error) { await resources.disposeAsync(); throw error; } +} diff --git a/packages/features/src/registry.ts b/packages/features/src/registry.ts index 09a360555c..403c9d8141 100644 --- a/packages/features/src/registry.ts +++ b/packages/features/src/registry.ts @@ -199,6 +199,15 @@ export const FEATURES = defineFeatures({ deployments: everywhere, default: false, }, + workbotCommandPalette: { + label: "Workbot: command palette", + description: "Members press Cmd+K or Ctrl+K, or the search button in Workbot's header, to search and jump to their chats, background tasks, pages and settings, or run an action, from the keyboard.", + since: "2026-10", + author: "benjaminshafii", + docs: "docs/features/workbot-command-palette/README.md", + deployments: everywhere, + default: false, + }, workbotIncludedModels: { label: "Workbot: included OpenWork models by default", description: "Signed-in members chat in Workbot on their included OpenWork models without an AI Gateway provider. When the free allowance runs out, Workbot says when it resets and links admins to OpenWork Models or the workspace's model settings.", diff --git a/packaging/helm/openwork-ee/values.schema.json b/packaging/helm/openwork-ee/values.schema.json index 8e184c5f1a..48db790b1e 100644 --- a/packaging/helm/openwork-ee/values.schema.json +++ b/packaging/helm/openwork-ee/values.schema.json @@ -211,6 +211,16 @@ ], "description": "Workbot: workspace assistant: Members ask Workbot about the document or calendar view they have open, chat without leaving their work, and navigate from a compact sidebar. Workbot offers to open the documents it mentions, and the conversation stays open beside the document. Renders DEN_FEATURE_WORKBOT_WORKSPACE_ASSISTANT." }, + "workbotCommandPalette": { + "enum": [ + "", + "true", + "false", + true, + false + ], + "description": "Workbot: command palette: Members press Cmd+K or Ctrl+K, or the search button in Workbot's header, to search and jump to their chats, background tasks, pages and settings, or run an action, from the keyboard. Renders DEN_FEATURE_WORKBOT_COMMAND_PALETTE." + }, "workbotIncludedModels": { "enum": [ "", diff --git a/packaging/helm/openwork-ee/values.yaml b/packaging/helm/openwork-ee/values.yaml index 1fb7f128a1..689c026a4c 100644 --- a/packaging/helm/openwork-ee/values.yaml +++ b/packaging/helm/openwork-ee/values.yaml @@ -41,6 +41,7 @@ config: workbotDocuments: "" # default off · Members browse recent Google Drive files and folders in Workbot, open documents in tabs, and save supported text and spreadsheet edits to Drive. workbotDocumentEditing: "" # default off · Members choose Edit from a document preview. Full Office editing starts their Cloud workspace only when requested. Requires Workbot Documents. workbotWorkspaceAssistant: "" # default off · Members ask Workbot about the document or calendar view they have open, chat without leaving their work, and navigate from a compact sidebar. Workbot offers to open the documents it mentions, and the conversation stays open beside the document. + workbotCommandPalette: "" # default off · Members press Cmd+K or Ctrl+K, or the search button in Workbot's header, to search and jump to their chats, background tasks, pages and settings, or run an action, from the keyboard. workbotIncludedModels: "" # default off · Signed-in members chat in Workbot on their included OpenWork models without an AI Gateway provider. When the free allowance runs out, Workbot says when it resets and links admins to OpenWork Models or the workspace's model settings. workbotUsageRecovery: "" # default off · Signed-in Workbot uses free Auto or the member’s paid Models Luna entitlement, with allowance recovery and permission-aware upgrades in the conversation. usageLimitUpgrade: "" # default off · When free Auto, OpenWork Models or an AI Gateway budget runs out in the desktop app, one card says what ran out, when it resets and that the message is saved, with the upgrade or request that fits the person’s role and a way to keep working with another model. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6255515473..a21a0a6190 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1467,6 +1467,9 @@ importers: '@openwork/workbook': specifier: workspace:* version: link:../../../packages/workbook + fuzzysort: + specifier: ^3.1.0 + version: 3.1.0 lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.8)