diff --git a/AGENTS.md b/AGENTS.md index 9e0bfa5..1488ac0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,8 @@ - Do not invent Runta Cloud Agents routes or event framing. Record assumptions in `API_INTEGRATION.md` and require route/transport injection. - Mock fixtures belong under `src/clients/mock`, never in React components. Mock computer UI must remain visibly labeled. - Default to the light theme and preserve both theme token sets. +- Read and follow `DESIGN_SYSTEM.md` for every UI change. Apply its visible-element admission rule and border checklist before adding permanent controls or decoration. - Use strict TypeScript; do not add `any` escapes. - Run `npm run typecheck`, `npm run lint`, `npm test`, `npm run build`, and packaging/smoke checks for release-facing changes. - Use Conventional Commits. Changes in this `runta-dev` repository must go through a feature branch and pull request. +- Runta Crew is temporarily not onboarded to Runta Review. Do not trigger, wait for, or treat Runta Review as a merge gate for this repository. GitHub pull requests and the repository CI workflow are the required review/delivery path until this rule is explicitly changed. diff --git a/API_INTEGRATION.md b/API_INTEGRATION.md index cd71e5f..206f75a 100644 --- a/API_INTEGRATION.md +++ b/API_INTEGRATION.md @@ -9,8 +9,10 @@ The backend contract must support: | Client operation | Required behavior | | --- | --- | | `listAgents`, `getAgent`, `createAgent` | Named agents, role/goal, status, timestamps, unread/approval counters, computer association | +| `updateAgent`, `deleteAgent`, `duplicateAgent`, `setAgentUnread` | Optimistic concurrency/version, deletion cleanup, duplication semantics and per-user preferences | | `listConversations`, `getConversation` | Stable conversation identity and ordered message history | | `sendMessage` | Idempotency key, accepted message, and subsequent stream identity | +| `reactToMessage` | Supported reaction vocabulary, toggle/idempotency behavior and multi-device counts | | `subscribeToConversationEvents` | Resume cursor, ordering, heartbeat, message deltas/completion, activity, approvals, connection events | | `listApprovalRequests`, `respondToApproval` | Explicit action scope, decision, optional note, actor, audit timestamp, single-use semantics | | `getComputer` | Runtime state, active app/tool, preview availability, supported actions | @@ -39,3 +41,5 @@ The domain currently understands `message.created`, `message.delta`, `message.co ## Credential handling Tokens entered in Settings are encrypted using Electron `safeStorage` and written with user-only file permissions. The preload exposes `has` and `set`, not credential reads. Production API requests should be made by a main-process broker that decrypts only for the outbound request. + +Local file selection currently returns only an opaque ID and safe metadata to the renderer. The main process retains the path in memory. `HttpCloudAgentsClient` deliberately rejects these local selections until the backend defines upload creation/finalization, size limits, checksums, and how an opaque desktop selection becomes a cloud attachment ID. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bb40761..daae30c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -30,7 +30,7 @@ React renderer └── HttpCloudAgentsClient (contract-ready adapter) ``` -The renderer runs with `contextIsolation: true`, `nodeIntegration: false`, and `sandbox: true`. It cannot access the filesystem, Node.js, arbitrary Electron APIs, or arbitrary IPC channels. The preload exposes only application version, HTTP(S) external navigation, non-secret settings, and write/exists credential operations. It never exposes the stored credential value. +The renderer runs with `contextIsolation: true`, `nodeIntegration: false`, and `sandbox: true`. It cannot access the filesystem, Node.js, arbitrary Electron APIs, or arbitrary IPC channels. The preload exposes only application version, HTTP(S) external navigation, non-secret settings, write/exists credential operations, native file selection metadata, and bounded notification operations. It never exposes the stored credential value or selected filesystem paths. ## Cloud Agents boundary diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md new file mode 100644 index 0000000..288110d --- /dev/null +++ b/DESIGN_SYSTEM.md @@ -0,0 +1,49 @@ +# Runta Crew Design System + +Runta Crew is a work surface, not a dashboard. Its interface should feel quiet until the user needs to act. + +## Visible-element admission rule + +Every permanently visible element must do at least one of the following: + +1. Enable an immediate user action. +2. Communicate state that changes the user's next decision. +3. Identify the current context. +4. Explain an error or required intervention. + +If removing an element does not make a common task harder, less safe, or ambiguous, remove it. Do not add labels, badges, borders, cards, shadows, status dots, helper copy, or decorative artwork merely to make an area feel complete. + +## Whitespace + +- Prefer open space over filler UI. Empty space establishes hierarchy and keeps conversation content primary. +- Do not fill blank areas with metrics, onboarding copy, illustrations, or secondary navigation unless the current task requires them. +- Keep controls close to the object they operate on; do not create extra toolbars for infrequent actions. + +## Border usage + +- Borders communicate structure or interactivity. They are not decoration. +- Inputs may keep a subtle border because it makes editability immediately clear. +- A stationary conversation header has no bottom border. Show a `0.5px` hairline only after content scrolls beneath it. +- The sidebar boundary uses a low-contrast `0.5px` hairline. It separates regions without becoming a visual column. +- Avoid nested borders. Prefer spacing or a single soft surface before adding another outline. +- Do not put borders around self-explanatory icon actions located in an established action area. +- Use stronger borders only for safety-critical boundaries, focused inputs, errors, approvals, or selected controls. + +## Immediate comprehension + +- A visible control must be understandable at a glance from its icon, label, position, and current context. +- Do not rely on users discovering what a permanent control means through trial and error. +- Use icon-only controls only for conventional actions or when placement makes the meaning unambiguous. Always provide an accessible name. +- Prefer direct language: describe the action (`Open computer`, `Delete agent`) rather than an internal system concept. +- Do not show raw infrastructure state unless it affects what the user can do next. + +## UI review checklist + +Before shipping a UI change, verify: + +- Can any new visible element be removed without reducing clarity, safety, or capability? +- Is any state represented twice? +- Does every border have a structural, interactive, or safety purpose? +- Is the default state quieter than its hover, focus, active, error, and approval states? +- Can a first-time user understand every persistent control without documentation? +- Does the layout preserve generous whitespace at both the default and minimum window sizes? diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md new file mode 100644 index 0000000..970f51f --- /dev/null +++ b/FEATURE_PARITY.md @@ -0,0 +1,33 @@ +# Grok Bot → Runta Crew feature parity + +This matrix compares the public/reconstructed Grok Bot 0.18 product surface with Runta Crew. The reference repository has no reusable upstream source license, so entries describe behavior to implement independently, not code to copy. + +Legend: **Done** ships in the current desktop client; **Local next** can be implemented without a backend contract; **API blocked** requires a confirmed Runta Cloud Agents contract; **Excluded** is intentionally outside Runta Crew's product direction. + +| Area | Reference surface | Runta Crew status | Next contract or work | +| --- | --- | --- | --- | +| Multi-agent sidebar | Named agents, unread state, row actions | **Done:** named agents, status, badges, search, edit, pin, duplicate, read state and confirmed delete | **Local next:** hide/restore and bulk organization | +| Command palette | Root commands plus agent/message/link search | **Done:** keyboard palette, agent switching, create/settings/computer commands | **Local next:** indexed message/link search after durable history exists | +| Conversation | Streaming turns, thinking/activity, reconnect | **Done:** typed messages, streaming deltas/completion, activity timeline, errors/reconnect | **API blocked:** durable cursor/replay, cancellation, retries, pagination | +| Agent computer | Remote box overlay and takeover | **Done:** status, active app, clearly labeled safe mock, Open/Take over UX | **API blocked:** signed ingress/WebRTC/VNC session, arbitration, audit | +| Approvals | Tool permission scope and user decision | **Done:** scoped Allow once/Deny with note | **API blocked:** expiry, revocation, policy modes, durable audit | +| Attachments | File/image/link attachment gateway and downloads | **Done:** typed native chooser, opaque IDs, metadata preview, removal, 25 MB validation, mock message parts | **API blocked:** upload, signed download, remote file identity | +| Reactions | Message reaction root and acknowledgement | **Done:** typed useful/needs-work reactions, optimistic mock event updates and accessible controls | **API blocked:** persistence and multi-device sync | +| Notifications | OS notification manager and dock badge | **Done:** typed, preference-aware, focus-aware OS notification bridge and unread dock badge | **API blocked:** background event delivery when the app is closed | +| Deep links | Single-instance app routing from external surfaces | **Done:** strict `runta-crew://agent/` parsing and typed renderer navigation | **API blocked:** canonical web-to-desktop link issuance | +| Agent lifecycle | Rename, delete, duplicate, preferences | **Done:** mock lifecycle, pin/read preferences and destructive confirmation UI | **API blocked:** canonical mutation and concurrency semantics | +| Plugins/MCP | Plugin lifecycle, OAuth, MCP tools | **API blocked:** Runta plugin catalog, OAuth and agent capability contract | Do not reuse Cursor/xAI plugin services | +| Skills/routines | Saved repeatable work and proactive routines | **API blocked:** skill schema, scheduler, ownership and execution history | Desktop management UI follows backend contract | +| Multi-agent handoff | Subagents/group members and shared context | **API blocked:** relationship, handoff, permissions and event model | Keep `ActivityEvent.kind = handoff` as the UI seam | +| Authentication | Cursor account/session funnel | **API blocked:** Runta account/device authorization | **Excluded:** Cursor account and machine identity | +| Local inference router | Cursor/Claude/Codex/OpenRouter routing extension | **Excluded** | Runta Crew connects to Runta Cloud Agents rather than routing local inference | +| Local Docker box | Optional replacement for remote box | **Excluded for product MVP** | Runta Runtime is the cloud-computer substrate | +| Telemetry/updater | Upstream services and release feeds | **Excluded until approved** | Ports exist; require Runta privacy policy, signing and update infrastructure | + +## Delivery order + +1. Local desktop completeness: agent row actions, attachments, reactions, OS notifications, deep links. +2. Durable Cloud Agents foundation: auth, agents, conversations, resumable events, approvals. +3. Real computer transport: short-lived session descriptors, preview, takeover, audit. +4. Extensibility: plugins/MCP, skills, routines, schedules. +5. Collaboration: handoffs, shared computers, organization policy, multi-device sync. diff --git a/README.md b/README.md index 1a98d15..c9787ed 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,18 @@ Runta Crew is the desktop client for creating, messaging, and supervising Runta ## What works today - Search, switch, and create named agents with roles and goals. +- Edit, pin, duplicate, mark read/unread, and safely delete agents. - Display working, idle, approval-required, and offline states. - Persistent-style conversations with user, agent, system, and activity data models. - Mock streaming responses and structured browser/file activity. +- Native attachment selection with opaque IDs, safe metadata previews, removal, and size validation. +- Useful/needs-work message reactions. - Scoped approval review with explicit Allow once / Deny actions and notes. - Cloud computer status plus safe mock Open / Take over surfaces. - Connection, theme, notification, credential, and About settings. - OS-encrypted credential storage through Electron `safeStorage`. +- Focus-aware OS notifications and macOS unread dock badges. +- Single-instance `runta-crew://agent/` deep links. - macOS native window/menu, DMG/ZIP packaging, and a native packaged-app smoke check. ## Development @@ -53,6 +58,7 @@ Packaged artifacts are written under `release/`. The app is ad-hoc signed for lo - `src/ui`: presentation and focused interaction components. See [ARCHITECTURE.md](./ARCHITECTURE.md) for process and security boundaries. +See [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) for the UI necessity, whitespace, border, and immediate-comprehension rules. ## Relationship to the reference project diff --git a/docs/screenshots/main-window.png b/docs/screenshots/main-window.png index b8ddd52..d7a490b 100644 Binary files a/docs/screenshots/main-window.png and b/docs/screenshots/main-window.png differ diff --git a/docs/screenshots/minimum-window.png b/docs/screenshots/minimum-window.png index 26863fd..1635db0 100644 Binary files a/docs/screenshots/minimum-window.png and b/docs/screenshots/minimum-window.png differ diff --git a/electron/main/index.ts b/electron/main/index.ts index 53c9151..4ffdc47 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1,12 +1,31 @@ -import { app, BrowserWindow, ipcMain, Menu, safeStorage, shell } from "electron"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { app, BrowserWindow, dialog, ipcMain, Menu, Notification, safeStorage, shell } from "electron"; +import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { extname, join, basename } from "node:path"; +import { randomUUID } from "node:crypto"; import type { AppSettings } from "../../src/shared/desktop"; -const isDev = Boolean(process.env.VITE_DEV_SERVER_URL); +const devServerUrl = process.env.ELECTRON_RENDERER_URL ?? process.env.VITE_DEV_SERVER_URL; +const isDev = Boolean(devServerUrl); const credentialFile = () => join(app.getPath("userData"), "credentials.bin"); const settingsFile = () => join(app.getPath("userData"), "settings.json"); let settings: AppSettings = { endpoint: "", theme: "light", notifications: true }; +const selectedAttachmentPaths = new Map(); +let mainWindow: BrowserWindow | undefined; +let pendingDeepLinkAgentId: string | undefined; + +function agentIdFromDeepLink(value: string): string | undefined { + try { const url = new URL(value); const id = url.protocol === "runta-crew:" && url.hostname === "agent" ? decodeURIComponent(url.pathname.replace(/^\//, "")) : ""; return /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(id) ? id : undefined; } catch { return undefined; } +} +function openAgentDeepLink(value: string) { + const agentId = agentIdFromDeepLink(value); if (!agentId) return; + pendingDeepLinkAgentId = agentId; + if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.show(); mainWindow.focus(); mainWindow.webContents.send("deep-link:agent", agentId); pendingDeepLinkAgentId = undefined; } +} + +function mediaTypeForPath(path: string): string { + const types: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".pdf": "application/pdf", ".txt": "text/plain", ".md": "text/markdown", ".json": "application/json", ".csv": "text/csv" }; + return types[extname(path).toLowerCase()] ?? "application/octet-stream"; +} function loadSettings(): AppSettings { if (!existsSync(settingsFile())) return settings; @@ -31,7 +50,8 @@ function createWindow() { contextIsolation: true, nodeIntegration: false, sandbox: true, }, }); - if (isDev && process.env.VITE_DEV_SERVER_URL) void window.loadURL(process.env.VITE_DEV_SERVER_URL); + mainWindow = window; window.on("closed", () => { if (mainWindow === window) mainWindow = undefined; }); + if (isDev && devServerUrl) void window.loadURL(devServerUrl); else void window.loadFile(join(__dirname, "../renderer/index.html")); window.webContents.setWindowOpenHandler(({ url }) => { try { const parsed = new URL(url); if (parsed.protocol === "https:" || parsed.protocol === "http:") void shell.openExternal(parsed.toString()); } catch { /* deny malformed URLs */ } @@ -42,6 +62,7 @@ function createWindow() { if (url !== current) event.preventDefault(); }); window.webContents.once("did-finish-load", () => { + if (pendingDeepLinkAgentId) { window.webContents.send("deep-link:agent", pendingDeepLinkAgentId); pendingDeepLinkAgentId = undefined; } const smokeMarker = process.env.RUNTA_CREW_SMOKE_MARKER; if (smokeMarker) { writeFileSync(smokeMarker, "ready\n"); app.quit(); return; } const screenshotPath = process.env.RUNTA_CREW_SCREENSHOT_PATH; @@ -49,8 +70,16 @@ function createWindow() { }); } +const hasSingleInstanceLock = app.requestSingleInstanceLock(); +if (!hasSingleInstanceLock) app.quit(); +else { + app.on("second-instance", (_event, argv) => { const deepLink = argv.find((value) => value.startsWith("runta-crew://")); if (deepLink) openAgentDeepLink(deepLink); else { mainWindow?.show(); mainWindow?.focus(); } }); + app.on("open-url", (event, url) => { event.preventDefault(); openAgentDeepLink(url); }); +} + app.whenReady().then(() => { settings = loadSettings(); + if (app.isPackaged) app.setAsDefaultProtocolClient("runta-crew"); Menu.setApplicationMenu(Menu.buildFromTemplate([ { label: "Runta Crew", submenu: [{ role: "about" }, { type: "separator" }, { role: "quit" }] }, { label: "Edit", submenu: [{ role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "selectAll" }] }, @@ -78,3 +107,25 @@ ipcMain.handle("credentials:set", (_event, token: string | null) => { writeFileSync(credentialFile(), safeStorage.encryptString(token), { mode: 0o600 }); return true; }); +ipcMain.handle("attachments:choose", async () => { + const result = await dialog.showOpenDialog({ title: "Attach files to your message", properties: ["openFile", "multiSelections"], filters: [{ name: "Supported files", extensions: ["png", "jpg", "jpeg", "gif", "webp", "pdf", "txt", "md", "json", "csv"] }] }); + if (result.canceled) return []; + const attachments = result.filePaths.flatMap((path) => { + const size = statSync(path).size; + if (size > 25 * 1024 * 1024) return []; + const id = randomUUID(); selectedAttachmentPaths.set(id, path); + return [{ id, name: basename(path), size, mediaType: mediaTypeForPath(path) }]; + }); + if (attachments.length !== result.filePaths.length) await dialog.showMessageBox({ type: "warning", title: "Some files were not attached", message: "Runta Crew supports files up to 25 MB in this preview." }); + return attachments; +}); +ipcMain.handle("notifications:show", (event, value: { title?: unknown; body?: unknown }) => { + const title = typeof value?.title === "string" ? value.title.slice(0, 120) : ""; + const body = typeof value?.body === "string" ? value.body.slice(0, 500) : ""; + const sourceWindow = BrowserWindow.fromWebContents(event.sender); + if (!settings.notifications || !title || !body || !Notification.isSupported() || sourceWindow?.isFocused()) return false; + new Notification({ title, body }).show(); return true; +}); +ipcMain.handle("notifications:setBadge", (_event, count: number) => { + if (process.platform === "darwin" && app.dock) app.dock.setBadge(Number.isSafeInteger(count) && count > 0 ? String(Math.min(count, 99)) : ""); +}); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 985412c..b9c95f2 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -12,6 +12,17 @@ const bridge: DesktopBridge = { has: () => ipcRenderer.invoke("credentials:has"), set: (token) => ipcRenderer.invoke("credentials:set", token), }, + attachments: { choose: () => ipcRenderer.invoke("attachments:choose") }, + notifications: { + show: (notification) => ipcRenderer.invoke("notifications:show", notification), + setBadge: (count) => ipcRenderer.invoke("notifications:setBadge", count), + }, + deepLinks: { + onOpenAgent: (listener) => { + const handler = (_event: Electron.IpcRendererEvent, agentId: string) => listener(agentId); + ipcRenderer.on("deep-link:agent", handler); return () => ipcRenderer.removeListener("deep-link:agent", handler); + }, + }, }; contextBridge.exposeInMainWorld("runtaCrew", bridge); diff --git a/package.json b/package.json index 6a15d27..23e8df1 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,9 @@ "engines": { "node": ">=22 <27" }, "scripts": { "dev": "electron-vite dev", + "postinstall": "node scripts/ensure-electron.mjs", "build": "electron-vite build", - "package": "npm run build && electron-builder --mac", + "package": "npm run build && electron-builder --mac --publish never", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p electron/tsconfig.json", "lint": "eslint .", "test": "vitest run", diff --git a/scripts/ensure-electron.mjs b/scripts/ensure-electron.mjs new file mode 100644 index 0000000..de492a1 --- /dev/null +++ b/scripts/ensure-electron.mjs @@ -0,0 +1,33 @@ +import { createRequire } from "node:module"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +const require = createRequire(import.meta.url); +const electronDir = join(process.cwd(), "node_modules", "electron"); +const manifest = JSON.parse(readFileSync(join(electronDir, "package.json"), "utf8")); +const platformPath = process.platform === "darwin" ? "Electron.app/Contents/MacOS/Electron" : process.platform === "win32" ? "electron.exe" : "electron"; +const distDir = join(electronDir, "dist"); +const executable = join(distDir, platformPath); +const installedVersion = join(distDir, "version"); +const pathFile = join(electronDir, "path.txt"); + +if (existsSync(executable) && existsSync(installedVersion) && readFileSync(installedVersion, "utf8").trim().replace(/^v/, "") === manifest.version) { + writeFileSync(pathFile, platformPath); + process.exit(0); +} + +const { downloadArtifact } = require("@electron/get"); +const zipPath = await downloadArtifact({ version: manifest.version, artifactName: "electron", platform: process.platform, arch: process.arch }); +mkdirSync(distDir, { recursive: true }); + +if (process.platform === "darwin") { + const extracted = spawnSync("ditto", ["-x", "-k", zipPath, distDir], { stdio: "inherit" }); + if (extracted.status !== 0) throw new Error(`Could not extract Electron with ditto (exit ${extracted.status ?? "unknown"})`); +} else { + const extract = require("extract-zip"); + await extract(zipPath, { dir: distDir }); +} + +if (!existsSync(executable)) throw new Error(`Electron executable is missing after extraction: ${executable}`); +writeFileSync(pathFile, platformPath); diff --git a/src/assets/runta-logo-icon.png b/src/assets/runta-logo-icon.png deleted file mode 100644 index 1e99dbe..0000000 Binary files a/src/assets/runta-logo-icon.png and /dev/null differ diff --git a/src/clients/http/HttpCloudAgentsClient.test.ts b/src/clients/http/HttpCloudAgentsClient.test.ts index a42e38f..5e11fcc 100644 --- a/src/clients/http/HttpCloudAgentsClient.test.ts +++ b/src/clients/http/HttpCloudAgentsClient.test.ts @@ -10,9 +10,16 @@ describe("HttpCloudAgentsClient", () => { it("applies endpoint, bearer authentication, and cancellation signal", async () => { const fetchImpl = vi.fn().mockResolvedValue(new Response(JSON.stringify([]), { status: 200 })); const client = new HttpCloudAgentsClient({ endpoint: "https://api.example.test/base/", routes: { - listAgents: "agents", getAgent: (id) => `agents/${id}`, createAgent: "agents", listConversations: (id) => `agents/${id}/conversations`, getConversation: (id) => `conversations/${id}`, sendMessage: (id) => `conversations/${id}/messages`, approvals: "approvals", respondToApproval: (id) => `approvals/${id}`, computer: (id) => `agents/${id}/computer`, openComputer: (id) => `agents/${id}/computer/open`, takeOverComputer: (id) => `agents/${id}/computer/takeover`, conversationEvents: (id) => `conversations/${id}/events`, + listAgents: "agents", getAgent: (id) => `agents/${id}`, createAgent: "agents", listConversations: (id) => `agents/${id}/conversations`, getConversation: (id) => `conversations/${id}`, sendMessage: (id) => `conversations/${id}/messages`, approvals: "approvals", respondToApproval: (id) => `approvals/${id}`, computer: (id) => `agents/${id}/computer`, openComputer: (id) => `agents/${id}/computer/open`, takeOverComputer: (id) => `agents/${id}/computer/takeover`, conversationEvents: (id) => `conversations/${id}/events`, reactToMessage: (conversationId, messageId) => `conversations/${conversationId}/messages/${messageId}/reaction`, updateAgent: (id) => `agents/${id}`, deleteAgent: (id) => `agents/${id}`, duplicateAgent: (id) => `agents/${id}/duplicate`, setAgentUnread: (id) => `agents/${id}/unread`, }, accessToken: async () => "secret", fetchImpl }); const controller = new AbortController(); await client.listAgents(controller.signal); expect(fetchImpl).toHaveBeenCalledWith(new URL("https://api.example.test/base/agents"), expect.objectContaining({ headers: expect.objectContaining({ authorization: "Bearer secret" }), signal: controller.signal })); }); + + it("does not pretend local selections are uploaded before an attachment contract exists", () => { + const client = new HttpCloudAgentsClient({ endpoint: "https://api.example.test", routes: { + listAgents: "agents", getAgent: (id) => `agents/${id}`, createAgent: "agents", listConversations: (id) => `agents/${id}/conversations`, getConversation: (id) => `conversations/${id}`, sendMessage: (id) => `conversations/${id}/messages`, approvals: "approvals", respondToApproval: (id) => `approvals/${id}`, computer: (id) => `agents/${id}/computer`, openComputer: (id) => `agents/${id}/computer/open`, takeOverComputer: (id) => `agents/${id}/computer/takeover`, conversationEvents: (id) => `conversations/${id}/events`, reactToMessage: (conversationId, messageId) => `conversations/${conversationId}/messages/${messageId}/reaction`, updateAgent: (id) => `agents/${id}`, deleteAgent: (id) => `agents/${id}`, duplicateAgent: (id) => `agents/${id}/duplicate`, setAgentUnread: (id) => `agents/${id}/unread`, + } }); + expect(() => client.sendMessage({ conversationId: "c1", text: "See file", attachments: [{ id: "local", name: "brief.pdf", size: 42, mediaType: "application/pdf", source: "local-selection" }] })).toThrowError(expect.objectContaining({ code: "contract_pending" })); + }); }); diff --git a/src/clients/http/HttpCloudAgentsClient.ts b/src/clients/http/HttpCloudAgentsClient.ts index aa25775..9872f9e 100644 --- a/src/clients/http/HttpCloudAgentsClient.ts +++ b/src/clients/http/HttpCloudAgentsClient.ts @@ -1,5 +1,5 @@ import type { CloudAgentsClient } from "@/domain/CloudAgentsClient"; -import { CrewError, type Agent, type ApprovalRequest, type CloudComputer, type Conversation, type ConversationEvent, type CreateAgentInput, type Message, type RespondApprovalInput, type SendMessageInput, type Subscription } from "@/domain/types"; +import { CrewError, type Agent, type ApprovalRequest, type CloudComputer, type Conversation, type ConversationEvent, type CreateAgentInput, type Message, type ReactToMessageInput, type RespondApprovalInput, type SendMessageInput, type Subscription, type UpdateAgentInput } from "@/domain/types"; export interface CloudAgentsRoutes { listAgents: string; getAgent: (id: string) => string; createAgent: string; @@ -8,6 +8,9 @@ export interface CloudAgentsRoutes { respondToApproval: (id: string) => string; computer: (agentId: string) => string; openComputer: (agentId: string) => string; takeOverComputer: (agentId: string) => string; conversationEvents: (conversationId: string) => string; + reactToMessage: (conversationId: string, messageId: string) => string; + updateAgent: (agentId: string) => string; deleteAgent: (agentId: string) => string; + duplicateAgent: (agentId: string) => string; setAgentUnread: (agentId: string) => string; } export interface HttpClientOptions { endpoint: string; routes?: CloudAgentsRoutes; accessToken?: () => Promise; fetchImpl?: typeof fetch } @@ -23,14 +26,23 @@ export class HttpCloudAgentsClient implements CloudAgentsClient { if (response.status === 401) throw new CrewError("unauthorized", "Authentication is required"); if (response.status === 404) throw new CrewError("not_found", "Resource not found"); if (!response.ok) throw new CrewError("unknown", `Cloud Agents request failed (${response.status})`, response.status >= 500); + if (response.status === 204) return undefined as T; return response.json() as Promise; } listAgents(signal?: AbortSignal) { return this.request(this.route((r) => r.listAgents), { signal }); } getAgent(id: string, signal?: AbortSignal) { return this.request(this.route((r) => r.getAgent(id)), { signal }); } createAgent(input: CreateAgentInput, signal?: AbortSignal) { return this.request(this.route((r) => r.createAgent), { method: "POST", body: JSON.stringify(input), signal }); } + updateAgent(agentId: string, input: UpdateAgentInput, signal?: AbortSignal) { return this.request(this.route((r) => r.updateAgent(agentId)), { method: "PATCH", body: JSON.stringify(input), signal }); } + async deleteAgent(agentId: string, signal?: AbortSignal) { await this.request(this.route((r) => r.deleteAgent(agentId)), { method: "DELETE", signal }); } + duplicateAgent(agentId: string, signal?: AbortSignal) { return this.request(this.route((r) => r.duplicateAgent(agentId)), { method: "POST", signal }); } + setAgentUnread(agentId: string, unread: boolean, signal?: AbortSignal) { return this.request(this.route((r) => r.setAgentUnread(agentId)), { method: "PUT", body: JSON.stringify({ unread }), signal }); } listConversations(id: string, signal?: AbortSignal) { return this.request(this.route((r) => r.listConversations(id)), { signal }); } getConversation(id: string, signal?: AbortSignal) { return this.request<{ conversation: Conversation; messages: Message[] }>(this.route((r) => r.getConversation(id)), { signal }); } - sendMessage(input: SendMessageInput) { return this.request(this.route((r) => r.sendMessage(input.conversationId)), { method: "POST", body: JSON.stringify({ text: input.text }), signal: input.signal }); } + sendMessage(input: SendMessageInput) { + if (input.attachments?.some((attachment) => attachment.source === "local-selection")) throw new CrewError("contract_pending", "Local attachment upload is pending the Runta Cloud Agents contract"); + return this.request(this.route((r) => r.sendMessage(input.conversationId)), { method: "POST", body: JSON.stringify({ text: input.text, attachments: input.attachments ?? [] }), signal: input.signal }); + } + reactToMessage(input: ReactToMessageInput, signal?: AbortSignal) { return this.request(this.route((r) => r.reactToMessage(input.conversationId, input.messageId)), { method: "PUT", body: JSON.stringify({ reaction: input.reaction }), signal }); } subscribeToConversationEvents(id: string, listener: (event: ConversationEvent) => void): Subscription { void id; void listener; throw new CrewError("contract_pending", "SSE/WebSocket event framing is pending backend confirmation"); } listApprovalRequests(_agentId?: string, signal?: AbortSignal) { return this.request(this.route((r) => r.approvals), { signal }); } respondToApproval(input: RespondApprovalInput, signal?: AbortSignal) { return this.request(this.route((r) => r.respondToApproval(input.requestId)), { method: "POST", body: JSON.stringify(input), signal }); } diff --git a/src/clients/mock/MockCloudAgentsClient.test.ts b/src/clients/mock/MockCloudAgentsClient.test.ts index b340990..33f1267 100644 --- a/src/clients/mock/MockCloudAgentsClient.test.ts +++ b/src/clients/mock/MockCloudAgentsClient.test.ts @@ -24,4 +24,31 @@ describe("MockCloudAgentsClient", () => { const response = await client.respondToApproval({ requestId: "approval-1", decision: "deny", note: "Use the sandbox first" }); expect(response).toMatchObject({ status: "denied", responseNote: "Use the sandbox first" }); }); + + it("keeps selected attachments as typed message parts", async () => { + const client = new MockCloudAgentsClient(); + const message = await client.sendMessage({ conversationId: "conversation-atlas", text: "Review this", attachments: [{ id: "attachment-1", name: "brief.pdf", size: 4200, mediaType: "application/pdf", source: "local-selection" }] }); + expect(message.parts).toContainEqual({ type: "attachment", attachment: expect.objectContaining({ id: "attachment-1", name: "brief.pdf" }) }); + }); + + it("toggles a reaction and publishes the updated message", async () => { + const client = new MockCloudAgentsClient(); const updated: string[] = []; + client.subscribeToConversationEvents("conversation-atlas", (event) => { if (event.type === "message.updated") updated.push(event.message.id); }); + const selected = await client.reactToMessage({ conversationId: "conversation-atlas", messageId: "m2", reaction: "useful" }); + expect(selected.reactions).toContainEqual({ kind: "useful", count: 1, selected: true }); + const cleared = await client.reactToMessage({ conversationId: "conversation-atlas", messageId: "m2", reaction: "useful" }); + expect(cleared.reactions).toContainEqual({ kind: "useful", count: 0, selected: false }); + expect(updated).toEqual(["m2", "m2"]); + }); + + it("supports the complete local agent lifecycle", async () => { + const client = new MockCloudAgentsClient(); + const updated = await client.updateAgent("patch", { name: "Patch Prime", pinned: true }); + expect(updated).toMatchObject({ name: "Patch Prime", avatar: "P", pinned: true }); + const duplicate = await client.duplicateAgent("patch"); + expect(duplicate).toMatchObject({ name: "Patch Prime copy", role: "Software engineer" }); + expect((await client.setAgentUnread("patch", true)).unreadCount).toBe(1); + await client.deleteAgent(duplicate.id); + await expect(client.getAgent(duplicate.id)).rejects.toMatchObject({ code: "not_found" }); + }); }); diff --git a/src/clients/mock/MockCloudAgentsClient.ts b/src/clients/mock/MockCloudAgentsClient.ts index 1f1e794..c4b9c85 100644 --- a/src/clients/mock/MockCloudAgentsClient.ts +++ b/src/clients/mock/MockCloudAgentsClient.ts @@ -1,5 +1,5 @@ import type { CloudAgentsClient } from "@/domain/CloudAgentsClient"; -import { CrewError, type Agent, type ConversationEvent, type CreateAgentInput, type Message, type RespondApprovalInput, type SendMessageInput, type Subscription } from "@/domain/types"; +import { CrewError, type Agent, type ConversationEvent, type CreateAgentInput, type Message, type ReactToMessageInput, type RespondApprovalInput, type SendMessageInput, type Subscription, type UpdateAgentInput } from "@/domain/types"; import { activitiesFixture, agentsFixture, approvalsFixture, computersFixture, conversationsFixture, messagesFixture } from "./fixtures"; const wait = (ms: number, signal?: AbortSignal) => new Promise((resolve, reject) => { @@ -24,10 +24,14 @@ export class MockCloudAgentsClient implements CloudAgentsClient { this.computers.push({ id: agent.computerId, agentId: id, runtimeName: `crew-${id}`, status: "starting", activeApp: "Preparing workspace", previewKind: "mock", capabilities: ["open", "takeover"] }); return copy(agent); } + async updateAgent(agentId: string, input: UpdateAgentInput, signal?: AbortSignal) { await wait(120, signal); const agent = this.agents.find((item) => item.id === agentId); if (!agent) throw new CrewError("not_found", "Agent not found"); Object.assign(agent, input, input.name ? { avatar: input.name.slice(0, 1).toUpperCase() } : {}); return copy(agent); } + async deleteAgent(agentId: string, signal?: AbortSignal) { await wait(140, signal); if (!this.agents.some((item) => item.id === agentId)) throw new CrewError("not_found", "Agent not found"); const conversationIds = this.conversations.filter((item) => item.agentId === agentId).map((item) => item.id); this.agents = this.agents.filter((item) => item.id !== agentId); this.conversations = this.conversations.filter((item) => item.agentId !== agentId); this.messages = this.messages.filter((item) => !conversationIds.includes(item.conversationId)); this.approvals = this.approvals.filter((item) => item.agentId !== agentId); this.computers = this.computers.filter((item) => item.agentId !== agentId); } + async duplicateAgent(agentId: string, signal?: AbortSignal) { const source = await this.getAgent(agentId, signal); return this.createAgent({ name: `${source.name} copy`, role: source.role, goal: source.goal }, signal); } + async setAgentUnread(agentId: string, unread: boolean, signal?: AbortSignal) { await this.updateAgent(agentId, { }, signal); const stored = this.agents.find((item) => item.id === agentId); if (!stored) throw new CrewError("not_found", "Agent not found"); stored.unreadCount = unread ? Math.max(1, stored.unreadCount) : 0; return copy(stored); } async listConversations(agentId: string, signal?: AbortSignal) { await wait(60, signal); return copy(this.conversations.filter((item) => item.agentId === agentId)); } async getConversation(conversationId: string, signal?: AbortSignal) { await wait(80, signal); const conversation = this.conversations.find((item) => item.id === conversationId); if (!conversation) throw new CrewError("not_found", "Conversation not found"); return { conversation: copy(conversation), messages: copy(this.messages.filter((item) => item.conversationId === conversationId)) }; } async sendMessage(input: SendMessageInput) { - await wait(90, input.signal); const message: Message = { id: crypto.randomUUID(), conversationId: input.conversationId, role: "user", parts: [{ type: "text", text: input.text }], createdAt: new Date().toISOString() }; + await wait(90, input.signal); const message: Message = { id: crypto.randomUUID(), conversationId: input.conversationId, role: "user", parts: [{ type: "text", text: input.text }, ...(input.attachments ?? []).map((attachment) => ({ type: "attachment" as const, attachment }))], createdAt: new Date().toISOString() }; this.messages.push(message); this.emit(input.conversationId, { type: "message.created", message }); const reply: Message = { id: crypto.randomUUID(), conversationId: input.conversationId, role: "agent", parts: [{ type: "text", text: "" }], createdAt: new Date().toISOString(), streaming: true }; window.setTimeout(() => { this.messages.push(reply); this.emit(input.conversationId, { type: "message.created", message: reply }); }, 240); @@ -39,6 +43,16 @@ export class MockCloudAgentsClient implements CloudAgentsClient { }, 650 + index * 420)); return copy(message); } + async reactToMessage(input: ReactToMessageInput, signal?: AbortSignal) { + await wait(80, signal); const message = this.messages.find((item) => item.id === input.messageId && item.conversationId === input.conversationId); + if (!message) throw new CrewError("not_found", "Message not found"); + const existing = message.reactions ?? []; + const current = existing.find((reaction) => reaction.kind === input.reaction); + message.reactions = current + ? existing.map((reaction) => reaction.kind === input.reaction ? { ...reaction, selected: !reaction.selected, count: reaction.selected ? Math.max(0, reaction.count - 1) : reaction.count + 1 } : reaction) + : [...existing, { kind: input.reaction, count: 1, selected: true }]; + this.emit(input.conversationId, { type: "message.updated", message }); return copy(message); + } subscribeToConversationEvents(conversationId: string, listener: (event: ConversationEvent) => void): Subscription { const set = this.listeners.get(conversationId) ?? new Set(); set.add(listener); this.listeners.set(conversationId, set); return { unsubscribe: () => set.delete(listener) }; } async listApprovalRequests(agentId?: string, signal?: AbortSignal) { await wait(60, signal); return copy(this.approvals.filter((item) => !agentId || item.agentId === agentId)); } async respondToApproval(input: RespondApprovalInput, signal?: AbortSignal) { await wait(220, signal); const approval = this.approvals.find((item) => item.id === input.requestId); if (!approval) throw new CrewError("not_found", "Approval not found"); approval.status = input.decision === "allow" ? "allowed" : "denied"; approval.responseNote = input.note; this.emit(approval.conversationId, { type: "approval.updated", approval }); return copy(approval); } diff --git a/src/domain/CloudAgentsClient.ts b/src/domain/CloudAgentsClient.ts index 87e350b..30cf97a 100644 --- a/src/domain/CloudAgentsClient.ts +++ b/src/domain/CloudAgentsClient.ts @@ -1,12 +1,17 @@ -import type { Agent, ApprovalRequest, CloudComputer, Conversation, ConversationEvent, CreateAgentInput, Message, RespondApprovalInput, SendMessageInput, Subscription } from "./types"; +import type { Agent, ApprovalRequest, CloudComputer, Conversation, ConversationEvent, CreateAgentInput, Message, ReactToMessageInput, RespondApprovalInput, SendMessageInput, Subscription, UpdateAgentInput } from "./types"; export interface CloudAgentsClient { listAgents(signal?: AbortSignal): Promise; getAgent(agentId: string, signal?: AbortSignal): Promise; createAgent(input: CreateAgentInput, signal?: AbortSignal): Promise; + updateAgent(agentId: string, input: UpdateAgentInput, signal?: AbortSignal): Promise; + deleteAgent(agentId: string, signal?: AbortSignal): Promise; + duplicateAgent(agentId: string, signal?: AbortSignal): Promise; + setAgentUnread(agentId: string, unread: boolean, signal?: AbortSignal): Promise; listConversations(agentId: string, signal?: AbortSignal): Promise; getConversation(conversationId: string, signal?: AbortSignal): Promise<{ conversation: Conversation; messages: Message[] }>; sendMessage(input: SendMessageInput): Promise; + reactToMessage(input: ReactToMessageInput, signal?: AbortSignal): Promise; subscribeToConversationEvents(conversationId: string, listener: (event: ConversationEvent) => void): Subscription; listApprovalRequests(agentId?: string, signal?: AbortSignal): Promise; respondToApproval(input: RespondApprovalInput, signal?: AbortSignal): Promise; diff --git a/src/domain/types.ts b/src/domain/types.ts index dad8501..9db93a4 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -1,27 +1,34 @@ export type AgentStatus = "working" | "idle" | "waiting_for_approval" | "offline"; export type ConnectionState = "connected" | "connecting" | "disconnected" | "error"; export type MessageRole = "user" | "agent" | "system"; +export type ReactionKind = "useful" | "needs_work"; export interface Agent { id: string; name: string; role: string; goal: string; status: AgentStatus; - avatar: string; lastActiveAt: string; unreadCount: number; computerId: string; + avatar: string; lastActiveAt: string; unreadCount: number; computerId: string; pinned?: boolean; } export interface TextPart { type: "text"; text: string } export interface ActivityPart { type: "activity"; activityId: string } -export type MessagePart = TextPart | ActivityPart; -export interface Message { id: string; conversationId: string; role: MessageRole; parts: MessagePart[]; createdAt: string; streaming?: boolean } +export interface Attachment { id: string; name: string; size: number; mediaType: string; source: "local-selection" | "cloud" } +export interface AttachmentPart { type: "attachment"; attachment: Attachment } +export type MessagePart = TextPart | ActivityPart | AttachmentPart; +export interface MessageReaction { kind: ReactionKind; count: number; selected: boolean } +export interface Message { id: string; conversationId: string; role: MessageRole; parts: MessagePart[]; createdAt: string; streaming?: boolean; reactions?: MessageReaction[] } export interface Conversation { id: string; agentId: string; title: string; updatedAt: string } export type ActivityStatus = "running" | "completed" | "failed"; export interface ActivityEvent { id: string; conversationId: string; kind: "browser" | "terminal" | "file" | "handoff" | "status"; title: string; detail: string; status: ActivityStatus; createdAt: string } export interface ApprovalRequest { id: string; agentId: string; conversationId: string; title: string; description: string; scope: string[]; status: "pending" | "allowed" | "denied"; createdAt: string; responseNote?: string } export interface CloudComputer { id: string; agentId: string; runtimeName: string; status: "online" | "starting" | "offline"; activeApp?: string; previewKind: "mock" | "remote"; capabilities: Array<"open" | "takeover"> } export interface CreateAgentInput { name: string; role: string; goal: string } -export interface SendMessageInput { conversationId: string; text: string; signal?: AbortSignal } +export interface UpdateAgentInput { name?: string; role?: string; goal?: string; pinned?: boolean } +export interface SendMessageInput { conversationId: string; text: string; attachments?: Attachment[]; signal?: AbortSignal } export interface RespondApprovalInput { requestId: string; decision: "allow" | "deny"; note?: string } +export interface ReactToMessageInput { conversationId: string; messageId: string; reaction: ReactionKind } export type ConversationEvent = | { type: "message.created"; message: Message } | { type: "message.delta"; messageId: string; delta: string } | { type: "message.completed"; messageId: string } + | { type: "message.updated"; message: Message } | { type: "activity.updated"; activity: ActivityEvent } | { type: "approval.updated"; approval: ApprovalRequest } | { type: "connection.changed"; state: ConnectionState }; diff --git a/src/shared/desktop.ts b/src/shared/desktop.ts index 73c97d4..555ccfd 100644 --- a/src/shared/desktop.ts +++ b/src/shared/desktop.ts @@ -1,9 +1,14 @@ export type ThemePreference = "light" | "dark" | "system"; export type AppSettings = { endpoint: string; theme: ThemePreference; notifications: boolean }; +export interface SelectedAttachment { id: string; name: string; size: number; mediaType: string } +export interface DesktopNotification { title: string; body: string } export interface DesktopBridge { getVersion(): Promise; openExternal(url: string): Promise; settings: { get(): Promise; set(settings: AppSettings): Promise }; credentials: { has(): Promise; set(token: string | null): Promise }; + attachments: { choose(): Promise }; + notifications: { show(notification: DesktopNotification): Promise; setBadge(count: number): Promise }; + deepLinks: { onOpenAgent(listener: (agentId: string) => void): () => void }; } diff --git a/src/state/useCrewController.ts b/src/state/useCrewController.ts index e7eed1e..5e0a59d 100644 --- a/src/state/useCrewController.ts +++ b/src/state/useCrewController.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { CloudAgentsClient } from "@/domain/CloudAgentsClient"; -import type { Agent, ApprovalRequest, CloudComputer, ConnectionState, ConversationEvent, CreateAgentInput, Message } from "@/domain/types"; +import type { Agent, ApprovalRequest, Attachment, CloudComputer, ConnectionState, ConversationEvent, CreateAgentInput, Message, ReactionKind, UpdateAgentInput } from "@/domain/types"; export function useCrewController(client: CloudAgentsClient) { const [agents, setAgents] = useState([]); const [selectedAgentId, setSelectedAgentId] = useState(""); @@ -10,7 +10,7 @@ export function useCrewController(client: CloudAgentsClient) { const selectedAgent = useMemo(() => agents.find((agent) => agent.id === selectedAgentId), [agents, selectedAgentId]); const conversationId = selectedAgentId ? `conversation-${selectedAgentId}` : ""; - const refreshAgents = useCallback(async () => { const next = await client.listAgents(); setAgents(next); setSelectedAgentId((current) => current || next[0]?.id || ""); }, [client]); + const refreshAgents = useCallback(async () => { const next = await client.listAgents(); setAgents(next); setSelectedAgentId((current) => current && next.some((agent) => agent.id === current) ? current : next[0]?.id || ""); return next; }, [client]); useEffect(() => { let alive = true; void refreshAgents().then(() => { if (alive) { setConnection("connected"); setLoading(false); } }).catch((reason: unknown) => { if (alive) { setConnection("error"); setError(reason instanceof Error ? reason.message : "Could not load agents"); setLoading(false); } }); return () => { alive = false; }; }, [refreshAgents]); useEffect(() => { if (!selectedAgentId) return; const controller = new AbortController(); let alive = true; @@ -24,18 +24,25 @@ export function useCrewController(client: CloudAgentsClient) { if (!conversationId) return; const subscription = client.subscribeToConversationEvents(conversationId, (event: ConversationEvent) => { if (event.type === "message.created") setMessages((current) => current.some((message) => message.id === event.message.id) ? current : [...current, event.message]); if (event.type === "message.delta") setMessages((current) => current.map((message) => message.id === event.messageId ? { ...message, parts: message.parts.map((part, index) => index === 0 && part.type === "text" ? { ...part, text: part.text + event.delta } : part) } : message)); - if (event.type === "message.completed") setMessages((current) => current.map((message) => message.id === event.messageId ? { ...message, streaming: false } : message)); - if (event.type === "approval.updated") setApprovals((current) => current.map((approval) => approval.id === event.approval.id ? event.approval : approval)); + if (event.type === "message.completed") { setMessages((current) => current.map((message) => message.id === event.messageId ? { ...message, streaming: false } : message)); void window.runtaCrew?.notifications.show({ title: `${selectedAgent?.name ?? "Agent"} finished`, body: "New work is ready to review in Runta Crew." }); } + if (event.type === "message.updated") setMessages((current) => current.map((message) => message.id === event.message.id ? event.message : message)); + if (event.type === "approval.updated") { setApprovals((current) => current.map((approval) => approval.id === event.approval.id ? event.approval : approval)); if (event.approval.status === "pending") void window.runtaCrew?.notifications.show({ title: `${selectedAgent?.name ?? "Agent"} needs approval`, body: event.approval.title }); } if (event.type === "connection.changed") setConnection(event.state); }); return () => subscription.unsubscribe(); - }, [client, conversationId]); + }, [client, conversationId, selectedAgent?.name]); return { agents, selectedAgent, selectedAgentId, setSelectedAgentId, messages, approvals, computer, connection, loading, error, dismissError: () => setError(undefined), createAgent: async (input: CreateAgentInput) => { const agent = await client.createAgent(input); await refreshAgents(); setSelectedAgentId(agent.id); }, - sendMessage: async (text: string) => { if (!conversationId) return; await client.sendMessage({ conversationId, text }); }, + updateAgent: async (agentId: string, input: UpdateAgentInput) => { await client.updateAgent(agentId, input); await refreshAgents(); }, + deleteAgent: async (agentId: string) => { await client.deleteAgent(agentId); await refreshAgents(); }, + duplicateAgent: async (agentId: string) => { const agent = await client.duplicateAgent(agentId); await refreshAgents(); setSelectedAgentId(agent.id); }, + setAgentUnread: async (agentId: string, unread: boolean) => { await client.setAgentUnread(agentId, unread); await refreshAgents(); }, + sendMessage: async (text: string, attachments: Attachment[] = []) => { if (!conversationId) return; await client.sendMessage({ conversationId, text, attachments }); }, + reactToMessage: async (messageId: string, reaction: ReactionKind) => { if (!conversationId) return; const next = await client.reactToMessage({ conversationId, messageId, reaction }); setMessages((current) => current.map((message) => message.id === next.id ? next : message)); }, respondToApproval: async (requestId: string, decision: "allow" | "deny", note?: string) => { const next = await client.respondToApproval({ requestId, decision, note }); setApprovals((current) => current.map((item) => item.id === next.id ? next : item)); }, + openComputer: async (action: "open" | "takeover") => { if (!selectedAgentId) return { mode: "mock" as const }; try { return await (action === "open" ? client.openComputer(selectedAgentId) : client.takeOverComputer(selectedAgentId)); } catch (reason) { setError(reason instanceof Error ? reason.message : "Could not open cloud computer"); throw reason; } }, reconnect: async () => { setConnection("connecting"); try { await client.reconnect(); await refreshAgents(); setConnection("connected"); } catch (reason) { setConnection("error"); setError(reason instanceof Error ? reason.message : "Reconnect failed"); } }, }; } diff --git a/src/ui/App.test.tsx b/src/ui/App.test.tsx index bb29342..dde95f2 100644 --- a/src/ui/App.test.tsx +++ b/src/ui/App.test.tsx @@ -1,13 +1,14 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import { App } from "./App"; +import type { DesktopBridge } from "@/shared/desktop"; describe("Runta Crew primary flows", () => { it("switches agents and handles a scoped approval", async () => { const user = userEvent.setup(); render(); await screen.findByRole("heading", { name: "Atlas", level: 1 }); - await user.click(screen.getByRole("button", { name: /Mira/ })); + await user.click(screen.getByRole("button", { name: /^M Mira Needs approval$/ })); await screen.findByText("Submit vendor renewal"); await user.type(screen.getByLabelText("Approval note"), "Approved for this form only"); await user.click(screen.getByRole("button", { name: "Allow once" })); @@ -20,14 +21,94 @@ describe("Runta Crew primary flows", () => { await user.type(screen.getByLabelText("Name"), "Scout"); await user.type(screen.getByLabelText("Role"), "Research lead"); await user.type(screen.getByLabelText("Initial goal"), "Track customer feedback"); await user.click(screen.getByRole("button", { name: "Create agent" })); await screen.findByRole("heading", { name: "Scout", level: 1 }); - await user.type(screen.getByLabelText("Message Scout"), "Start with this week's interviews"); await user.click(screen.getByLabelText("Send message")); + const send = screen.getByLabelText("Send message"); expect(send).toBeDisabled(); + expect([...send.querySelectorAll("path")].map((path) => path.getAttribute("d"))).toEqual(["m5 12 7-7 7 7", "M12 19V5"]); + await user.type(screen.getByLabelText("Message Scout"), "Start with this week's interviews"); expect(send).toBeEnabled(); await user.click(send); expect(await screen.findByText("Start with this week's interviews")).toBeInTheDocument(); }); it("labels the computer surface as a safe mock", async () => { render(); await screen.findByRole("heading", { name: "Atlas", level: 1 }); + fireEvent.click(screen.getByRole("button", { name: "Open agent computer" })); expect(screen.getByText("Safe mock preview")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Take over" })); - expect(document.querySelector(".detail-panel")).toHaveAttribute("data-computer-action", "takeover"); + await waitFor(() => expect(document.querySelector(".detail-panel")).toHaveAttribute("data-computer-action", "takeover")); await waitFor(() => expect(document.querySelector(".computer-overlay")?.textContent).toContain("No remote desktop session is connected yet.")); }); + + it("opens the command palette from the native shortcut and switches agents", async () => { + const user = userEvent.setup(); render(); await screen.findByRole("heading", { name: "Atlas", level: 1 }); + fireEvent.keyDown(window, { key: "k", metaKey: true }); + expect(await screen.findByRole("dialog", { name: "Command palette" })).toBeInTheDocument(); + await user.type(screen.getByLabelText("Search commands and agents"), "Patch"); + await user.keyboard("{Enter}"); + expect(await screen.findByRole("heading", { name: "Patch", level: 1 })).toBeInTheDocument(); + expect(screen.queryByRole("dialog", { name: "Command palette" })).not.toBeInTheDocument(); + }); + + it("selects a local attachment through the typed desktop bridge and sends it", async () => { + const bridge: DesktopBridge = { + getVersion: async () => "0.1.0", openExternal: async () => undefined, + settings: { get: async () => ({ endpoint: "", theme: "light", notifications: true }), set: async (settings) => settings }, + credentials: { has: async () => false, set: async () => true }, + attachments: { choose: async () => [{ id: "selected-1", name: "brief.pdf", size: 4200, mediaType: "application/pdf" }] }, + notifications: { show: async () => true, setBadge: async () => undefined }, + deepLinks: { onOpenAgent: () => () => undefined }, + }; + window.runtaCrew = bridge; + const user = userEvent.setup(); render(); await screen.findByRole("heading", { name: "Atlas", level: 1 }); + await user.click(screen.getByRole("button", { name: "Attach files" })); + expect(await screen.findByText("brief.pdf")).toBeInTheDocument(); + await user.type(screen.getByLabelText("Message Atlas"), "Please review this brief"); + await user.click(screen.getByRole("button", { name: "Send message" })); + expect(await screen.findByText("Please review this brief")).toBeInTheDocument(); + expect(screen.getByText("4.1 KB · application/pdf")).toBeInTheDocument(); + delete window.runtaCrew; + }); + + it("opens a validated agent deep link through the typed desktop bridge", async () => { + let listener: ((agentId: string) => void) | undefined; + const bridge: DesktopBridge = { + getVersion: async () => "0.1.0", openExternal: async () => undefined, + settings: { get: async () => ({ endpoint: "", theme: "light", notifications: true }), set: async (settings) => settings }, + credentials: { has: async () => false, set: async () => true }, attachments: { choose: async () => [] }, + notifications: { show: async () => true, setBadge: async () => undefined }, + deepLinks: { onOpenAgent: (next) => { listener = next; return () => { listener = undefined; }; } }, + }; + window.runtaCrew = bridge; render(); await screen.findByRole("heading", { name: "Atlas", level: 1 }); + await act(() => listener?.("patch")); + expect(await screen.findByRole("heading", { name: "Patch", level: 1 })).toBeInTheDocument(); delete window.runtaCrew; + }); + + it("records useful feedback on an agent message", async () => { + const user = userEvent.setup(); render(); await screen.findByRole("heading", { name: "Atlas", level: 1 }); + await screen.findByText(/I’m grouping the feedback/); + const reaction = screen.getByRole("button", { name: "Mark as useful" }); + expect(reaction).toHaveAttribute("aria-pressed", "false"); await user.click(reaction); + await waitFor(() => expect(reaction).toHaveAttribute("aria-pressed", "true")); + expect(reaction).toHaveTextContent("1"); + }); + + it("edits and marks an agent read from scoped row actions", async () => { + const user = userEvent.setup(); render(); await screen.findByRole("heading", { name: "Atlas", level: 1 }); + await user.click(screen.getByRole("button", { name: "More actions for Atlas" })); + await user.click(screen.getByRole("menuitem", { name: "Mark as read" })); + await user.click(screen.getByRole("button", { name: "More actions for Atlas" })); + expect(await screen.findByRole("menuitem", { name: "Mark as unread" })).toBeInTheDocument(); + await user.click(screen.getByRole("menuitem", { name: "Edit agent" })); + const name = screen.getByLabelText("Name"); await user.clear(name); await user.type(name, "Atlas Prime"); + await user.click(screen.getByRole("button", { name: "Save agent" })); + expect(await screen.findByRole("heading", { name: "Atlas Prime", level: 1 })).toBeInTheDocument(); + }); + + it("duplicates and safely deletes an agent", async () => { + const user = userEvent.setup(); render(); await screen.findByRole("heading", { name: "Atlas", level: 1 }); + await user.click(screen.getByRole("button", { name: "More actions for Patch" })); + await user.click(screen.getByRole("menuitem", { name: "Duplicate" })); + expect(await screen.findByRole("heading", { name: "Patch copy", level: 1 })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "More actions for Patch copy" })); + await user.click(screen.getByRole("menuitem", { name: "Delete agent" })); + expect(screen.getByText("This action cannot be undone.")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Delete agent" })); + await waitFor(() => expect(screen.queryByRole("button", { name: "More actions for Patch copy" })).not.toBeInTheDocument()); + }); }); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index f19fb80..c98ec26 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -2,15 +2,17 @@ import { useEffect, useMemo, useState } from "react"; import { AlertCircle } from "lucide-react"; import { MockCloudAgentsClient } from "@/clients/mock/MockCloudAgentsClient"; import { useCrewController } from "@/state/useCrewController"; -import { AgentList } from "./components/AgentList"; +import { AgentList, type AgentAction } from "./components/AgentList"; import { Conversation } from "./components/Conversation"; import { DetailPanel } from "./components/DetailPanel"; -import { CreateAgentDialog, SettingsDialog } from "./components/Dialogs"; -import runtaLogo from "@/assets/runta-logo-icon.png"; +import { CreateAgentDialog, DeleteAgentDialog, EditAgentDialog, SettingsDialog } from "./components/Dialogs"; +import { CommandPalette } from "./components/CommandPalette"; +import type { Agent } from "@/domain/types"; export function App() { const client = useMemo(() => new MockCloudAgentsClient(), []); const crew = useCrewController(client); - const [search, setSearch] = useState(""); const [detailsOpen, setDetailsOpen] = useState(() => window.innerWidth >= 1120); const [createOpen, setCreateOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const crewAgents = crew.agents; const selectAgent = crew.setSelectedAgentId; + const [search, setSearch] = useState(""); const [detailsOpen, setDetailsOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); const [editingAgent, setEditingAgent] = useState(); const [deletingAgent, setDeletingAgent] = useState(); const agents = crew.agents.filter((agent) => `${agent.name} ${agent.role}`.toLowerCase().includes(search.toLowerCase())); useEffect(() => { void window.runtaCrew?.settings.get().then((settings) => { @@ -19,13 +21,33 @@ export function App() { : settings.theme; }); }, []); - if (crew.loading) return
Loading your crew…
; + useEffect(() => { void window.runtaCrew?.notifications.setBadge(crew.agents.reduce((total, agent) => total + agent.unreadCount, 0)); }, [crew.agents]); + useEffect(() => window.runtaCrew?.deepLinks.onOpenAgent((agentId) => { if (crewAgents.some((agent) => agent.id === agentId)) selectAgent(agentId); }), [crewAgents, selectAgent]); + useEffect(() => { if (crew.selectedAgent?.status === "waiting_for_approval") setDetailsOpen(true); }, [crew.selectedAgent?.status]); + function handleAgentAction(agent: Agent, action: AgentAction) { + if (action === "edit") setEditingAgent(agent); + if (action === "delete") setDeletingAgent(agent); + if (action === "pin") void crew.updateAgent(agent.id, { pinned: !agent.pinned }); + if (action === "duplicate") void crew.duplicateAgent(agent.id); + if (action === "toggle-unread") void crew.setAgentUnread(agent.id, agent.unreadCount === 0); + } + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); setPaletteOpen((value) => !value); } + if (event.key === "Escape") setPaletteOpen(false); + }; + window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); + }, []); + if (crew.loading) return
Loading your crew…
; return
- setCreateOpen(true)} onSettings={() => setSettingsOpen(true)} /> - {crew.selectedAgent ? void crew.reconnect()} onToggleDetails={() => setDetailsOpen((value) => !value)} /> :
No agent selected
} - {detailsOpen && crew.selectedAgent && setDetailsOpen(false)} />} + setCreateOpen(true)} onSettings={() => setSettingsOpen(true)} /> + {crew.selectedAgent ? void crew.reconnect()} onToggleDetails={() => setDetailsOpen((value) => !value)} /> :
No agent selected
} + {detailsOpen && crew.selectedAgent && setDetailsOpen(false)} />} {crew.error &&
{crew.error}
} {createOpen && setCreateOpen(false)} onCreate={crew.createAgent} />} + {editingAgent && setEditingAgent(undefined)} onSave={(input) => crew.updateAgent(editingAgent.id, input)} />} + {deletingAgent && setDeletingAgent(undefined)} onDelete={() => crew.deleteAgent(deletingAgent.id)} />} {settingsOpen && setSettingsOpen(false)} />} + setPaletteOpen(false)} onSelectAgent={crew.setSelectedAgentId} onCreateAgent={() => setCreateOpen(true)} onSettings={() => setSettingsOpen(true)} onComputer={() => setDetailsOpen(true)} />
; } diff --git a/src/ui/components/AgentList.tsx b/src/ui/components/AgentList.tsx index 16acc58..1950ccf 100644 --- a/src/ui/components/AgentList.tsx +++ b/src/ui/components/AgentList.tsx @@ -1,21 +1,35 @@ -import { Plus, Search, Settings, UsersRound } from "lucide-react"; +import { Copy, Mail, MailOpen, MoreHorizontal, Pencil, Pin, PinOff, Plus, Search, Settings, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; import type { Agent, AgentStatus } from "@/domain/types"; -import runtaLogo from "@/assets/runta-logo-icon.png"; +export type AgentAction = "edit" | "pin" | "duplicate" | "toggle-unread" | "delete"; const statusLabel: Record = { working: "Working", idle: "Idle", waiting_for_approval: "Needs approval", offline: "Offline" }; -export function AgentList({ agents, selectedId, search, onSearch, onSelect, onCreate, onSettings }: { agents: Agent[]; selectedId: string; search: string; onSearch(value: string): void; onSelect(id: string): void; onCreate(): void; onSettings(): void }) { + +export function AgentList({ agents, selectedId, search, onSearch, onSelect, onAction, onCreate, onSettings }: { agents: Agent[]; selectedId: string; search: string; onSearch(value: string): void; onSelect(id: string): void; onAction(agent: Agent, action: AgentAction): void; onCreate(): void; onSettings(): void }) { + const [menuAgentId, setMenuAgentId] = useState(); + useEffect(() => { if (!menuAgentId) return; const close = () => setMenuAgentId(undefined); window.addEventListener("pointerdown", close); return () => window.removeEventListener("pointerdown", close); }, [menuAgentId]); + const sortedAgents = [...agents].sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned))); + const act = (agent: Agent, action: AgentAction) => { setMenuAgentId(undefined); onAction(agent, action); }; return ; } diff --git a/src/ui/components/CommandPalette.tsx b/src/ui/components/CommandPalette.tsx new file mode 100644 index 0000000..b893240 --- /dev/null +++ b/src/ui/components/CommandPalette.tsx @@ -0,0 +1,25 @@ +import { Bot, Cloud, Plus, Search, Settings } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { Agent } from "@/domain/types"; + +type PaletteCommand = { id: string; label: string; detail: string; icon: typeof Search; run(): void }; +export function CommandPalette({ open, agents, onClose, onSelectAgent, onCreateAgent, onSettings, onComputer }: { open: boolean; agents: Agent[]; onClose(): void; onSelectAgent(id: string): void; onCreateAgent(): void; onSettings(): void; onComputer(): void }) { + const [query, setQuery] = useState(""); const inputRef = useRef(null); + useEffect(() => { if (open) { setQuery(""); window.setTimeout(() => inputRef.current?.focus(), 0); } }, [open]); + const commands = useMemo(() => [ + { id: "create", label: "Create cloud agent", detail: "Add a teammate to your crew", icon: Plus, run: onCreateAgent }, + { id: "computer", label: "Open agent details", detail: "View the current cloud computer", icon: Cloud, run: onComputer }, + { id: "settings", label: "Open settings", detail: "Connection, theme and notifications", icon: Settings, run: onSettings }, + ...agents.map((agent) => ({ id: `agent-${agent.id}`, label: agent.name, detail: `${agent.role} · ${agent.status.replaceAll("_", " ")}`, icon: Bot, run: () => onSelectAgent(agent.id) })), + ], [agents, onComputer, onCreateAgent, onSelectAgent, onSettings]); + const results = commands.filter((command) => `${command.label} ${command.detail}`.toLowerCase().includes(query.toLowerCase())); + if (!open) return null; + const choose = (command: PaletteCommand) => { command.run(); onClose(); }; + return
{ if (event.target === event.currentTarget) onClose(); }}> +
+ +
{results.length ? results.map((command, index) => { const Icon = command.icon; return ; }) :

No matching commands or agents

}
+
Runta Crew⌘K to open
+
+
; +} diff --git a/src/ui/components/Conversation.test.tsx b/src/ui/components/Conversation.test.tsx new file mode 100644 index 0000000..8d02a38 --- /dev/null +++ b/src/ui/components/Conversation.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { Agent, Message } from "@/domain/types"; +import { Conversation, MessageView } from "./Conversation"; + +const agent: Agent = { id: "atlas", name: "Atlas", role: "Researcher", goal: "Find the signal", status: "idle", avatar: "A", lastActiveAt: new Date().toISOString(), unreadCount: 0, computerId: "computer-atlas" }; + +describe("Conversation states", () => { + it("renders a system event without treating it as a user or agent reaction target", () => { + const message: Message = { id: "system-1", conversationId: "conversation-atlas", role: "system", parts: [{ type: "text", text: "Cloud computer reconnected" }], createdAt: new Date().toISOString() }; + const { container } = render( undefined} />); + expect(screen.getByText("Cloud computer reconnected")).toBeInTheDocument(); + expect(container.firstElementChild).toHaveClass("system"); + expect(screen.queryByRole("button", { name: "Mark as useful" })).not.toBeInTheDocument(); + }); + + it("shows a reconnect action only when the connection is unavailable", async () => { + const reconnect = vi.fn(); const user = userEvent.setup(); + render( undefined} onReact={async () => undefined} onReconnect={reconnect} onToggleDetails={() => undefined} />); + expect(screen.getByText("Connection lost")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Reconnect" })); + expect(reconnect).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/ui/components/Conversation.tsx b/src/ui/components/Conversation.tsx index adb3c00..23c0e36 100644 --- a/src/ui/components/Conversation.tsx +++ b/src/ui/components/Conversation.tsx @@ -1,24 +1,29 @@ -import { ArrowUp, CircleStop, Cloud, FileText, Globe2, LoaderCircle, Terminal } from "lucide-react"; +import { CircleStop, Cloud, File, FileText, Globe2, LoaderCircle, Monitor, Paperclip, Terminal, ThumbsDown, ThumbsUp, X } from "lucide-react"; import { useEffect, useRef, useState, type FormEvent } from "react"; -import type { ActivityEvent, Agent, ConnectionState, Message } from "@/domain/types"; +import type { ActivityEvent, Agent, Attachment, ConnectionState, Message, ReactionKind } from "@/domain/types"; const activityIcon = { browser: Globe2, terminal: Terminal, file: FileText, handoff: Cloud, status: Cloud }; function textOf(message: Message) { return message.parts.filter((part) => part.type === "text").map((part) => part.text).join(""); } -function MessageView({ message, activities }: { message: Message; activities: ActivityEvent[] }) { +const formatBytes = (bytes: number) => bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`; +function SubmitArrowIcon() { return ; } +export function MessageView({ message, activities, onReact }: { message: Message; activities: ActivityEvent[]; onReact(messageId: string, reaction: ReactionKind): Promise }) { const relevant = message.parts.flatMap((part) => part.type === "activity" ? activities.filter((activity) => activity.id === part.activityId) : []); + const attachments = message.parts.flatMap((part) => part.type === "attachment" ? [part.attachment] : []); return
- {message.role !== "system" &&
{message.role === "user" ? "You" : "Agent"}
} -
{textOf(message)}{message.streaming && }
- {relevant.map((activity) => { const Icon = activityIcon[activity.kind]; return
{activity.title}{activity.detail}
{activity.status === "running" ? : "Done"}
; })} + {textOf(message) &&
{textOf(message)}{message.streaming && }
} + {attachments.length > 0 &&
{attachments.map((attachment) =>
{attachment.name}{formatBytes(attachment.size)} · {attachment.mediaType}
)}
} + {relevant.map((activity) => { const Icon = activityIcon[activity.kind]; return
{activity.title}{activity.detail}
{activity.status === "running" ? : "Done"}
; })} + {message.role === "agent" && !message.streaming &&
}
; } -export function Conversation({ agent, messages, activities, connection, onSend, onReconnect, onToggleDetails }: { agent: Agent; messages: Message[]; activities: ActivityEvent[]; connection: ConnectionState; onSend(text: string): Promise; onReconnect(): void; onToggleDetails(): void }) { - const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); const bottomRef = useRef(null); - useEffect(() => { if (typeof bottomRef.current?.scrollIntoView === "function") bottomRef.current.scrollIntoView({ behavior: "smooth" }); }, [messages]); - async function submit(event: FormEvent) { event.preventDefault(); const text = draft.trim(); if (!text || sending) return; setDraft(""); setSending(true); try { await onSend(text); } finally { setSending(false); } } +export function Conversation({ agent, messages, activities, connection, onSend, onReact, onReconnect, onToggleDetails }: { agent: Agent; messages: Message[]; activities: ActivityEvent[]; connection: ConnectionState; onSend(text: string, attachments?: Attachment[]): Promise; onReact(messageId: string, reaction: ReactionKind): Promise; onReconnect(): void; onToggleDetails(): void }) { + const [draft, setDraft] = useState(""); const [attachments, setAttachments] = useState([]); const [sending, setSending] = useState(false); const [isScrolled, setIsScrolled] = useState(false); const scrollRef = useRef(null); + useEffect(() => { const element = scrollRef.current; if (element && typeof element.scrollTo === "function") element.scrollTo({ top: element.scrollHeight, behavior: "smooth" }); }, [messages]); + async function submit(event: FormEvent) { event.preventDefault(); const text = draft.trim(); if ((!text && attachments.length === 0) || sending) return; setDraft(""); setSending(true); try { await onSend(text, attachments); setAttachments([]); } finally { setSending(false); } } + async function chooseAttachments() { const selected = await window.runtaCrew?.attachments.choose() ?? []; setAttachments((current) => [...current, ...selected.filter((next) => !current.some((item) => item.id === next.id)).map((item) => ({ ...item, source: "local-selection" as const }))].slice(0, 8)); } return
-

{agent.name}

{agent.role}

{connection === "connected" ? "Connected" : connection === "connecting" ? "Connecting" : "Connection lost"}{connection !== "connected" && }
-
{agent.avatar}

{agent.name}

{agent.goal}

Cloud agent · Persistent workspace
{messages.map((message) => )}
-