diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df847bb..bf8c486 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,11 @@ name: CI on: + workflow_dispatch: pull_request: push: branches: [main] + tags: ["*"] jobs: verify: @@ -19,6 +21,18 @@ jobs: - run: npm run lint - run: npm test - run: npm run build + + artifact: + if: startsWith(github.ref, 'refs/tags/') + needs: verify + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci - run: npm run package - run: npm run smoke - uses: actions/upload-artifact@v4 diff --git a/AGENTS.md b/AGENTS.md index 1488ac0..6689e7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,11 +3,11 @@ - Keep the Electron main process, typed preload bridge, domain model, transports, state orchestration, and renderer UI as separate layers. - Renderer code must not import Node.js or Electron. Keep `contextIsolation` and sandbox enabled, and `nodeIntegration` disabled. - Never expose generic IPC, filesystem access, shell execution, or decrypted credentials through preload. -- 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. +- Keep Runta Cloud Agents routes and event framing aligned with the `runta` Cloud Agents implementation and its end-to-end tests. +- Do not add product-side mock data or simulated computer surfaces. Tests may use bounded transport doubles, but the application must surface unavailable backend capabilities honestly. - 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. +- Keep every UI change minimal: add only necessary controls or decoration, and justify every visible border. - 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. +- Use Conventional Commits. Runta Crew is currently in rapid iteration: commit and push directly to the active remote branch. A pull request is not required unless the user explicitly asks for one. Keep branch history linear and never introduce merge commits. +- 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. diff --git a/API_INTEGRATION.md b/API_INTEGRATION.md deleted file mode 100644 index 206f75a..0000000 --- a/API_INTEGRATION.md +++ /dev/null @@ -1,45 +0,0 @@ -# Runta Cloud Agents API integration - -Runta Crew does not assume unconfirmed backend paths. `HttpCloudAgentsClient` accepts a `CloudAgentsRoutes` mapping and implements endpoint resolution, bearer authentication injection, typed failures, and `AbortSignal` cancellation. - -## Required operations - -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 | -| `openComputer`, `takeOverComputer` | Short-lived secure session descriptor, origin/audience, expiry, audit trail | -| `reconnect` | Authentication/session validation and event resume behavior | - -## Decisions still required - -1. Canonical REST or ConnectRPC paths and response envelopes. -2. Account/organization identity and token issuance/refresh flow. -3. Whether authenticated requests are brokered through the Electron main process (recommended) or use a cookie-bound web origin. -4. SSE vs WebSocket vs Connect streaming framing, cursor format, replay window, and backpressure. -5. Message idempotency, retry, cancellation, and offline queue semantics. -6. Agent status state machine and terminal/offline reason codes. -7. Approval scope schema, expiry, revocation, and audit retention. -8. Mapping from Agent to Runtime/Cloud Computer: dedicated vs shared environments. -9. Computer session transport: Runta ingress, WebRTC, VNC, or browser stream; takeover arbitration and idle timeout. -10. Attachments, files, tool output truncation, and signed download URLs. -11. API error envelope, retry-after behavior, rate limits, and compatibility/version negotiation. -12. Desktop notification payloads and background delivery when the app is closed. - -## Proposed event shapes - -The domain currently understands `message.created`, `message.delta`, `message.completed`, `activity.updated`, `approval.updated`, and `connection.changed`. These are client-side names, not claims about existing backend events. The adapter should translate the confirmed server protocol into these stable domain events. - -## 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 deleted file mode 100644 index daae30c..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,53 +0,0 @@ -# Architecture - -## Product mapping - -| Product concept | Runta Crew implementation | -| --- | --- | -| Persistent named AI teammate | `Agent` domain object | -| Ongoing user relationship | `Conversation` and typed event stream | -| Visible work | `ActivityEvent` timeline | -| Human authority boundary | `ApprovalRequest` with explicit scope | -| Agent's machine | `CloudComputer` and replaceable computer transport | -| Desktop shell | Electron main, preload, and React renderer | - -This is a new implementation. The reference reconstruction was not licensed for source reuse, so it informed only the product/architecture mapping above. - -## Process boundaries - -```text -Electron main - native window/menu - safeStorage credentials - settings file - allowlisted external URLs - │ typed invoke-only preload bridge - ▼ -React renderer - useCrewController (domain state/effects) - │ CloudAgentsClient port - ├── MockCloudAgentsClient (active MVP) - └── 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, 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 - -`CloudAgentsClient` is the renderer-facing port. Components never import fixtures or transport implementations. `useCrewController` owns request cancellation, subscriptions, selection, loading, error, and connection state. This keeps the eventual backend migration localized to client construction and transport wiring. - -The HTTP adapter requires route injection. It deliberately throws `contract_pending` if routes are absent rather than inventing Runta API paths. Event framing and remote computer transports remain explicit integration points. - -Before production authentication, authenticated HTTP should move behind a narrow main-process request broker so decrypted bearer tokens never enter renderer memory. The current HTTP adapter is not activated by the UI. - -## Computer surface - -The MVP preview is generated UI, clearly marked `Safe mock preview`. Open/Take over show a modal explaining that no remote session exists. A production adapter may return an approved HTTPS ingress URL or drive a WebRTC/VNC viewer, but must preserve origin validation, short-lived authorization, and takeover audit events. - -## Updates - -`UpdateService` is a port with a disabled implementation. No update URL or third-party update service is contacted. Production rollout must provide signing, notarization, update manifest authenticity, downgrade policy, and staged rollout behavior. - -## Theme and layout - -Light tokens are the default. Dark tokens live under `data-theme="dark"`. At widths below 1030px, the detail panel becomes an overlay so conversation space remains usable; the Electron minimum window is 960×640. diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md deleted file mode 100644 index 288110d..0000000 --- a/DESIGN_SYSTEM.md +++ /dev/null @@ -1,49 +0,0 @@ -# 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 deleted file mode 100644 index 970f51f..0000000 --- a/FEATURE_PARITY.md +++ /dev/null @@ -1,33 +0,0 @@ -# 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 c9787ed..ab73123 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,78 @@ +
+ # Runta Crew -Runta Crew is the desktop client for creating, messaging, and supervising Runta Cloud Agents. Each agent is modeled as a persistent AI teammate working inside a Runta Runtime cloud computer. +### Your cloud agents, ready to take on real work. + +Create a crew. Give them a job. Come back to finished work. + +[Runta](https://runta.com) · macOS · Powered by Runta Cloud Agents + +
+ +![Runta Crew](./docs/screenshots/main-window.gif) -![Runta Crew main window](./docs/screenshots/main-window.png) +## Your AI team has a computer now -> **Current status:** this repository is an installable Electron MVP running entirely against `MockCloudAgentsClient`. It is not connected to the Runta Cloud Agents service, and the computer preview is intentionally labeled as a mock. See [API_INTEGRATION.md](./API_INTEGRATION.md) for the backend contract still to be confirmed. +Runta Crew is a desktop home for persistent cloud agents. Each teammate runs inside its own Runta Runtime, keeps its workspace, and continues working after you close the app. -## What works today +No tab jungle. No babysitting terminal sessions. Just message your crew and let them work. -- 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. +## Built for delegation -## Development +- **A crew that sticks around** — create focused agents with persistent cloud workspaces. +- **Real work, not chat theater** — follow live activity, tool use, approvals, and results. +- **Pick up where you left off** — conversations and completed runs stay with each agent. +- **Cloud-native by default** — the desktop app connects directly to Runta Cloud Agents. +- **Quietly native** — a fast, minimal macOS experience with notifications and deep links. +- **Secure at the boundary** — device authorization and credentials protected by Electron `safeStorage`. -Requires Node.js 22+ and npm 10+ on macOS. +## Run it locally + +Requires macOS, Node.js 22+, and npm 10+. ```bash +git clone https://github.com/runta-dev/runta-crew.git +cd runta-crew npm ci npm run dev ``` -The first screen uses the light theme. No account or backend is required in mock mode. +The development app connects to: + +- Cloud Agents API: `https://api.forge` +- Runta Dashboard: `https://app.forge` -## Verification +Runta Crew uses the real Cloud Agents API. There is no local demo transport or silent mock fallback. + +## Ship with confidence ```bash npm run typecheck npm run lint npm test npm run build +``` + +For the authenticated end-to-end flow: + +```bash +RUNTA_CREW_E2E_TOKEN=... npm run test:e2e +``` + +Package and smoke-test the macOS app: + +```bash npm run package npm run smoke ``` -Packaged artifacts are written under `release/`. The app is ad-hoc signed for local development; production distribution will require the Runta Developer ID identity, notarization, and an approved update service. - -## Architecture +Artifacts are written to `release/`. Local builds are ad-hoc signed; public distribution requires Runta signing, notarization, and an approved update channel. -- `electron/main`: native window lifecycle, safe external navigation, settings persistence, OS credential encryption. -- `electron/preload`: small typed bridge; no generic IPC or Node primitives. -- `src/domain`: stable Cloud Agents types and ports. -- `src/clients/mock`: local demo transport and separate fixtures. -- `src/clients/http`: endpoint/auth/error/cancellation adapter with route injection. -- `src/state`: renderer orchestration and event subscription lifecycle. -- `src/ui`: presentation and focused interaction components. +## Under the hood -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. +Runta Crew keeps the security boundary small: Electron main owns native lifecycle, authorization, encrypted credentials, and the allowlisted Cloud API broker; preload exposes a narrow typed bridge; React handles the product experience. -## Relationship to the reference project +## Independent implementation -Grok Bot 0.18 Reconstructed was used only to study the high-level product shape and Electron boundaries. Its provenance states that no upstream source-code license is implied. Runta Crew therefore contains an independent implementation and does not copy its source, binaries, assets, private interfaces, account system, telemetry, updater, or trademarks. +Grok Bot 0.18 Reconstructed was studied for product shape and Electron boundaries. Runta Crew is an independent implementation and does not copy its source, binaries, assets, private interfaces, account system, telemetry, updater, or trademarks. diff --git a/build/icon.png b/build/icon.png index b7aaaf6..bb8b28f 100644 Binary files a/build/icon.png and b/build/icon.png differ diff --git a/docs/screenshots/main-window.gif b/docs/screenshots/main-window.gif new file mode 100644 index 0000000..73986e3 Binary files /dev/null and b/docs/screenshots/main-window.gif differ diff --git a/docs/screenshots/main-window.png b/docs/screenshots/main-window.png deleted file mode 100644 index d7a490b..0000000 Binary files a/docs/screenshots/main-window.png and /dev/null differ diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 2994021..7031b24 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ }, preload: { plugins: [externalizeDepsPlugin()], - build: { rollupOptions: { input: resolve("electron/preload/index.ts") } }, + build: { rollupOptions: { input: resolve("electron/preload/index.ts"), output: { format: "cjs" } } }, }, renderer: { root: ".", diff --git a/electron/main/index.ts b/electron/main/index.ts index 4ffdc47..d7f94a9 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1,18 +1,24 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, Notification, safeStorage, shell } from "electron"; -import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, net, Notification, safeStorage, shell } from "electron"; +import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { extname, join, basename } from "node:path"; import { randomUUID } from "node:crypto"; -import type { AppSettings } from "../../src/shared/desktop"; +import type { AppSettings, CloudRequest, CloudStreamEvent, DeviceAuthorizationStatus } from "../../src/shared/desktop"; 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 defaultSettings: AppSettings = { endpoint: "https://api.forge", dashboardUrl: "https://app.forge", theme: "light", notifications: true }; +let settings: AppSettings = defaultSettings; +let authorizationStatus: DeviceAuthorizationStatus = "idle"; const selectedAttachmentPaths = new Map(); +const cloudStreams = new Map(); +const cloudStreamSenders = new Set(); let mainWindow: BrowserWindow | undefined; let pendingDeepLinkAgentId: string | undefined; +app.setName("Runta Crew"); + 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; } } @@ -32,21 +38,23 @@ function loadSettings(): AppSettings { try { const value = JSON.parse(readFileSync(settingsFile(), "utf8")) as Partial; const theme = value.theme === "dark" || value.theme === "system" ? value.theme : "light"; - return { endpoint: typeof value.endpoint === "string" ? value.endpoint : "", notifications: value.notifications !== false, theme }; + const configuredEndpoint = typeof value.endpoint === "string" ? value.endpoint.trim() : ""; + const endpoint = configuredEndpoint === "https://app.forge/api" ? defaultSettings.endpoint : configuredEndpoint; + return { endpoint: isDev && endpoint ? endpoint : defaultSettings.endpoint, dashboardUrl: isDev && typeof value.dashboardUrl === "string" && value.dashboardUrl.trim() ? value.dashboardUrl : defaultSettings.dashboardUrl, notifications: value.notifications !== false, theme, modelProviderId: typeof value.modelProviderId === "string" && value.modelProviderId.trim() ? value.modelProviderId : undefined }; } catch { return settings; } } function createWindow() { const requestedSize = process.env.RUNTA_CREW_WINDOW_SIZE?.match(/^(\d+)x(\d+)$/); - const width = requestedSize ? Math.max(960, Number(requestedSize[1])) : 1440; - const height = requestedSize ? Math.max(640, Number(requestedSize[2])) : 920; + const width = requestedSize ? Math.max(960, Number(requestedSize[1])) : 1040; + const height = requestedSize ? Math.max(640, Number(requestedSize[2])) : 760; const window = new BrowserWindow({ width, height, minWidth: 960, minHeight: 640, title: "Runta Crew", backgroundColor: "#f7f6f3", titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default", trafficLightPosition: { x: 18, y: 18 }, webPreferences: { - preload: join(__dirname, "../preload/index.mjs"), + preload: join(__dirname, "../preload/index.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true, }, }); @@ -79,6 +87,10 @@ else { app.whenReady().then(() => { settings = loadSettings(); + if (process.platform === "darwin" && app.dock && isDev) { + const dockIconPath = join(process.cwd(), "build/icon.png"); + if (existsSync(dockIconPath)) app.dock.setIcon(nativeImage.createFromPath(dockIconPath)); + } if (app.isPackaged) app.setAsDefaultProtocolClient("runta-crew"); Menu.setApplicationMenu(Menu.buildFromTemplate([ { label: "Runta Crew", submenu: [{ role: "about" }, { type: "separator" }, { role: "quit" }] }, @@ -99,14 +111,132 @@ ipcMain.handle("desktop:openExternal", (_event, url: string) => { return shell.openExternal(parsed.toString()); }); ipcMain.handle("settings:get", () => settings); -ipcMain.handle("settings:set", (_event, next: AppSettings) => { settings = next; writeFileSync(settingsFile(), JSON.stringify(settings, null, 2), { mode: 0o600 }); return settings; }); -ipcMain.handle("credentials:has", () => existsSync(credentialFile())); +ipcMain.handle("settings:set", (_event, next: AppSettings) => { + settings = { ...next, endpoint: isDev ? next.endpoint : defaultSettings.endpoint, dashboardUrl: isDev ? next.dashboardUrl : defaultSettings.dashboardUrl }; + writeFileSync(settingsFile(), JSON.stringify(settings, null, 2), { mode: 0o600 }); return settings; +}); +ipcMain.handle("credentials:has", () => existsSync(credentialFile()) && readFileSync(credentialFile()).length > 0); ipcMain.handle("credentials:set", (_event, token: string | null) => { - if (!token) { if (existsSync(credentialFile())) writeFileSync(credentialFile(), Buffer.alloc(0)); return false; } + if (!token) { if (existsSync(credentialFile())) rmSync(credentialFile()); authorizationStatus = "idle"; return false; } if (!safeStorage.isEncryptionAvailable()) throw new Error("OS credential encryption is unavailable"); writeFileSync(credentialFile(), safeStorage.encryptString(token), { mode: 0o600 }); return true; }); +ipcMain.handle("auth:status", () => authorizationStatus); +ipcMain.handle("auth:logout", async () => { + if (!existsSync(credentialFile()) || readFileSync(credentialFile()).length === 0) { authorizationStatus = "idle"; return true; } + if (!safeStorage.isEncryptionAvailable()) throw new Error("OS credential encryption is unavailable"); + if (!settings.endpoint) throw new Error("Runta API endpoint is not configured"); + const token = safeStorage.decryptString(readFileSync(credentialFile())); + const apiBase = `${settings.endpoint.replace(/\/+$/, "")}/`; + const response = await net.fetch(new URL("v1/auth/token", apiBase).toString(), { method: "DELETE", headers: { authorization: `Bearer ${token}` } }); + if (!response.ok && response.status !== 401) throw new Error(`Runta key revocation failed (${response.status})`); + if (existsSync(credentialFile())) rmSync(credentialFile()); + authorizationStatus = "idle"; + return true; +}); +ipcMain.handle("auth:start", async () => { + if (!settings.endpoint || !settings.dashboardUrl) throw new Error("API and Dashboard URLs are required"); + const apiBase = `${settings.endpoint.replace(/\/+$/, "")}/`; + const response = await net.fetch(new URL("v1/auth/device/authorization", apiBase).toString(), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ client_id: "runta_crew", device_name: `Runta Crew on ${process.platform}`, app_url: settings.dashboardUrl.replace(/\/+$/, "") }), + }); + if (!response.ok) throw new Error(`Device authorization failed (${response.status})`); + const envelope = await response.json() as { data: { device_code: string; user_code: string; verification_uri_complete: string; expires_at: string; interval: number } }; + authorizationStatus = "pending"; + const poll = async () => { + let interval = Math.max(5, envelope.data.interval || 5); + const expiresAt = Date.parse(envelope.data.expires_at); + while (authorizationStatus === "pending") { + await new Promise((resolve) => setTimeout(resolve, interval * 1000)); + if (Number.isFinite(expiresAt) && Date.now() >= expiresAt) { authorizationStatus = "expired"; return; } + let tokenResponse: Response; + try { + tokenResponse = await net.fetch(new URL("v1/auth/device/token", apiBase).toString(), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ device_code: envelope.data.device_code }) }); + } catch { + continue; + } + if (tokenResponse.ok) { + const token = await tokenResponse.json() as { access_token: string }; + if (!safeStorage.isEncryptionAvailable()) { authorizationStatus = "error"; return; } + writeFileSync(credentialFile(), safeStorage.encryptString(token.access_token), { mode: 0o600 }); + authorizationStatus = "authorized"; + if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.show(); mainWindow.focus(); } + if (process.platform === "darwin") app.focus({ steal: true }); + return; + } + const error = await tokenResponse.json().catch(() => ({})) as { error?: string; interval?: number }; + if (error.error === "slow_down") interval = Math.max(interval + 5, error.interval ?? 0); + else if (error.error === "access_denied") { authorizationStatus = "denied"; return; } + else if (error.error === "expired_token") { authorizationStatus = "expired"; return; } + else if (error.error !== "authorization_pending") { authorizationStatus = "error"; return; } + } + }; + void poll(); + await shell.openExternal(envelope.data.verification_uri_complete); + return { verificationUrl: envelope.data.verification_uri_complete, userCode: envelope.data.user_code, expiresAt: envelope.data.expires_at }; +}); +ipcMain.handle("cloud:request", async (_event, request: CloudRequest) => { + if (!settings.endpoint) throw new Error("Runta API endpoint is not configured"); + if (!existsSync(credentialFile()) || !safeStorage.isEncryptionAvailable()) throw new Error("Runta API token is not configured"); + const encrypted = readFileSync(credentialFile()); + if (!encrypted.length) throw new Error("Runta API token is not configured"); + const token = safeStorage.decryptString(encrypted); + const endpoint = new URL(`${settings.endpoint.replace(/\/+$/, "")}/`); + const url = new URL(request.path.replace(/^\/+/, ""), endpoint); + const apiPrefix = `${endpoint.pathname.replace(/\/+$/, "")}/v1/`; + if (url.origin !== endpoint.origin || !url.pathname.startsWith(apiPrefix)) throw new Error("Cloud request path is not allowed"); + const response = await net.fetch(url.toString(), { + method: request.method, + headers: { authorization: `Bearer ${token}`, ...(request.body === undefined ? {} : { "content-type": "application/json" }) }, + body: request.body === undefined ? undefined : JSON.stringify(request.body), + }); + const text = await response.text(); + let body: unknown; + if (text) { try { body = JSON.parse(text) as unknown; } catch { body = text; } } + return { status: response.status, body }; +}); +ipcMain.on("cloud:stream:subscribe", (event, value: { subscriptionId?: unknown; path?: unknown }) => { + const subscriptionId = typeof value?.subscriptionId === "string" && /^\d{1,10}$/.test(value.subscriptionId) ? value.subscriptionId : undefined; + const path = typeof value?.path === "string" && /^\/v1\/agents\/[A-Za-z0-9._-]{1,160}\/runs\/[A-Za-z0-9._-]{1,160}\/events(?:\?after=-?\d+)?$/.test(value.path) ? value.path : undefined; + if (!subscriptionId || !path) return; + const senderId = event.sender.id; const key = `${senderId}:${subscriptionId}`; + if (!cloudStreamSenders.has(senderId)) { + cloudStreamSenders.add(senderId); + event.sender.once("destroyed", () => { for (const [candidate, stream] of cloudStreams) { if (candidate.startsWith(`${senderId}:`)) { stream.abort(); cloudStreams.delete(candidate); } } cloudStreamSenders.delete(senderId); }); + } + if ([...cloudStreams.keys()].filter((candidate) => candidate.startsWith(`${senderId}:`)).length >= 16) return; + cloudStreams.get(key)?.abort(); + const controller = new AbortController(); cloudStreams.set(key, controller); + const send = (streamEvent: CloudStreamEvent) => { if (!event.sender.isDestroyed()) event.sender.send("cloud:stream:event", { subscriptionId, ...streamEvent }); }; + void (async () => { + try { + if (!settings.endpoint || !existsSync(credentialFile()) || !safeStorage.isEncryptionAvailable()) throw new Error("Runta API token is not configured"); + const endpoint = new URL(`${settings.endpoint.replace(/\/+$/, "")}/`); const url = new URL(path.replace(/^\/+/, ""), endpoint); + const token = safeStorage.decryptString(readFileSync(credentialFile())); + const response = await net.fetch(url.toString(), { headers: { authorization: `Bearer ${token}`, accept: "text/event-stream" }, signal: controller.signal }); + if (!response.ok || !response.body) throw new Error(`Cloud event stream failed (${response.status})`); + const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; + const dispatch = (block: string) => { + let eventName = "message"; let id: string | undefined; const data: string[] = []; + for (const line of block.split(/\r?\n/)) { if (line.startsWith("event:")) eventName = line.slice(6).trim(); else if (line.startsWith("id:")) id = line.slice(3).trim(); else if (line.startsWith("data:")) data.push(line.slice(5).trimStart()); } + if (!data.length) return; const text = data.join("\n"); let parsed: unknown = text; try { parsed = JSON.parse(text) as unknown; } catch { /* preserve non-JSON SSE data */ } + send({ event: eventName, id, data: parsed }); + }; + while (!controller.signal.aborted) { + const { done, value: chunk } = await reader.read(); if (done) break; buffer += decoder.decode(chunk, { stream: true }); + const blocks = buffer.split(/\r?\n\r?\n/); buffer = blocks.pop() ?? ""; for (const block of blocks) dispatch(block); + } + } catch (reason) { if (!controller.signal.aborted) send({ event: "error", data: reason instanceof Error ? reason.message : "Cloud event stream failed" }); } + finally { + if (!controller.signal.aborted) send({ event: "stream.closed" }); + if (cloudStreams.get(key) === controller) cloudStreams.delete(key); + } + })(); +}); +ipcMain.on("cloud:stream:unsubscribe", (event, subscriptionId: unknown) => { if (typeof subscriptionId !== "string") return; const key = `${event.sender.id}:${subscriptionId}`; cloudStreams.get(key)?.abort(); cloudStreams.delete(key); }); 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 []; @@ -116,7 +246,7 @@ ipcMain.handle("attachments:choose", async () => { 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." }); + 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." }); return attachments; }); ipcMain.handle("notifications:show", (event, value: { title?: unknown; body?: unknown }) => { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index b9c95f2..c1cf5d8 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -1,6 +1,8 @@ import { contextBridge, ipcRenderer } from "electron"; import type { DesktopBridge } from "../../src/shared/desktop"; +let nextCloudSubscriptionId = 1; + const bridge: DesktopBridge = { getVersion: () => ipcRenderer.invoke("desktop:version"), openExternal: (url) => ipcRenderer.invoke("desktop:openExternal", url), @@ -12,6 +14,21 @@ const bridge: DesktopBridge = { has: () => ipcRenderer.invoke("credentials:has"), set: (token) => ipcRenderer.invoke("credentials:set", token), }, + auth: { + start: () => ipcRenderer.invoke("auth:start"), + status: () => ipcRenderer.invoke("auth:status"), + logout: () => ipcRenderer.invoke("auth:logout"), + }, + cloud: { + request: (request) => ipcRenderer.invoke("cloud:request", request), + subscribe: (path, listener) => { + const subscriptionId = String(nextCloudSubscriptionId++); + const handler = (_event: Electron.IpcRendererEvent, value: { subscriptionId: string; event: string; id?: string; data?: unknown }) => { if (value.subscriptionId === subscriptionId) listener(value); }; + ipcRenderer.on("cloud:stream:event", handler); + ipcRenderer.send("cloud:stream:subscribe", { subscriptionId, path }); + return () => { ipcRenderer.removeListener("cloud:stream:event", handler); ipcRenderer.send("cloud:stream:unsubscribe", subscriptionId); }; + }, + }, attachments: { choose: () => ipcRenderer.invoke("attachments:choose") }, notifications: { show: (notification) => ipcRenderer.invoke("notifications:show", notification), diff --git a/package-lock.json b/package-lock.json index 6f1f452..86c612f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,15 @@ "": { "name": "runta-crew", "version": "0.1.0", + "hasInstallScript": true, "dependencies": { "@vitejs/plugin-react": "^5.0.4", + "boring-avatars": "^2.0.4", "clsx": "^2.1.1", "lucide-react": "^0.468.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "streamdown": "^2.6.0", "zod": "^3.25.76" }, "devDependencies": { @@ -2633,7 +2636,6 @@ "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", - "dev": true, "license": "MIT", "dependencies": { "@types/ms": "*" @@ -2652,6 +2654,15 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -2662,6 +2673,15 @@ "@types/node": "*" } }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -2686,11 +2706,19 @@ "@types/node": "*" } }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -2733,6 +2761,12 @@ "@types/node": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", @@ -2989,6 +3023,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.4.tgz", + "integrity": "sha512-JL+CF0GeLHyPWI0rXu7UnxgiuOm9UQWzadi0OYOJNhNO2q6EZElpwlgXkNkfU1PzANDHq3YcwKVZprdvS+BrbQ==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -3527,6 +3567,16 @@ "dev": true, "license": "MIT" }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -3586,6 +3636,16 @@ "license": "MIT", "optional": true }, + "node_modules/boring-avatars": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/boring-avatars/-/boring-avatars-2.0.4.tgz", + "integrity": "sha512-xhZO/w/6aFmRfkaWohcl2NfyIy87gK5SBbys8kctZeTGF1Apjpv/10pfUuv+YEfVPkESU/h2Y6tt/Dwp+bIZPw==", + "license": "MIT", + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -3810,6 +3870,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -3844,6 +3914,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -3957,6 +4067,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", @@ -4110,6 +4230,19 @@ "dev": true, "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -4218,7 +4351,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4232,6 +4364,19 @@ "license": "MIT", "optional": true }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -4674,7 +4819,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -5058,6 +5202,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -5095,6 +5249,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5635,6 +5795,155 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -5705,6 +6014,26 @@ "dev": true, "license": "MIT" }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -5833,6 +6162,46 @@ "dev": true, "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5866,6 +6235,28 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -6194,6 +6585,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -6291,6 +6692,28 @@ "node": ">=10" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/marked": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", + "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -6315,40 +6738,885 @@ "node": ">= 0.4" } }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", "license": "MIT", - "bin": { - "mime": "cli.js" + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, - "engines": { - "node": ">=4.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" }, - "engines": { - "node": ">= 0.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" } }, "node_modules/mimic-response": { @@ -6731,11 +7999,35 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -7045,6 +8337,16 @@ "signal-exit": "^3.0.2" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -7180,6 +8482,116 @@ "node": ">=8" } }, + "node_modules/rehype-harden": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/rehype-harden/-/rehype-harden-1.1.8.tgz", + "integrity": "sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw==", + "license": "MIT", + "dependencies": { + "unist-util-visit": "^5.0.0" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remend": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/remend/-/remend-1.3.1.tgz", + "integrity": "sha512-N3DiY5qbRPoa5vkxn1oDLMyXOVTeo6Hp+XOj6SIqJAYUgLS0Q587gILPMom/qm86AQ/ZrcOdwEIzCz8V3J0nxQ==", + "license": "Apache-2.0" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7524,6 +8936,16 @@ "source-map": "^0.6.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -7556,6 +8978,33 @@ "dev": true, "license": "MIT" }, + "node_modules/streamdown": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/streamdown/-/streamdown-2.6.0.tgz", + "integrity": "sha512-nQZVUn4GvB2R5SAlDNph20iKZeW+RM4gv6G1H3ACypEnmQf8PgTHZ/1Ta2OACRwQ2coK7aW5AeIAa7Ls5zk+RA==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1", + "hast-util-to-jsx-runtime": "^2.3.6", + "html-url-attributes": "^3.0.1", + "marked": "^17.0.1", + "rehype-harden": "^1.1.8", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remend": "1.3.1", + "tailwind-merge": "^3.6.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0", + "unist-util-visit-parents": "^6.0.2" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -7597,6 +9046,20 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -7670,6 +9133,24 @@ "dev": true, "license": "MIT" }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -7703,6 +9184,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tar": { "version": "7.5.22", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", @@ -8010,6 +9501,26 @@ "node": ">=18" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -8122,6 +9633,93 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -8238,6 +9836,48 @@ "dev": true, "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -8878,6 +10518,16 @@ "node": ">=18" } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/webcrypto-core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", @@ -9155,6 +10805,16 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 23e8df1..c3ee056 100644 --- a/package.json +++ b/package.json @@ -6,24 +6,29 @@ "main": "out/main/index.js", "description": "Desktop client for Runta Cloud Agents", "author": "Runta", - "engines": { "node": ">=22 <27" }, + "engines": { + "node": ">=22 <27" + }, "scripts": { - "dev": "electron-vite dev", + "dev": "node scripts/rebrand-electron.mjs && electron-vite dev", "postinstall": "node scripts/ensure-electron.mjs", "build": "electron-vite build", "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", + "test:e2e": "node scripts/e2e.mjs", "test:ui": "vitest run --project renderer", "smoke": "node scripts/smoke.mjs" }, "dependencies": { "@vitejs/plugin-react": "^5.0.4", + "boring-avatars": "^2.0.4", "clsx": "^2.1.1", "lucide-react": "^0.468.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "streamdown": "^2.6.0", "zod": "^3.25.76" }, "devDependencies": { @@ -52,12 +57,30 @@ "appId": "com.runta.crew", "productName": "Runta Crew", "asar": true, - "directories": { "output": "release" }, - "files": ["out/**/*", "package.json"], + "directories": { + "output": "release" + }, + "files": [ + "out/**/*", + "package.json" + ], "mac": { "category": "public.app-category.productivity", "icon": "build/icon.png", - "target": [{ "target": "dmg", "arch": ["arm64"] }, { "target": "zip", "arch": ["arm64"] }], + "target": [ + { + "target": "dmg", + "arch": [ + "arm64" + ] + }, + { + "target": "zip", + "arch": [ + "arm64" + ] + } + ], "identity": null } } diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs new file mode 100644 index 0000000..08e9966 --- /dev/null +++ b/scripts/e2e.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; + +const endpoint = (process.env.RUNTA_CREW_E2E_ENDPOINT || "https://api.forge").replace(/\/+$/, ""); +const token = process.env.RUNTA_CREW_E2E_TOKEN; +if (!token) throw new Error("RUNTA_CREW_E2E_TOKEN is required"); + +async function request(path, init = {}) { + const response = await fetch(`${endpoint}${path}`, { + ...init, + headers: { authorization: `Bearer ${token}`, ...(init.body ? { "content-type": "application/json" } : {}), ...init.headers }, + }); + const text = await response.text(); + const body = text ? JSON.parse(text) : undefined; + if (!response.ok) throw new Error(`${init.method || "GET"} ${path} failed (${response.status}): ${JSON.stringify(body)}`); + return body; +} + +const profile = await request("/v1/me"); +assert.equal(typeof profile.data?.user_id, "string", "user-authorized device token must expose /v1/me"); + +const providers = await request("/v1/model-providers"); +const provider = providers.model_providers?.[0]; +assert.equal(typeof provider?.id, "string", "an E2E model provider is required"); + +const suffix = Date.now().toString(36); +let agent; +try { + agent = await request("/v1/agents", { + method: "POST", + body: JSON.stringify({ name: `runta-crew-e2e-${suffix}`, model_provider: { type: "managed", id: provider.id } }), + }); + assert.equal(typeof agent.id, "string"); + + const run = await request(`/v1/agents/${encodeURIComponent(agent.id)}/runs`, { + method: "POST", + body: JSON.stringify({ prompt: "Reply with RUNTA_CREW_E2E_OK." }), + }); + assert.equal(typeof run.id, "string"); + + const deadline = Date.now() + 180_000; + let completed; + while (Date.now() < deadline) { + completed = await request(`/v1/agents/${encodeURIComponent(agent.id)}/runs/${encodeURIComponent(run.id)}`); + if (["finished", "failed", "cancelled"].includes(completed.status)) break; + await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + assert.equal(completed?.status, "finished", `run did not finish: ${completed?.status ?? "timeout"}`); + assert.match(completed.result || "", /RUNTA_CREW_E2E_OK/); + console.log(`Runta Crew E2E passed for agent ${agent.id}`); +} finally { + if (agent?.id) await request(`/v1/agents/${encodeURIComponent(agent.id)}?delete_runtime=true`, { method: "DELETE" }); +} diff --git a/scripts/rebrand-electron.mjs b/scripts/rebrand-electron.mjs new file mode 100644 index 0000000..2d0935d --- /dev/null +++ b/scripts/rebrand-electron.mjs @@ -0,0 +1,41 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +if (process.platform === "darwin") { + const appName = "Runta Crew"; + const electronRoot = join(process.cwd(), "node_modules/electron"); + const dist = join(electronRoot, "dist"); + const brandedBundle = join(dist, `${appName}.app`); + const stockBundle = join(dist, "Electron.app"); + const bundle = existsSync(brandedBundle) ? brandedBundle : stockBundle; + const plist = join(bundle, "Contents/Info.plist"); + + if (!existsSync(plist)) throw new Error(`Electron app bundle was not found at ${bundle}`); + + const macOSDirectory = join(bundle, "Contents/MacOS"); + const brandedExecutable = join(macOSDirectory, appName); + if (!existsSync(brandedExecutable)) { + const executable = readdirSync(macOSDirectory).find((entry) => statSync(join(macOSDirectory, entry)).isFile()); + if (!executable) throw new Error(`Electron executable was not found in ${macOSDirectory}`); + renameSync(join(macOSDirectory, executable), brandedExecutable); + } + + for (const key of ["CFBundleName", "CFBundleDisplayName"]) { + execFileSync("plutil", ["-replace", key, "-string", appName, plist]); + } + execFileSync("plutil", ["-replace", "CFBundleExecutable", "-string", appName, plist]); + execFileSync("plutil", ["-replace", "CFBundleIdentifier", "-string", "com.runta.crew.dev", plist]); + + const localization = join(bundle, "Contents/Resources/en.lproj"); + mkdirSync(localization, { recursive: true }); + writeFileSync( + join(localization, "InfoPlist.strings"), + `"CFBundleName" = "${appName}";\n"CFBundleDisplayName" = "${appName}";\n`, + ); + + const launchServices = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"; + if (bundle !== brandedBundle) renameSync(bundle, brandedBundle); + writeFileSync(join(electronRoot, "path.txt"), `${appName}.app/Contents/MacOS/${appName}`); + if (existsSync(launchServices)) execFileSync(launchServices, ["-f", brandedBundle]); +} diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index e98e342..d2c1d9e 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -5,8 +5,9 @@ import { spawn } from "node:child_process"; const appBinary = join(process.cwd(), "release/mac-arm64/Runta Crew.app/Contents/MacOS/Runta Crew"); if (!existsSync(appBinary)) throw new Error(`Packaged app is missing: ${appBinary}`); -const marker = join(mkdtempSync(join(tmpdir(), "runta-crew-smoke-")), "ready"); -const child = spawn(appBinary, [], { env: { ...process.env, RUNTA_CREW_SMOKE_MARKER: marker }, stdio: "pipe" }); +const smokeRoot = mkdtempSync(join(tmpdir(), "runta-crew-smoke-")); +const marker = join(smokeRoot, "ready"); +const child = spawn(appBinary, [`--user-data-dir=${join(smokeRoot, "profile")}`], { env: { ...process.env, RUNTA_CREW_SMOKE_MARKER: marker }, stdio: "pipe" }); const deadline = Date.now() + 20_000; while (!existsSync(marker) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 200)); if (!existsSync(marker) || readFileSync(marker, "utf8").trim() !== "ready") { child.kill(); throw new Error("Packaged app did not finish loading within 20 seconds"); } diff --git a/src/assets/runta-logo-icon.png b/src/assets/runta-logo-icon.png new file mode 100644 index 0000000..1e99dbe Binary files /dev/null and b/src/assets/runta-logo-icon.png differ diff --git a/src/assets/screen-placeholder.gif b/src/assets/screen-placeholder.gif new file mode 100644 index 0000000..73986e3 Binary files /dev/null and b/src/assets/screen-placeholder.gif differ diff --git a/src/clients/http/HttpCloudAgentsClient.test.ts b/src/clients/http/HttpCloudAgentsClient.test.ts deleted file mode 100644 index 5e11fcc..0000000 --- a/src/clients/http/HttpCloudAgentsClient.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { HttpCloudAgentsClient } from "./HttpCloudAgentsClient"; - -describe("HttpCloudAgentsClient", () => { - it("refuses to invent routes before the backend contract is confirmed", async () => { - const client = new HttpCloudAgentsClient({ endpoint: "https://api.example.test" }); - expect(() => client.listAgents()).toThrowError(expect.objectContaining({ code: "contract_pending" })); - }); - - 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`, 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 deleted file mode 100644 index 9872f9e..0000000 --- a/src/clients/http/HttpCloudAgentsClient.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { CloudAgentsClient } from "@/domain/CloudAgentsClient"; -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; - listConversations: (agentId: string) => string; getConversation: (id: string) => string; - sendMessage: (conversationId: string) => string; approvals: string; - 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 } - -export class HttpCloudAgentsClient implements CloudAgentsClient { - private fetchImpl: typeof fetch; - constructor(private options: HttpClientOptions) { this.fetchImpl = options.fetchImpl ?? fetch; } - private route(get: (routes: CloudAgentsRoutes) => T): T { if (!this.options.routes) throw new CrewError("contract_pending", "Runta Cloud Agents API routes have not been confirmed"); return get(this.options.routes); } - private async request(path: string, init: RequestInit = {}): Promise { - const token = await this.options.accessToken?.(); - let response: Response; - try { response = await this.fetchImpl(new URL(path, this.options.endpoint), { ...init, headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}), ...init.headers } }); } - catch { throw new CrewError("network", "Unable to reach Runta Cloud Agents", true); } - 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) { - 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 }); } - getComputer(id: string, signal?: AbortSignal) { return this.request(this.route((r) => r.computer(id)), { signal }); } - openComputer(id: string, signal?: AbortSignal) { return this.request<{ url?: string; mode: "mock" | "remote" }>(this.route((r) => r.openComputer(id)), { method: "POST", signal }); } - takeOverComputer(id: string, signal?: AbortSignal) { return this.request<{ url?: string; mode: "mock" | "remote" }>(this.route((r) => r.takeOverComputer(id)), { method: "POST", signal }); } - async reconnect(signal?: AbortSignal) { await this.listAgents(signal); } -} diff --git a/src/clients/http/RuntaCloudAgentsClient.test.ts b/src/clients/http/RuntaCloudAgentsClient.test.ts new file mode 100644 index 0000000..508a0ee --- /dev/null +++ b/src/clients/http/RuntaCloudAgentsClient.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RuntaCloudAgentsClient } from "./RuntaCloudAgentsClient"; +import type { CloudRequest, DesktopBridge } from "@/shared/desktop"; +import type { CloudStreamEvent } from "@/shared/desktop"; +import type { ConversationEvent } from "@/domain/types"; + +afterEach(() => { delete window.runtaCrew; }); + +describe("RuntaCloudAgentsClient", () => { + it("maps a missing local token to a normal authentication error", async () => { + window.runtaCrew = { cloud: { request: async () => { throw new Error("Error invoking remote method 'cloud:request': Error: Runta API token is not configured"); }, subscribe: () => () => undefined }, settings: {} as DesktopBridge["settings"], credentials: {} as DesktopBridge["credentials"], attachments: {} as DesktopBridge["attachments"], notifications: {} as DesktopBridge["notifications"], deepLinks: {} as DesktopBridge["deepLinks"], getVersion: async () => "test", openExternal: async () => undefined }; + await expect(new RuntaCloudAgentsClient().listAgents()).rejects.toMatchObject({ code: "unauthorized", message: "Authentication is required" }); + }); + + it("maps the current Cloud Agents envelope and uses the managed provider for creation", async () => { + const request = vi.fn(async ({ method, path }: CloudRequest) => { + if (path.startsWith("/v1/agents?")) return { status: 200, body: { agents: [{ id: "agent-1", runtime_id: "agent-1", name: "Builder", status: "running", created_at_unix_seconds: 1, updated_at_unix_seconds: 2, latest_reply: { run_id: "run-1", text: "Latest agent reply", created_at: "2026-08-26T01:00:00Z", updated_at: "2026-08-26T01:00:01Z" } }] } }; + if (path === "/v1/model-providers") return { status: 200, body: { model_providers: [{ id: "provider-1", display_name: "Kimi", protocol: "openai_responses", default_model: "k3" }] } }; + if (method === "GET" && path === "/v1/agents/agent-1/runs?limit=100") return { status: 200, body: [ + { id: "run-2", agent_id: "agent-1", status: "failed", prompt: "Break it", result: null, error: "Tool failed", created_at: "2026-08-26T02:00:00Z", updated_at: "2026-08-26T02:00:01Z" }, + { id: "run-1", agent_id: "agent-1", status: "finished", prompt: "Build it", result: "Done", error: null, created_at: "2026-08-26T01:00:00Z", updated_at: "2026-08-26T01:00:01Z" }, + ] }; + if (method === "POST" && path === "/v1/agents") return { status: 201, body: { id: "agent-2", runtime_id: "agent-2", name: "Reviewer", status: "pending", created_at_unix_seconds: 3, updated_at_unix_seconds: 3 } }; + if (method === "PATCH" && path === "/v1/agents/agent-1") return { status: 200, body: { id: "agent-1", runtime_id: "agent-1", name: "Atlas", status: "running", created_at_unix_seconds: 1, updated_at_unix_seconds: 4 } }; + return { status: 404 }; + }); + const subscribe = (_path: string, listener: (event: CloudStreamEvent) => void) => { queueMicrotask(() => listener({ event: "stream.closed" })); return () => undefined; }; + window.runtaCrew = { cloud: { request, subscribe }, settings: {} as DesktopBridge["settings"], credentials: {} as DesktopBridge["credentials"], attachments: {} as DesktopBridge["attachments"], notifications: {} as DesktopBridge["notifications"], deepLinks: {} as DesktopBridge["deepLinks"], getVersion: async () => "test", openExternal: async () => undefined }; + const client = new RuntaCloudAgentsClient(); + expect((await client.listAgents())[0]).toEqual(expect.objectContaining({ id: "agent-1", name: "Builder", status: "idle", lastMessagePreview: "Latest agent reply", lastActiveAt: "2026-08-26T01:00:01Z" })); + expect(await client.listModelProviders()).toEqual([{ id: "provider-1", name: "Kimi", protocol: "openai_responses", defaultModel: "k3" }]); + expect((await client.getConversation("conversation-agent-1")).messages).toEqual([ + expect.objectContaining({ id: "run-1:user", role: "user", parts: [{ type: "text", text: "Build it" }] }), + expect.objectContaining({ id: "run-1:agent", role: "agent", parts: [{ type: "text", text: "Done" }], streaming: false }), + expect.objectContaining({ id: "run-2:user", role: "user", parts: [{ type: "text", text: "Break it" }] }), + expect.objectContaining({ id: "run-2:agent", role: "system", parts: [{ type: "text", text: "Tool failed" }] }), + ]); + expect(await client.createAgent({ name: "Reviewer", modelProviderId: "provider-1" })).toEqual(expect.objectContaining({ id: "agent-2", status: "working" })); + expect(await client.updateAgent("agent-1", { name: "Atlas" })).toEqual(expect.objectContaining({ id: "agent-1", name: "Atlas" })); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ method: "POST", path: "/v1/agents", body: expect.objectContaining({ model_provider: { type: "managed", id: "provider-1" } }) })); + expect(request).toHaveBeenCalledWith({ method: "PATCH", path: "/v1/agents/agent-1", body: { name: "Atlas" } }); + }); + + it("translates the authenticated run SSE stream without exposing credentials", async () => { + let streamListener: ((event: CloudStreamEvent) => void) | undefined; + const subscribe = vi.fn((_path: string, listener: (event: CloudStreamEvent) => void) => { streamListener = listener; return () => undefined; }); + const request = vi.fn(async () => ({ status: 200, body: [{ id: "run-1", agent_id: "agent-1", status: "running", prompt: "Hello", result: null, error: null, created_at: "2026-08-26T01:00:00Z", updated_at: "2026-08-26T01:00:01Z" }] })); + window.runtaCrew = { cloud: { request, subscribe }, settings: {} as DesktopBridge["settings"], credentials: {} as DesktopBridge["credentials"], attachments: {} as DesktopBridge["attachments"], notifications: {} as DesktopBridge["notifications"], deepLinks: {} as DesktopBridge["deepLinks"], getVersion: async () => "test", openExternal: async () => undefined }; + const events: ConversationEvent[] = []; + const subscription = new RuntaCloudAgentsClient().subscribeToConversationEvents("conversation-agent-1", (event) => events.push(event)); + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledWith("/v1/agents/agent-1/runs/run-1/events?after=-1", expect.any(Function))); + streamListener?.({ event: "acp.event", id: "4", data: { params: { update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-1", content: { text: "Checking." } } } } }); + streamListener?.({ event: "acp.event", id: "5", data: { params: { update: { sessionUpdate: "tool_call", toolCallId: "tool-1", title: "Read", kind: "read", status: "in_progress" } } } }); + streamListener?.({ event: "acp.event", id: "6", data: { params: { update: { sessionUpdate: "tool_call_update", toolCallId: "tool-1", title: "Read", kind: "read", status: "completed" } } } }); + streamListener?.({ event: "acp.event", id: "7", data: { params: { update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-2", content: { text: "Hi" } } } } }); + streamListener?.({ event: "acp.event", id: "8", data: { params: { update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-2", content: { text: " there" } } } } }); + streamListener?.({ event: "run.status", id: "status:finished", data: { id: "run-1", agent_id: "agent-1", status: "finished", prompt: "Hello", result: "Hi there", error: null } }); + expect(events).toContainEqual({ type: "message.created", message: expect.objectContaining({ id: "run-1:agent:assistant-1", parts: [{ type: "text", text: "Checking." }], streaming: true }) }); + expect(events).toContainEqual({ type: "message.completed", messageId: "run-1:agent:assistant-1", notify: false }); + expect(events).toContainEqual({ type: "message.created", message: expect.objectContaining({ id: "run-1:agent:assistant-2", parts: [{ type: "text", text: "Hi" }], streaming: true }) }); + expect(events).toContainEqual({ type: "message.delta", messageId: "run-1:agent:assistant-2", delta: " there" }); + expect(events).toContainEqual({ type: "activity.updated", activity: expect.objectContaining({ id: "tool:tool-1", title: "Reading file", kind: "file", status: "running" }) }); + expect(events).toContainEqual({ type: "activity.updated", activity: expect.objectContaining({ id: "tool:tool-1", status: "completed" }) }); + expect(events).not.toContainEqual(expect.objectContaining({ type: "message.updated", message: expect.objectContaining({ id: "run-1:agent" }) })); + expect(events).toContainEqual({ type: "message.completed", messageId: "run-1:agent:assistant-2", notify: true }); + subscription.unsubscribe(); + }); + + it("discovers a locally created run immediately instead of waiting for fallback polling", async () => { + let created = false; + const subscribe = vi.fn(() => () => undefined); + const request = vi.fn(async ({ method }: CloudRequest) => method === "POST" + ? (created = true, { status: 201, body: { id: "run-new", agent_id: "agent-1", status: "pending", prompt: "Start", result: null, error: null } }) + : { status: 200, body: created ? [{ id: "run-new", agent_id: "agent-1", status: "pending", prompt: "Start", result: null, error: null }] : [] }); + window.runtaCrew = { cloud: { request, subscribe }, settings: {} as DesktopBridge["settings"], credentials: {} as DesktopBridge["credentials"], attachments: {} as DesktopBridge["attachments"], notifications: {} as DesktopBridge["notifications"], deepLinks: {} as DesktopBridge["deepLinks"], getVersion: async () => "test", openExternal: async () => undefined }; + const client = new RuntaCloudAgentsClient(); + const subscription = client.subscribeToConversationEvents("conversation-agent-1", () => undefined); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)); + await client.sendMessage({ conversationId: "conversation-agent-1", text: "Start" }); + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledWith("/v1/agents/agent-1/runs/run-new/events?after=-1", expect.any(Function))); + subscription.unsubscribe(); + }); + + it("replays run summaries from oldest to newest so the sidebar preview stays current", async () => { + const request = vi.fn(async () => ({ status: 200, body: [ + { id: "run-new", agent_id: "agent-1", status: "finished", prompt: "New prompt", result: "Newest reply", error: null, created_at: "2026-08-26T02:00:00Z", updated_at: "2026-08-26T02:00:01Z" }, + { id: "run-old", agent_id: "agent-1", status: "finished", prompt: "Old prompt", result: "Old reply", error: null, created_at: "2026-08-26T01:00:00Z", updated_at: "2026-08-26T01:00:01Z" }, + ] })); + window.runtaCrew = { cloud: { request, subscribe: () => () => undefined }, settings: {} as DesktopBridge["settings"], credentials: {} as DesktopBridge["credentials"], attachments: {} as DesktopBridge["attachments"], notifications: {} as DesktopBridge["notifications"], deepLinks: {} as DesktopBridge["deepLinks"], getVersion: async () => "test", openExternal: async () => undefined }; + const replies: string[] = []; + const subscription = new RuntaCloudAgentsClient().subscribeToConversationEvents("conversation-agent-1", (event) => { + if (event.type === "message.created" && event.message.role === "agent") replies.push(event.message.parts[0]?.type === "text" ? event.message.parts[0].text : ""); + }); + await vi.waitFor(() => expect(replies).toEqual(["Old reply", "Newest reply"])); + subscription.unsubscribe(); + }); + + it("replays historical assistant messages by ACP message id instead of the aggregated run result", async () => { + const request = vi.fn(async () => ({ status: 200, body: [{ + id: "run-history", agent_id: "agent-1", status: "finished", prompt: "Research it", + result: "Checking.Browsing.Done with a giant aggregate", error: null, + created_at: "2026-08-26T01:00:00Z", updated_at: "2026-08-26T01:00:01Z", + }] })); + const subscribe = vi.fn((_path: string, listener: (event: CloudStreamEvent) => void) => { + queueMicrotask(() => { + listener({ event: "run.status", data: { status: "finished" } }); + listener({ event: "acp.event", id: "1", data: { params: { update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-1", content: { text: "Checking." } } } } }); + listener({ event: "acp.event", id: "2", data: { params: { update: { sessionUpdate: "tool_call", toolCallId: "tool-1" } } } }); + listener({ event: "acp.event", id: "3", data: { params: { update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-2", content: { text: "Done" } } } } }); + listener({ event: "acp.event", id: "4", data: { params: { update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-2", content: { text: " now." } } } } }); + listener({ event: "stream.closed" }); + }); + return () => undefined; + }); + window.runtaCrew = { cloud: { request, subscribe }, settings: {} as DesktopBridge["settings"], credentials: {} as DesktopBridge["credentials"], attachments: {} as DesktopBridge["attachments"], notifications: {} as DesktopBridge["notifications"], deepLinks: {} as DesktopBridge["deepLinks"], getVersion: async () => "test", openExternal: async () => undefined }; + + const result = await new RuntaCloudAgentsClient().getConversation("conversation-agent-1"); + + expect(result.messages).toEqual([ + expect.objectContaining({ id: "run-history:user", role: "user", parts: [{ type: "text", text: "Research it" }] }), + expect.objectContaining({ id: "run-history:agent:assistant-2", role: "agent", parts: [{ type: "text", text: "Done now." }], streaming: false }), + ]); + expect(JSON.stringify(result.messages)).not.toContain("Checking."); + expect(JSON.stringify(result.messages)).not.toContain("giant aggregate"); + }); +}); diff --git a/src/clients/http/RuntaCloudAgentsClient.ts b/src/clients/http/RuntaCloudAgentsClient.ts new file mode 100644 index 0000000..36395d0 --- /dev/null +++ b/src/clients/http/RuntaCloudAgentsClient.ts @@ -0,0 +1,283 @@ +import type { CloudAgentsClient } from "@/domain/CloudAgentsClient"; +import { CrewError, type ActivityEvent, type Agent, type ApprovalRequest, type CloudComputer, type ConversationEvent, type CreateAgentInput, type Message, type ModelProviderOption, type RespondApprovalInput, type SendMessageInput, type Subscription, type UpdateAgentInput } from "@/domain/types"; +import type { CloudRequest, CloudStreamEvent } from "@/shared/desktop"; + +interface RuntaAgent { id: string; runtime_id: string; name: string; status: string; created_at_unix_seconds: number; updated_at_unix_seconds: number; latest_reply?: { run_id: string; text: string; created_at?: string | null; updated_at?: string | null } | null } +interface RuntaRun { id: string; agent_id: string; status: string; prompt?: string | null; result?: string | null; error?: string | null; dsh_session_id?: string | null; created_at?: string | null; updated_at?: string | null } +interface ModelProvider { id: string; display_name: string; protocol: string; default_model?: string | null } + +const conversationId = (agentId: string) => `conversation-${agentId}`; +const agentIdFromConversation = (id: string) => id.startsWith("conversation-") ? id.slice("conversation-".length) : id; +const iso = (seconds: number) => new Date(seconds * 1000).toISOString(); +const status = (value: string): Agent["status"] => value === "running" ? "idle" : value === "pending" ? "working" : "offline"; +const terminalRunStatuses = new Set(["finished", "failed", "cancelled"]); +const RUN_FALLBACK_REFRESH_MS = 30_000; + +function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } +function toolActivityKind(value: string): ActivityEvent["kind"] { + const normalized = value.toLowerCase(); + if (/browser|web|fetch|url/.test(normalized)) return "browser"; + if (/terminal|command|shell|bash|exec|run/.test(normalized)) return "terminal"; + if (/file|read|write|edit|patch|search|grep|glob/.test(normalized)) return "file"; + if (/agent|task|handoff/.test(normalized)) return "handoff"; + return "status"; +} +function toolActivityTitle(value: string): string { + const normalized = value.toLowerCase(); + if (/read|cat|view/.test(normalized)) return "Reading file"; + if (/write|edit|patch|create/.test(normalized)) return "Editing file"; + if (/search|grep|glob|find/.test(normalized)) return "Searching files"; + if (/terminal|command|shell|bash|exec|run/.test(normalized)) return "Running command"; + if (/browser|web|fetch|url/.test(normalized)) return "Browsing web"; + if (/agent|task|handoff/.test(normalized)) return "Running agent"; + return value.trim() || "Working"; +} +function activityFromToolUpdate(update: Record, conversationIdValue: string): ActivityEvent | undefined { + if (typeof update.sessionUpdate !== "string" || !/tool[_-]?call(?:[_-]?update)?/i.test(update.sessionUpdate)) return undefined; + const id = stringValue(update.toolCallId) ?? stringValue(update.tool_call_id) ?? stringValue(update.id); + if (!id) return undefined; + const rawTitle = stringValue(update.title) ?? stringValue(update.name) ?? stringValue(update.kind) ?? "Working"; + const rawStatus = stringValue(update.status)?.toLowerCase() ?? "running"; + const status: ActivityEvent["status"] = /fail|error/.test(rawStatus) ? "failed" : /complete|finish|done/.test(rawStatus) ? "completed" : "running"; + return { id: `tool:${id}`, conversationId: conversationIdValue, kind: toolActivityKind(`${stringValue(update.kind) ?? ""} ${rawTitle}`), title: toolActivityTitle(rawTitle), detail: rawTitle, status, createdAt: new Date().toISOString() }; +} + +function runMessages(run: RuntaRun, id: string): Message[] { + const createdAt = run.created_at ?? new Date().toISOString(); + const updatedAt = run.updated_at ?? createdAt; + const user = run.prompt ? [{ id: `${run.id}:user`, conversationId: id, role: "user" as const, parts: [{ type: "text" as const, text: run.prompt }], createdAt }] : []; + if (run.status === "failed" || run.status === "cancelled") { + const detail = run.error?.trim() || (run.status === "cancelled" ? "Run cancelled." : "Run failed."); + return [...user, { id: `${run.id}:agent`, conversationId: id, role: "system", parts: [{ type: "text", text: detail }], createdAt: updatedAt }]; + } + return [...user, { id: `${run.id}:agent`, conversationId: id, role: "agent", parts: [{ type: "text", text: run.result ?? "" }], createdAt: updatedAt, streaming: !terminalRunStatuses.has(run.status) }]; +} + +function assistantChunk(event: CloudStreamEvent): { sourceId: string; text: string } | undefined { + if (event.event !== "acp.event" || !event.data || typeof event.data !== "object") return undefined; + const payload = event.data as { params?: { update?: { sessionUpdate?: string; messageId?: string; content?: { text?: string } } } }; + const update = payload.params?.update; + if (update?.sessionUpdate !== "agent_message_chunk" || typeof update.content?.text !== "string" || !update.content.text) return undefined; + return { sourceId: stringValue(update.messageId) ?? "legacy", text: update.content.text }; +} + +async function mapWithConcurrency(values: T[], concurrency: number, mapper: (value: T) => Promise): Promise { + const results = new Array(values.length); + let cursor = 0; + await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, async () => { + while (cursor < values.length) { + const index = cursor++; + results[index] = await mapper(values[index]); + } + })); + return results; +} + +export class RuntaCloudAgentsClient implements CloudAgentsClient { + private readonly conversationRefreshListeners = new Map void>>(); + private async request(request: CloudRequest): Promise { + const bridge = window.runtaCrew?.cloud; + if (!bridge) throw new CrewError("network", "Runta desktop cloud bridge is unavailable", true); + let response; + try { response = await bridge.request(request); } + catch (reason) { + const message = reason instanceof Error ? reason.message : ""; + if (/token is not configured/i.test(message)) throw new CrewError("unauthorized", "Authentication is required"); + throw new CrewError("network", "Runta Cloud Agents is unavailable", true); + } + 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.status < 200 || response.status >= 300) throw new CrewError("unknown", `Cloud Agents request failed (${response.status})`, response.status >= 500); + return response.body as T; + } + + private mapAgent(value: RuntaAgent): Agent { + return { id: value.id, name: value.name, role: "Cloud coding agent", goal: value.name, status: status(value.status), avatar: value.name.slice(0, 1).toUpperCase(), lastActiveAt: value.latest_reply?.updated_at ?? iso(value.updated_at_unix_seconds), unreadCount: 0, computerId: value.runtime_id, lastMessagePreview: value.latest_reply?.text }; + } + + async listModelProviders(_signal?: AbortSignal): Promise { + void _signal; + const response = await this.request<{ model_providers: ModelProvider[] }>({ method: "GET", path: "/v1/model-providers" }); + return response.model_providers.map((provider) => ({ id: provider.id, name: provider.display_name, protocol: provider.protocol, defaultModel: provider.default_model ?? undefined })); + } + + async listAgents(_signal?: AbortSignal) { + void _signal; + const response = await this.request<{ agents: RuntaAgent[] }>({ method: "GET", path: "/v1/agents?limit=250&include_latest_reply=true" }); + return response.agents.map((agent) => this.mapAgent(agent)); + } + async getAgent(agentId: string, _signal?: AbortSignal) { void _signal; return this.mapAgent(await this.request({ method: "GET", path: `/v1/agents/${encodeURIComponent(agentId)}` })); } + async createAgent(input: CreateAgentInput, _signal?: AbortSignal) { + void _signal; + if (!input.modelProviderId) throw new CrewError("contract_pending", "Select a managed model provider before creating a Crew agent"); + const created = await this.request({ method: "POST", path: "/v1/agents", body: { name: input.name, model_provider: { type: "managed", id: input.modelProviderId } } }); + return this.mapAgent(created); + } + async updateAgent(agentId: string, input: UpdateAgentInput, _signal?: AbortSignal): Promise { + void _signal; + if (!input.name || Object.keys(input).some((key) => key !== "name")) throw new CrewError("contract_pending", "Only the Agent name can be updated"); + return this.mapAgent(await this.request({ method: "PATCH", path: `/v1/agents/${encodeURIComponent(agentId)}`, body: { name: input.name } })); + } + async deleteAgent(agentId: string, _signal?: AbortSignal) { void _signal; await this.request({ method: "DELETE", path: `/v1/agents/${encodeURIComponent(agentId)}?delete_runtime=true` }); } + async duplicateAgent(_agentId: string, _signal?: AbortSignal): Promise { void _agentId; void _signal; throw new CrewError("contract_pending", "Agent duplication requires the Cloud Agents duplication contract"); } + async setAgentUnread(agentId: string, _unread: boolean, signal?: AbortSignal) { return this.getAgent(agentId, signal); } + async listConversations(agentId: string, _signal?: AbortSignal) { + void _signal; + const runs = await this.request({ method: "GET", path: `/v1/agents/${encodeURIComponent(agentId)}/runs?limit=100` }); + const latest = runs[0]; + return [{ id: conversationId(agentId), agentId, title: "Agent conversation", updatedAt: latest?.updated_at ?? new Date().toISOString() }]; + } + private replayRunMessages(agentId: string, run: RuntaRun, id: string, signal?: AbortSignal): Promise { + const fallback = runMessages(run, id); + const bridge = window.runtaCrew?.cloud; + if (!bridge?.subscribe || !terminalRunStatuses.has(run.status) || signal?.aborted) return Promise.resolve(fallback); + return new Promise((resolve) => { + let current: Message | undefined; + let sawAssistant = false; + let settled = false; + let unsubscribe: () => void = () => undefined; + const finish = () => { + if (settled) return; + settled = true; + window.clearTimeout(timeout); + signal?.removeEventListener("abort", finish); + unsubscribe(); + if (!sawAssistant) { resolve(fallback); return; } + resolve([ + ...fallback.filter((message) => message.role === "user"), + ...(current ? [current] : []), + ...fallback.filter((message) => message.role === "system"), + ]); + }; + const timeout = window.setTimeout(finish, 10_000); + signal?.addEventListener("abort", finish, { once: true }); + unsubscribe = bridge.subscribe(`/v1/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(run.id)}/events?after=-1`, (event) => { + if (event.event === "stream.closed" || event.event === "error") { finish(); return; } + if (event.event === "acp.event" && event.data && typeof event.data === "object") { + const payload = event.data as { params?: { update?: { sessionUpdate?: string } } }; + if (payload.params?.update?.sessionUpdate === "tool_call") { current = undefined; return; } + } + const chunk = assistantChunk(event); + if (!chunk) return; + sawAssistant = true; + const messageId = `${run.id}:agent:${chunk.sourceId}`; + if (current?.id === messageId) { + const part = current.parts[0]; + if (part?.type === "text") part.text += chunk.text; + return; + } + current = { + id: messageId, + conversationId: id, + role: "agent", + parts: [{ type: "text", text: chunk.text }], + createdAt: run.updated_at ?? run.created_at ?? new Date().toISOString(), + streaming: false, + }; + }); + }); + } + async getConversation(id: string, _signal?: AbortSignal) { + const agentId = agentIdFromConversation(id); + const runs = await this.request({ method: "GET", path: `/v1/agents/${encodeURIComponent(agentId)}/runs?limit=100` }); + const messageGroups = await mapWithConcurrency(runs.slice().reverse(), 8, (run) => this.replayRunMessages(agentId, run, id, _signal)); + const messages = messageGroups.flat(); + return { conversation: { id, agentId, title: "Agent conversation", updatedAt: runs[0]?.updated_at ?? new Date().toISOString() }, messages }; + } + async sendMessage(input: SendMessageInput): Promise { + if (input.attachments?.length) throw new CrewError("contract_pending", "Cloud attachment upload is not available yet"); + const agentId = agentIdFromConversation(input.conversationId); + const run = await this.request({ method: "POST", path: `/v1/agents/${encodeURIComponent(agentId)}/runs`, body: { prompt: input.text } }); + for (const refresh of this.conversationRefreshListeners.get(input.conversationId) ?? []) refresh(); + return { id: `${run.id}:user`, conversationId: input.conversationId, role: "user", parts: [{ type: "text", text: input.text }], createdAt: new Date().toISOString() }; + } + subscribeToConversationEvents(id: string, listener: (event: ConversationEvent) => void): Subscription { + const agentId = agentIdFromConversation(id); + const observed = new Map(); + const streams = new Map void>(); + const assistantStreams = new Map }>(); + const bridge = window.runtaCrew?.cloud; + const subscribeToRun = (run: RuntaRun) => { + if (!bridge?.subscribe || streams.has(run.id) || terminalRunStatuses.has(run.status)) return; + let terminal = false; + const assistant = { seen: new Set() } as { current?: string; seen: Set }; + assistantStreams.set(run.id, assistant); + const completeCurrentAssistant = (notify = false) => { + if (!assistant.current) return; + listener({ type: "message.completed", messageId: assistant.current, notify }); + assistant.current = undefined; + }; + const unsubscribe = bridge.subscribe(`/v1/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(run.id)}/events?after=-1`, (event: CloudStreamEvent) => { + if (event.event === "run.status" && event.data && typeof event.data === "object") { + const data = event.data as Partial & { session_id?: string | null }; + if (typeof data.status !== "string") return; + const next: RuntaRun = { ...run, ...data, prompt: data.prompt ?? run.prompt, dsh_session_id: data.session_id ?? data.dsh_session_id ?? run.dsh_session_id }; + terminal = terminalRunStatuses.has(next.status); + if (terminal) completeCurrentAssistant(true); + if (assistant.seen.size === 0) { + const message = runMessages(next, id).find((candidate) => candidate.id === `${run.id}:agent`); + if (message) listener({ type: "message.updated", message }); + if (terminal) listener({ type: "message.completed", messageId: `${run.id}:agent` }); + } + return; + } + if (event.event !== "acp.event" || terminal || !event.data || typeof event.data !== "object") return; + const payload = event.data as { params?: { update?: Record & { sessionUpdate?: string; messageId?: string; content?: { text?: string } } } }; + const update = payload.params?.update; + const chunk = assistantChunk(event); + if (chunk) { + const sourceId = chunk.sourceId; + const messageId = `${run.id}:agent:${sourceId}`; + if (!assistant.seen.has(messageId)) { + completeCurrentAssistant(); + assistant.current = messageId; + assistant.seen.add(messageId); + listener({ type: "message.created", message: { id: messageId, conversationId: id, role: "agent", parts: [{ type: "text", text: chunk.text }], createdAt: new Date().toISOString(), streaming: true } }); + } else { + assistant.current = messageId; + listener({ type: "message.delta", messageId, delta: chunk.text }); + } + } + if (update?.sessionUpdate === "tool_call") completeCurrentAssistant(); + const activity = update ? activityFromToolUpdate(update, id) : undefined; + if (activity) listener({ type: "activity.updated", activity }); + }); + streams.set(run.id, unsubscribe); + }; + let polling = false; let pollAgain = false; + const poll = async () => { + if (polling) { pollAgain = true; return; } + polling = true; + try { + const runs = await this.request({ method: "GET", path: `/v1/agents/${encodeURIComponent(agentId)}/runs?limit=100` }); + for (const run of runs.slice().reverse()) { + const signature = `${run.status}\u0000${run.result ?? ""}\u0000${run.error ?? ""}`; + const previous = observed.get(run.id); observed.set(run.id, signature); + subscribeToRun(run); + if (previous === undefined) { + for (const message of runMessages(run, id)) listener({ type: "message.created", message }); + } else if (previous !== signature && (assistantStreams.get(run.id)?.seen.size ?? 0) === 0) { + const agentMessage = runMessages(run, id).find((message) => message.id === `${run.id}:agent`); + if (agentMessage) listener({ type: "message.updated", message: agentMessage }); + } + if (previous !== signature && terminalRunStatuses.has(run.status) && (assistantStreams.get(run.id)?.seen.size ?? 0) === 0) listener({ type: "message.completed", messageId: `${run.id}:agent` }); + } + listener({ type: "connection.changed", state: "connected" }); + } catch { listener({ type: "connection.changed", state: "error" }); } + finally { polling = false; if (pollAgain) { pollAgain = false; void poll(); } } + }; + const refreshWhenActive = () => { if (document.visibilityState !== "hidden" && navigator.onLine) void poll(); }; + const onVisibilityChange = () => { if (document.visibilityState === "visible") refreshWhenActive(); }; + const refreshListeners = this.conversationRefreshListeners.get(id) ?? new Set<() => void>(); refreshListeners.add(refreshWhenActive); this.conversationRefreshListeners.set(id, refreshListeners); + void poll(); const timer = window.setInterval(refreshWhenActive, RUN_FALLBACK_REFRESH_MS); + window.addEventListener("focus", refreshWhenActive); window.addEventListener("online", refreshWhenActive); document.addEventListener("visibilitychange", onVisibilityChange); + return { unsubscribe: () => { window.clearInterval(timer); window.removeEventListener("focus", refreshWhenActive); window.removeEventListener("online", refreshWhenActive); document.removeEventListener("visibilitychange", onVisibilityChange); refreshListeners.delete(refreshWhenActive); if (refreshListeners.size === 0) this.conversationRefreshListeners.delete(id); for (const unsubscribe of streams.values()) unsubscribe(); streams.clear(); assistantStreams.clear(); } }; + } + async listApprovalRequests(_agentId?: string, _signal?: AbortSignal): Promise { void _agentId; void _signal; return []; } + async respondToApproval(_input: RespondApprovalInput, _signal?: AbortSignal): Promise { void _input; void _signal; throw new CrewError("contract_pending", "ACP approvals require the Cloud Agents approval contract"); } + async getComputer(agentId: string, signal?: AbortSignal): Promise { const agent = await this.getAgent(agentId, signal); return { id: agent.computerId, agentId, runtimeName: agent.computerId, status: agent.status === "offline" ? "offline" : "online", capabilities: ["open", "takeover"] }; } + async openComputer(_agentId: string, _signal?: AbortSignal): Promise<{ url: string; mode: "remote" }> { void _agentId; void _signal; throw new CrewError("contract_pending", "Computer sessions are outside the current Crew scope"); } + async takeOverComputer(agentId: string, signal?: AbortSignal) { return this.openComputer(agentId, signal); } + async reconnect(signal?: AbortSignal) { await this.listAgents(signal); } + getActivities(_conversationId: string) { void _conversationId; return []; } +} diff --git a/src/clients/mock/MockCloudAgentsClient.test.ts b/src/clients/mock/MockCloudAgentsClient.test.ts deleted file mode 100644 index 33f1267..0000000 --- a/src/clients/mock/MockCloudAgentsClient.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { MockCloudAgentsClient } from "./MockCloudAgentsClient"; - -describe("MockCloudAgentsClient", () => { - it("creates an isolated agent, conversation, and computer", async () => { - const client = new MockCloudAgentsClient(); - const agent = await client.createAgent({ name: "Scout", role: "Researcher", goal: "Track customer signals" }); - expect(agent.name).toBe("Scout"); - expect((await client.listConversations(agent.id))[0]?.agentId).toBe(agent.id); - expect((await client.getComputer(agent.id)).previewKind).toBe("mock"); - }); - - it("streams a response through conversation events", async () => { - vi.useFakeTimers(); const client = new MockCloudAgentsClient(); const events: string[] = []; - client.subscribeToConversationEvents("conversation-atlas", (event) => events.push(event.type)); - const promise = client.sendMessage({ conversationId: "conversation-atlas", text: "Start" }); - await vi.advanceTimersByTimeAsync(2200); await promise; - expect(events).toContain("message.created"); expect(events).toContain("message.delta"); expect(events).toContain("message.completed"); - vi.useRealTimers(); - }); - - it("records the explicit approval decision and note", async () => { - const client = new 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 deleted file mode 100644 index c4b9c85..0000000 --- a/src/clients/mock/MockCloudAgentsClient.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { CloudAgentsClient } from "@/domain/CloudAgentsClient"; -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) => { - const timer = window.setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { window.clearTimeout(timer); reject(new DOMException("Aborted", "AbortError")); }, { once: true }); -}); -const copy = (value: T): T => structuredClone(value); - -export class MockCloudAgentsClient implements CloudAgentsClient { - private agents = copy(agentsFixture); private conversations = copy(conversationsFixture); private messages = copy(messagesFixture); - private approvals = copy(approvalsFixture); private computers = copy(computersFixture); - private listeners = new Map void>>(); - connectionState: "connected" | "disconnected" = "connected"; - - private emit(conversationId: string, event: ConversationEvent) { this.listeners.get(conversationId)?.forEach((listener) => listener(copy(event))); } - async listAgents(signal?: AbortSignal) { await wait(120, signal); return copy(this.agents); } - async getAgent(agentId: string, signal?: AbortSignal) { await wait(60, signal); const agent = this.agents.find((item) => item.id === agentId); if (!agent) throw new CrewError("not_found", "Agent not found"); return copy(agent); } - async createAgent(input: CreateAgentInput, signal?: AbortSignal) { - await wait(240, signal); const id = `${input.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${Date.now()}`; - const agent: Agent = { id, ...input, status: "idle", avatar: input.name.slice(0, 1).toUpperCase(), lastActiveAt: new Date().toISOString(), unreadCount: 0, computerId: `computer-${id}` }; - this.agents.unshift(agent); this.conversations.push({ id: `conversation-${id}`, agentId: id, title: input.goal, updatedAt: agent.lastActiveAt }); - 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 }, ...(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); - const chunks = ["I’m on it. ", "I’ll work in the cloud computer ", "and keep you updated here."]; - chunks.forEach((delta, index) => window.setTimeout(() => { - const part = reply.parts[0]; if (part?.type === "text") part.text += delta; - this.emit(input.conversationId, { type: "message.delta", messageId: reply.id, delta }); - if (index === chunks.length - 1) { reply.streaming = false; this.emit(input.conversationId, { type: "message.completed", messageId: reply.id }); } - }, 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); } - async getComputer(agentId: string, signal?: AbortSignal) { await wait(80, signal); const computer = this.computers.find((item) => item.agentId === agentId); if (!computer) throw new CrewError("not_found", "Computer not found"); return copy(computer); } - async openComputer(agentId: string, signal?: AbortSignal) { await this.getComputer(agentId, signal); return { mode: "mock" as const }; } - async takeOverComputer(agentId: string, signal?: AbortSignal) { await this.getComputer(agentId, signal); return { mode: "mock" as const }; } - async reconnect(signal?: AbortSignal) { this.connectionState = "disconnected"; await wait(500, signal); this.connectionState = "connected"; } - getActivities(conversationId: string) { return copy(activitiesFixture.filter((item) => item.conversationId === conversationId)); } -} diff --git a/src/clients/mock/fixtures.ts b/src/clients/mock/fixtures.ts deleted file mode 100644 index e7edf66..0000000 --- a/src/clients/mock/fixtures.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ActivityEvent, Agent, ApprovalRequest, CloudComputer, Conversation, Message } from "@/domain/types"; - -const now = new Date("2026-08-26T09:42:00+08:00").toISOString(); -export const agentsFixture: Agent[] = [ - { id: "atlas", name: "Atlas", role: "Product researcher", goal: "Turn customer signals into clear product decisions.", status: "working", avatar: "A", lastActiveAt: now, unreadCount: 2, computerId: "computer-atlas" }, - { id: "mira", name: "Mira", role: "Operations lead", goal: "Keep recurring operations moving and surface exceptions.", status: "waiting_for_approval", avatar: "M", lastActiveAt: now, unreadCount: 1, computerId: "computer-mira" }, - { id: "patch", name: "Patch", role: "Software engineer", goal: "Implement, test, and ship scoped engineering work.", status: "idle", avatar: "P", lastActiveAt: now, unreadCount: 0, computerId: "computer-patch" }, - { id: "lumen", name: "Lumen", role: "Growth analyst", goal: "Find and explain high-leverage growth opportunities.", status: "offline", avatar: "L", lastActiveAt: "2026-08-25T22:15:00+08:00", unreadCount: 0, computerId: "computer-lumen" }, -]; -export const conversationsFixture: Conversation[] = agentsFixture.map((agent) => ({ id: `conversation-${agent.id}`, agentId: agent.id, title: agent.goal, updatedAt: agent.lastActiveAt })); -export const messagesFixture: Message[] = [ - { id: "m1", conversationId: "conversation-atlas", role: "user", parts: [{ type: "text", text: "Review this week's customer feedback and tell me what we should prioritize." }], createdAt: "2026-08-26T09:34:00+08:00" }, - { id: "m2", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "I’m grouping the feedback by job-to-be-done, frequency, and revenue impact. I’ll return with the strongest pattern and the evidence behind it." }, { type: "activity", activityId: "activity-1" }], createdAt: "2026-08-26T09:35:00+08:00" }, - { id: "m3", conversationId: "conversation-mira", role: "agent", parts: [{ type: "text", text: "The vendor portal requires permission before I submit the renewal form." }], createdAt: now }, - { id: "m4", conversationId: "conversation-patch", role: "agent", parts: [{ type: "text", text: "The release branch is clean and the test suite is green. What should I work on next?" }], createdAt: now }, -]; -export const activitiesFixture: ActivityEvent[] = [ - { id: "activity-1", conversationId: "conversation-atlas", kind: "browser", title: "Reviewing feedback workspace", detail: "Reading 24 tagged conversations in Linear", status: "completed", createdAt: "2026-08-26T09:36:00+08:00" }, - { id: "activity-2", conversationId: "conversation-atlas", kind: "file", title: "Building evidence table", detail: "Grouping feedback by theme and customer segment", status: "running", createdAt: "2026-08-26T09:39:00+08:00" }, -]; -export const approvalsFixture: ApprovalRequest[] = [{ id: "approval-1", agentId: "mira", conversationId: "conversation-mira", title: "Submit vendor renewal", description: "Mira wants to submit the renewal form to Acme Hosting.", scope: ["Submit one form", "Use the saved Acme Hosting session", "No payment will be made"], status: "pending", createdAt: now }]; -export const computersFixture: CloudComputer[] = agentsFixture.map((agent) => ({ id: agent.computerId, agentId: agent.id, runtimeName: `crew-${agent.name.toLowerCase()}`, status: agent.status === "offline" ? "offline" : "online", activeApp: agent.id === "atlas" ? "Linear · Browser" : agent.id === "mira" ? "Acme Hosting · Browser" : "Terminal", previewKind: "mock", capabilities: ["open", "takeover"] })); diff --git a/src/domain/CloudAgentsClient.ts b/src/domain/CloudAgentsClient.ts index 30cf97a..b5474ab 100644 --- a/src/domain/CloudAgentsClient.ts +++ b/src/domain/CloudAgentsClient.ts @@ -1,6 +1,7 @@ -import type { Agent, ApprovalRequest, CloudComputer, Conversation, ConversationEvent, CreateAgentInput, Message, ReactToMessageInput, RespondApprovalInput, SendMessageInput, Subscription, UpdateAgentInput } from "./types"; +import type { Agent, ApprovalRequest, CloudComputer, Conversation, ConversationEvent, CreateAgentInput, Message, ModelProviderOption, RespondApprovalInput, SendMessageInput, Subscription, UpdateAgentInput } from "./types"; export interface CloudAgentsClient { + listModelProviders(signal?: AbortSignal): Promise; listAgents(signal?: AbortSignal): Promise; getAgent(agentId: string, signal?: AbortSignal): Promise; createAgent(input: CreateAgentInput, signal?: AbortSignal): Promise; @@ -11,12 +12,11 @@ export interface CloudAgentsClient { 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; getComputer(agentId: string, signal?: AbortSignal): Promise; - openComputer(agentId: string, signal?: AbortSignal): Promise<{ url?: string; mode: "mock" | "remote" }>; - takeOverComputer(agentId: string, signal?: AbortSignal): Promise<{ url?: string; mode: "mock" | "remote" }>; + openComputer(agentId: string, signal?: AbortSignal): Promise<{ url: string; mode: "remote" }>; + takeOverComputer(agentId: string, signal?: AbortSignal): Promise<{ url: string; mode: "remote" }>; reconnect(signal?: AbortSignal): Promise; } diff --git a/src/domain/agentName.ts b/src/domain/agentName.ts new file mode 100644 index 0000000..380e6c6 --- /dev/null +++ b/src/domain/agentName.ts @@ -0,0 +1,24 @@ +const SINGLE_NAMES = [ + "Atlas", "Scout", "Mira", "Lumen", "Nova", "Echo", "Sage", "Orbit", + "Ember", "Cedar", "Pixel", "Quest", "Vale", "Rune", "Iris", "Patch", +] as const; + +const FIRST_WORDS = ["Amber", "Bright", "Calm", "Clever", "Golden", "Hidden", "Kind", "Quiet", "Silver", "Swift", "True", "Wild"] as const; +const SECOND_WORDS = ["Brook", "Cedar", "Comet", "Field", "Harbor", "Meadow", "Orbit", "Pine", "River", "Sparrow", "Vale", "Willow"] as const; + +export function nextAgentName(existingNames: Iterable): string { + const used = new Set(Array.from(existingNames, (name) => name.trim().toLocaleLowerCase()).filter(Boolean)); + const availableSingle = SINGLE_NAMES.find((name) => !used.has(name.toLocaleLowerCase())); + if (availableSingle) return availableSingle; + + for (const first of FIRST_WORDS) { + for (const second of SECOND_WORDS) { + const candidate = `${first} ${second}`; + if (!used.has(candidate.toLocaleLowerCase())) return candidate; + } + } + + let suffix = 2; + while (used.has(`wild harbor ${suffix}`)) suffix += 1; + return `Wild Harbor ${suffix}`; +} diff --git a/src/domain/types.ts b/src/domain/types.ts index 9db93a4..0079783 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -1,33 +1,31 @@ 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; pinned?: boolean; + avatar: string; lastActiveAt: string; unreadCount: number; computerId: string; pinned?: boolean; lastMessagePreview?: string; } export interface TextPart { type: "text"; text: string } export interface ActivityPart { type: "activity"; activityId: string } 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 Message { id: string; conversationId: string; role: MessageRole; parts: MessagePart[]; createdAt: string; streaming?: boolean } 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 CloudComputer { id: string; agentId: string; runtimeName: string; status: "online" | "starting" | "offline"; activeApp?: string; previewUrl?: string; capabilities: Array<"open" | "takeover"> } +export interface ModelProviderOption { id: string; name: string; protocol: string; defaultModel?: string } +export interface CreateAgentInput { name: string; modelProviderId: string } 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.completed"; messageId: string; notify?: boolean } | { type: "message.updated"; message: Message } | { type: "activity.updated"; activity: ActivityEvent } | { type: "approval.updated"; approval: ApprovalRequest } diff --git a/src/main.tsx b/src/main.tsx index 11bbadd..9167834 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./ui/App"; +import "streamdown/styles.css"; import "./ui/styles.css"; createRoot(document.getElementById("root")!).render(); diff --git a/src/shared/desktop.ts b/src/shared/desktop.ts index 555ccfd..400d837 100644 --- a/src/shared/desktop.ts +++ b/src/shared/desktop.ts @@ -1,13 +1,20 @@ export type ThemePreference = "light" | "dark" | "system"; -export type AppSettings = { endpoint: string; theme: ThemePreference; notifications: boolean }; +export type AppSettings = { endpoint: string; dashboardUrl?: string; theme: ThemePreference; notifications: boolean; modelProviderId?: string }; export interface SelectedAttachment { id: string; name: string; size: number; mediaType: string } export interface DesktopNotification { title: string; body: string } +export interface CloudRequest { method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; path: string; body?: unknown } +export interface CloudResponse { status: number; body?: unknown } +export interface CloudStreamEvent { event: string; id?: string; data?: unknown } +export interface DeviceAuthorizationStart { verificationUrl: string; userCode: string; expiresAt: string } +export type DeviceAuthorizationStatus = "idle" | "pending" | "authorized" | "denied" | "expired" | "error"; export interface DesktopBridge { getVersion(): Promise; openExternal(url: string): Promise; settings: { get(): Promise; set(settings: AppSettings): Promise }; credentials: { has(): Promise; set(token: string | null): Promise }; + auth?: { start(): Promise; status(): Promise; logout(): Promise }; + cloud?: { request(request: CloudRequest): Promise; subscribe(path: string, listener: (event: CloudStreamEvent) => void): () => void }; 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.test.tsx b/src/state/useCrewController.test.tsx new file mode 100644 index 0000000..31f8b42 --- /dev/null +++ b/src/state/useCrewController.test.tsx @@ -0,0 +1,180 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { CloudAgentsClient } from "@/domain/CloudAgentsClient"; +import type { Agent, ConversationEvent, Message } from "@/domain/types"; +import { useCrewController } from "./useCrewController"; + +const agent = (id: string, name: string): Agent => ({ id, name, role: "Cloud coding agent", goal: name, status: "idle", avatar: name[0]!, lastActiveAt: new Date(0).toISOString(), unreadCount: 0, computerId: id }); + +describe("useCrewController", () => { + it("does not start Cloud Agents requests until authentication is enabled", async () => { + const listAgents = vi.fn(); const listModelProviders = vi.fn(); + const client = { listAgents, listModelProviders } as unknown as CloudAgentsClient; + const { result } = renderHook(() => useCrewController(client, false)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(listAgents).not.toHaveBeenCalled(); + expect(listModelProviders).not.toHaveBeenCalled(); + expect(result.current.connection).toBe("disconnected"); + }); + + it("never lets the previous agent overwrite a newly selected conversation", async () => { + let resolveAtlas!: (value: { conversation: { id: string; agentId: string; title: string; updatedAt: string }; messages: Message[] }) => void; + const atlasConversation = new Promise[0]>((resolve) => { resolveAtlas = resolve; }); + const listeners = new Map void>(); + const client: CloudAgentsClient = { + listModelProviders: async () => [], listAgents: async () => [agent("atlas", "Atlas"), agent("scout", "Scout")], + getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), + listConversations: async (id) => [{ id: `conversation-${id}`, agentId: id, title: id, updatedAt: new Date(0).toISOString() }], + getConversation: async (id) => id === "conversation-atlas" ? atlasConversation : { conversation: { id, agentId: "scout", title: "Scout", updatedAt: new Date(0).toISOString() }, messages: [] }, + sendMessage: async (input) => ({ id: "sent", conversationId: input.conversationId, role: "user", parts: [{ type: "text", text: input.text }], createdAt: new Date(0).toISOString() }), + subscribeToConversationEvents: (id, listener) => { listeners.set(id, listener); return { unsubscribe: () => undefined }; }, + listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, + getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, + }; + const { result } = renderHook(() => useCrewController(client)); + await waitFor(() => expect(result.current.selectedAgentId).toBe("atlas")); + act(() => result.current.setSelectedAgentId("scout")); + await waitFor(() => expect(result.current.selectedAgentId).toBe("scout")); + expect(result.current.messages).toEqual([]); + + act(() => listeners.get("conversation-atlas")?.({ type: "message.created", message: { id: "late", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "Atlas reply" }], createdAt: new Date(0).toISOString() } })); + act(() => resolveAtlas({ conversation: { id: "conversation-atlas", agentId: "atlas", title: "Atlas", updatedAt: new Date(0).toISOString() }, messages: [{ id: "late-fetch", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "Atlas fetch" }], createdAt: new Date(0).toISOString() }] })); + await act(async () => { await Promise.resolve(); }); + expect(result.current.messages).toEqual([]); + }); + + it("restores a fresh per-agent snapshot without fetching the conversation again", async () => { + const getConversation = vi.fn(async (id: string) => ({ conversation: { id, agentId: id.endsWith("atlas") ? "atlas" : "scout", title: id, updatedAt: new Date(0).toISOString() }, messages: [{ id: `${id}-message`, conversationId: id, role: "agent" as const, parts: [{ type: "text" as const, text: id.endsWith("atlas") ? "Atlas cached" : "Scout cached" }], createdAt: new Date(0).toISOString() }] })); + const client: CloudAgentsClient = { + listModelProviders: async () => [], listAgents: async () => [agent("atlas", "Atlas"), agent("scout", "Scout")], + getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), + listConversations: async (id) => [{ id: `conversation-${id}`, agentId: id, title: id, updatedAt: new Date(0).toISOString() }], getConversation, + sendMessage: async (input) => ({ id: "sent", conversationId: input.conversationId, role: "user", parts: [{ type: "text", text: input.text }], createdAt: new Date(0).toISOString() }), subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, + }; + const { result } = renderHook(() => useCrewController(client)); + await waitFor(() => expect(result.current.messages[0]?.parts[0]).toEqual({ type: "text", text: "Atlas cached" })); + act(() => result.current.setSelectedAgentId("scout")); + await waitFor(() => expect(result.current.messages[0]?.parts[0]).toEqual({ type: "text", text: "Scout cached" })); + act(() => result.current.setSelectedAgentId("atlas")); + await waitFor(() => expect(result.current.messages[0]?.parts[0]).toEqual({ type: "text", text: "Atlas cached" })); + expect(getConversation.mock.calls.map(([id]) => id)).toEqual(["conversation-atlas", "conversation-scout"]); + }); + + it("optimistically renders the user message and working agent before the request resolves", async () => { + let resolveSend!: (message: Message) => void; + const pendingSend = new Promise((resolve) => { resolveSend = resolve; }); + const sendMessage = vi.fn(async () => pendingSend); + const listeners = new Map void>(); + const client: CloudAgentsClient = { + listModelProviders: async () => [], listAgents: async () => [{ ...agent("atlas", "Atlas"), lastMessagePreview: "Old server reply" }], + getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), + listConversations: async (id) => [{ id: `conversation-${id}`, agentId: id, title: id, updatedAt: new Date(0).toISOString() }], getConversation: async (id) => ({ conversation: { id, agentId: "atlas", title: "Atlas", updatedAt: new Date(0).toISOString() }, messages: [] }), + sendMessage, subscribeToConversationEvents: (id, listener) => { listeners.set(id, listener); return { unsubscribe: () => undefined }; }, listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, + }; + const { result } = renderHook(() => useCrewController(client)); + await waitFor(() => expect(result.current.selectedAgentId).toBe("atlas")); + await waitFor(() => expect(listeners.has("conversation-atlas")).toBe(true)); + let send!: Promise; + act(() => { send = result.current.sendMessage("hello"); }); + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[0]).toMatchObject({ role: "user", parts: [{ type: "text", text: "hello" }] }); + expect(result.current.messages[1]).toMatchObject({ role: "agent", streaming: true }); + const visualIds = result.current.messages.map((message) => message.id); + await act(async () => { await result.current.sendMessage("hello"); }); + expect(sendMessage).toHaveBeenCalledTimes(1); + + act(() => listeners.get("conversation-atlas")?.({ type: "message.created", message: { id: "run-1:user", conversationId: "conversation-atlas", role: "user", parts: [{ type: "text", text: "hello" }], createdAt: new Date(0).toISOString() } })); + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages.map((message) => message.id)).toEqual(visualIds); + + await act(async () => { resolveSend({ id: "run-1:user", conversationId: "conversation-atlas", role: "user", parts: [{ type: "text", text: "hello" }], createdAt: new Date(0).toISOString() }); await send; }); + expect(result.current.messages.map((message) => message.id)).toEqual(visualIds); + act(() => listeners.get("conversation-atlas")?.({ type: "message.created", message: { id: "run-1:agent", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "Hi" }], createdAt: new Date(0).toISOString(), streaming: true } })); + expect(result.current.messages.map((message) => message.id)).toEqual(visualIds); + expect(result.current.messages[1]?.parts).toEqual([{ type: "text", text: "Hi" }]); + await act(async () => { await result.current.reconnect(); }); + expect(result.current.agents[0]?.lastMessagePreview).toBe("Old server reply"); + act(() => listeners.get("conversation-atlas")?.({ type: "message.completed", messageId: "run-1:agent", notify: false })); + expect(result.current.messages).toHaveLength(1); + act(() => listeners.get("conversation-atlas")?.({ type: "message.delta", messageId: "run-1:agent", delta: "Final answer after the tool" })); + expect(result.current.messages[1]).toMatchObject({ id: visualIds[1], parts: [{ type: "text", text: "Final answer after the tool" }], streaming: true }); + expect(result.current.agents[0]?.lastMessagePreview).toBe("Old server reply"); + act(() => listeners.get("conversation-atlas")?.({ type: "message.completed", messageId: "run-1:agent", notify: true })); + expect(result.current.messages[1]).toMatchObject({ streaming: false }); + expect(result.current.agents[0]?.lastMessagePreview).toBe("Final answer after the tool"); + act(() => listeners.get("conversation-atlas")?.({ type: "message.created", message: { id: "run-1:agent:second", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "Final answer" }], createdAt: new Date(1).toISOString(), streaming: true } })); + expect(result.current.messages).toHaveLength(3); + expect(result.current.messages[2]).toMatchObject({ id: "run-1:agent:second", parts: [{ type: "text", text: "Final answer" }], streaming: true }); + }); + + it("treats a newly created agent as a known empty conversation", async () => { + let created = false; + const client: CloudAgentsClient = { + listModelProviders: async () => [], listAgents: async () => created ? [agent("new-agent", "Atlas")] : [], + getAgent: async (id) => agent(id, id), createAgent: async (input) => { created = true; return agent("new-agent", input.name); }, updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), + listConversations: async () => [], getConversation: async () => { throw new Error("unused"); }, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, + }; + const { result } = renderHook(() => useCrewController(client)); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { await result.current.createAgent({ name: "Atlas", modelProviderId: "provider" }); }); + expect(result.current.selectedAgentId).toBe("new-agent"); + expect(result.current.conversationLoading).toBe(false); + expect(result.current.messages).toEqual([]); + }); + + it("removes a deleted agent immediately and rolls back when deletion fails", async () => { + let rejectDelete!: (reason: Error) => void; + const pendingDelete = new Promise((_resolve, reject) => { rejectDelete = reject; }); + const client: CloudAgentsClient = { + listModelProviders: async () => [], listAgents: async () => [agent("atlas", "Atlas"), agent("scout", "Scout")], + getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => pendingDelete, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), + listConversations: async () => [], getConversation: async () => { throw new Error("unused"); }, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, + }; + const { result } = renderHook(() => useCrewController(client)); + await waitFor(() => expect(result.current.selectedAgentId).toBe("atlas")); + let deletion!: Promise; + act(() => { deletion = result.current.deleteAgent("atlas"); }); + expect(result.current.agents.map((item) => item.id)).toEqual(["scout"]); + expect(result.current.selectedAgentId).toBe("scout"); + rejectDelete(new Error("Delete failed")); + await act(async () => { await expect(deletion).rejects.toThrow("Delete failed"); }); + expect(result.current.agents.map((item) => item.id)).toEqual(["atlas", "scout"]); + expect(result.current.selectedAgentId).toBe("scout"); + expect(result.current.error).toBe("Delete failed"); + }); + + it("keeps an accepted deletion tombstoned until the server list confirms removal", async () => { + let serverAgents = [agent("atlas", "Atlas"), agent("scout", "Scout")]; + const client: CloudAgentsClient = { + listModelProviders: async () => [], listAgents: async () => serverAgents, + getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), + listConversations: async () => [], getConversation: async () => { throw new Error("unused"); }, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, + }; + const { result } = renderHook(() => useCrewController(client)); + await waitFor(() => expect(result.current.selectedAgentId).toBe("atlas")); + await act(async () => { await result.current.deleteAgent("atlas"); }); + expect(result.current.agents.map((item) => item.id)).toEqual(["scout"]); + await act(async () => { await result.current.reconnect(); }); + expect(result.current.agents.map((item) => item.id)).toEqual(["scout"]); + serverAgents = [agent("scout", "Scout")]; + await act(async () => { await result.current.reconnect(); }); + expect(result.current.agents.map((item) => item.id)).toEqual(["scout"]); + }); + + it("revalidates the selected conversation when the Agent preview is newer than its snapshot", async () => { + const server = { latestReply: undefined as string | undefined }; + const getConversation = vi.fn(async (id: string) => ({ conversation: { id, agentId: "atlas", title: "Atlas", updatedAt: new Date().toISOString() }, messages: server.latestReply ? [{ id: "reply", conversationId: id, role: "agent" as const, parts: [{ type: "text" as const, text: server.latestReply }], createdAt: new Date().toISOString() }] : [] })); + const client: CloudAgentsClient = { + listModelProviders: async () => [], listAgents: async () => [{ ...agent("atlas", "Atlas"), lastMessagePreview: server.latestReply }], + getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), + listConversations: async (id) => [{ id: `conversation-${id}`, agentId: id, title: id, updatedAt: new Date().toISOString() }], getConversation, sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: () => ({ unsubscribe: () => undefined }), listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test" }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test" }), reconnect: async () => undefined, + }; + const { result } = renderHook(() => useCrewController(client)); + await waitFor(() => expect(result.current.selectedAgentId).toBe("atlas")); + await waitFor(() => expect(getConversation).toHaveBeenCalledTimes(1)); + server.latestReply = "Server reply"; + await act(async () => { await result.current.reconnect(); }); + await waitFor(() => expect(result.current.messages[0]?.parts[0]).toEqual({ type: "text", text: "Server reply" })); + expect(getConversation).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/state/useCrewController.ts b/src/state/useCrewController.ts index 5e0a59d..a5018d0 100644 --- a/src/state/useCrewController.ts +++ b/src/state/useCrewController.ts @@ -1,48 +1,222 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CloudAgentsClient } from "@/domain/CloudAgentsClient"; -import type { Agent, ApprovalRequest, Attachment, CloudComputer, ConnectionState, ConversationEvent, CreateAgentInput, Message, ReactionKind, UpdateAgentInput } from "@/domain/types"; +import { CrewError, type ActivityEvent, type Agent, type ApprovalRequest, type Attachment, type CloudComputer, type ConnectionState, type ConversationEvent, type CreateAgentInput, type Message, type ModelProviderOption, type UpdateAgentInput } from "@/domain/types"; -export function useCrewController(client: CloudAgentsClient) { +function agentMessagePreview(message?: Message): string { + if (!message || message.role !== "agent") return ""; + return message.parts.filter((part) => part.type === "text").map((part) => part.text).join("").trim(); +} + +function latestCompletedAgentPreview(messages: Message[]): string { + return agentMessagePreview([...messages].reverse().find((message) => message.role === "agent" && !message.streaming)); +} + +function messageText(message: Message): string { + return message.parts.filter((part) => part.type === "text").map((part) => part.text).join(""); +} + +interface AgentSnapshot { messages: Message[]; approvals: ApprovalRequest[]; computer?: CloudComputer; cachedAt: number } +const SNAPSHOT_TTL_MS = 60_000; +const AGENT_FALLBACK_REFRESH_MS = 30_000; +const isTransientGatewayError = (message?: string) => /^Cloud Agents request failed \((?:502|503|504)\)$/.test(message ?? ""); +const OPTIMISTIC_USER_PREFIX = "optimistic-user:"; +const OPTIMISTIC_AGENT_PREFIX = "optimistic-agent:"; + +export function useCrewController(client: CloudAgentsClient, enabled = true) { const [agents, setAgents] = useState([]); const [selectedAgentId, setSelectedAgentId] = useState(""); - const [messages, setMessages] = useState([]); const [approvals, setApprovals] = useState([]); + const [messages, setMessages] = useState([]); const [activities, setActivities] = useState([]); const [approvals, setApprovals] = useState([]); const [computer, setComputer] = useState(); const [connection, setConnection] = useState("connecting"); - const [loading, setLoading] = useState(true); const [error, setError] = useState(); + const [modelProviders, setModelProviders] = useState([]); + const [loading, setLoading] = useState(enabled); const [error, setError] = useState(); + const [loadedAgentIds, setLoadedAgentIds] = useState>(() => new Set()); + const [liveAgentId, setLiveAgentId] = useState(""); const [revalidateVersion, setRevalidateVersion] = useState(0); + const snapshots = useRef(new Map()); const selectedAgentIdRef = useRef(selectedAgentId); const deletingAgentIds = useRef(new Set()); const sendingAgentIds = useRef(new Set()); const visualMessageIds = useRef(new Map()); const selectedAgent = useMemo(() => agents.find((agent) => agent.id === selectedAgentId), [agents, selectedAgentId]); const conversationId = selectedAgentId ? `conversation-${selectedAgentId}` : ""; + const conversationLoading = Boolean(selectedAgentId && !loadedAgentIds.has(selectedAgentId)); + useEffect(() => { selectedAgentIdRef.current = selectedAgentId; }, [selectedAgentId]); - 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]); + const refreshAgents = useCallback(async () => { + const fetched = await client.listAgents(); + for (const agentId of deletingAgentIds.current) if (!fetched.some((agent) => agent.id === agentId)) deletingAgentIds.current.delete(agentId); + const next = fetched.filter((agent) => !deletingAgentIds.current.has(agent.id)); + const selectedId = selectedAgentIdRef.current; const selected = next.find((agent) => agent.id === selectedId); const snapshot = snapshots.current.get(selectedId); + if (selected?.lastMessagePreview && snapshot && !snapshot.messages.some((message) => message.streaming)) { + const cachedPreview = latestCompletedAgentPreview(snapshot.messages); + if (cachedPreview !== selected.lastMessagePreview) { snapshots.current.set(selectedId, { ...snapshot, cachedAt: 0 }); setRevalidateVersion((value) => value + 1); } + } + const selectedIsStreaming = Boolean(snapshot?.messages.some((message) => message.streaming)); + setAgents((current) => next.map((agent) => { + const existing = current.find((item) => item.id === agent.id); + const localPreview = latestCompletedAgentPreview(snapshots.current.get(agent.id)?.messages ?? []); + const preserveLocalPreview = Boolean(localPreview) || (agent.id === selectedId && selectedIsStreaming); + return { ...agent, lastMessagePreview: preserveLocalPreview ? localPreview || existing?.lastMessagePreview : agent.lastMessagePreview ?? existing?.lastMessagePreview }; + })); + setSelectedAgentId((current) => current && next.some((agent) => agent.id === current) ? current : next[0]?.id || ""); return next; + }, [client]); + useEffect(() => { + if (!enabled) { setAgents([]); setModelProviders([]); setSelectedAgentId(""); setMessages([]); setActivities([]); setApprovals([]); setComputer(undefined); setConnection("disconnected"); setLoading(false); setError(undefined); return; } + let alive = true; setLoading(true); + void Promise.all([refreshAgents(), client.listModelProviders()]).then(([, providers]) => { if (alive) { setModelProviders(providers); 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; }; + }, [client, enabled, refreshAgents]); + useEffect(() => { + if (!enabled) return; + const refreshWhenActive = () => { + if (document.visibilityState === "hidden" || !navigator.onLine) return; + void refreshAgents().then(() => setError((current) => isTransientGatewayError(current) ? undefined : current)).catch(() => undefined); + }; + const onVisibilityChange = () => { if (document.visibilityState === "visible") refreshWhenActive(); }; + const timer = window.setInterval(refreshWhenActive, AGENT_FALLBACK_REFRESH_MS); + window.addEventListener("focus", refreshWhenActive); window.addEventListener("online", refreshWhenActive); document.addEventListener("visibilitychange", onVisibilityChange); + return () => { window.clearInterval(timer); window.removeEventListener("focus", refreshWhenActive); window.removeEventListener("online", refreshWhenActive); document.removeEventListener("visibilitychange", onVisibilityChange); }; + }, [enabled, refreshAgents]); useEffect(() => { - if (!selectedAgentId) return; const controller = new AbortController(); let alive = true; + if (!enabled) return; + const cached = snapshots.current.get(selectedAgentId); + setActivities([]); + if (cached) { setMessages(cached.messages); setApprovals(cached.approvals); setComputer(cached.computer); } + else { setMessages([]); setApprovals([]); setComputer(undefined); } + setLiveAgentId(""); + if (!selectedAgentId) return; + const cacheAge = cached ? Date.now() - cached.cachedAt : Number.POSITIVE_INFINITY; + if (cached && cacheAge < SNAPSHOT_TTL_MS) { + if (cached.messages.some((message) => message.streaming)) setLiveAgentId(selectedAgentId); + const timer = window.setTimeout(() => setRevalidateVersion((value) => value + 1), SNAPSHOT_TTL_MS - cacheAge); + return () => window.clearTimeout(timer); + } + const controller = new AbortController(); let alive = true; Promise.all([client.listConversations(selectedAgentId, controller.signal), client.listApprovalRequests(selectedAgentId, controller.signal), client.getComputer(selectedAgentId, controller.signal)]).then(async ([conversations, nextApprovals, nextComputer]) => { const conversation = conversations[0]; const data = conversation ? await client.getConversation(conversation.id, controller.signal) : undefined; - if (alive) { setMessages(data?.messages ?? []); setApprovals(nextApprovals); setComputer(nextComputer); } - }).catch((reason: unknown) => { if (alive && !(reason instanceof DOMException && reason.name === "AbortError")) setError(reason instanceof Error ? reason.message : "Could not load agent"); }); + if (alive && !deletingAgentIds.current.has(selectedAgentId)) { + const nextMessages = (data?.messages ?? []).map((message) => { const visualId = visualMessageIds.current.get(message.id); return visualId ? { ...message, id: visualId } : message; }); + const preview = latestCompletedAgentPreview(nextMessages); + snapshots.current.set(selectedAgentId, { messages: nextMessages, approvals: nextApprovals, computer: nextComputer, cachedAt: Date.now() }); + setLoadedAgentIds((current) => new Set(current).add(selectedAgentId)); + setMessages(nextMessages); setApprovals(nextApprovals); setComputer(nextComputer); + setLiveAgentId(selectedAgentId); + if (preview) setAgents((current) => current.map((agent) => agent.id === selectedAgentId ? { ...agent, lastMessagePreview: preview } : agent)); + } + }).catch((reason: unknown) => { if (alive && !deletingAgentIds.current.has(selectedAgentId) && !(reason instanceof DOMException && reason.name === "AbortError")) { snapshots.current.set(selectedAgentId, { messages: [], approvals: [], cachedAt: Date.now() }); setLoadedAgentIds((current) => new Set(current).add(selectedAgentId)); setMessages([]); setError(reason instanceof Error ? reason.message : "Could not load agent"); } }); return () => { alive = false; controller.abort(); }; - }, [client, selectedAgentId]); + }, [client, enabled, revalidateVersion, selectedAgentId]); useEffect(() => { - 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)); 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 (!enabled || !conversationId || liveAgentId !== selectedAgentId) return; let active = true; + const updateMessages = (updater: (current: Message[]) => Message[]) => setMessages((current) => { + const next = updater(current); const snapshot = snapshots.current.get(selectedAgentId); + snapshots.current.set(selectedAgentId, { messages: next, approvals: snapshot?.approvals ?? [], computer: snapshot?.computer, cachedAt: Date.now() }); + return next; + }); + const subscription = client.subscribeToConversationEvents(conversationId, (event: ConversationEvent) => { + if (!active) return; + if (event.type === "message.created") { + updateMessages((current) => { + let nextMessage = event.message; const knownVisualId = visualMessageIds.current.get(event.message.id); + if (knownVisualId) nextMessage = { ...event.message, id: knownVisualId }; + if (event.message.role === "user" && sendingAgentIds.current.has(selectedAgentId)) { + const optimistic = current.find((message) => message.id.startsWith(OPTIMISTIC_USER_PREFIX) && messageText(message) === messageText(event.message)); + if (optimistic) { visualMessageIds.current.set(event.message.id, optimistic.id); nextMessage = { ...event.message, id: optimistic.id }; } + } + if (event.message.role === "agent" && !knownVisualId) { + const claimedVisualIds = new Set(visualMessageIds.current.values()); + const optimistic = current.find((message) => message.id.startsWith(OPTIMISTIC_AGENT_PREFIX) && !claimedVisualIds.has(message.id)); + if (optimistic) { visualMessageIds.current.set(event.message.id, optimistic.id); nextMessage = { ...event.message, id: optimistic.id }; } + } + return current.some((message) => message.id === nextMessage.id) ? current.map((message) => message.id === nextMessage.id ? nextMessage : message) : [...current, nextMessage]; + }); + } + if (event.type === "message.delta") { + updateMessages((current) => { + let visualId = visualMessageIds.current.get(event.messageId); + if (!visualId) { const claimedVisualIds = new Set(visualMessageIds.current.values()); const optimistic = current.find((message) => message.id.startsWith(OPTIMISTIC_AGENT_PREFIX) && !claimedVisualIds.has(message.id)); if (optimistic) { visualId = optimistic.id; visualMessageIds.current.set(event.messageId, visualId); } } + visualId ??= event.messageId; + if (!current.some((message) => message.id === visualId)) return [...current, { id: visualId, conversationId, role: "agent", parts: [{ type: "text", text: event.delta }], createdAt: new Date().toISOString(), streaming: true }]; + return current.map((message) => message.id === visualId ? { ...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") { + const visualId = visualMessageIds.current.get(event.messageId) ?? event.messageId; + if (event.notify === false) { + updateMessages((current) => current.filter((message) => message.id !== visualId)); + } else { + updateMessages((current) => current.map((message) => message.id === visualId ? { ...message, streaming: false } : message)); + const completedMessages = (snapshots.current.get(selectedAgentId)?.messages ?? []).map((message) => message.id === visualId ? { ...message, streaming: false } : message); + const completedPreview = latestCompletedAgentPreview(completedMessages); + if (completedPreview) setAgents((current) => current.map((agent) => agent.id === selectedAgentId ? { ...agent, lastMessagePreview: completedPreview } : agent)); + 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") { + updateMessages((current) => { let visualId = visualMessageIds.current.get(event.message.id); if (!visualId && event.message.role === "agent") { const claimedVisualIds = new Set(visualMessageIds.current.values()); const optimistic = current.find((message) => message.id.startsWith(OPTIMISTIC_AGENT_PREFIX) && !claimedVisualIds.has(message.id)); if (optimistic) { visualId = optimistic.id; visualMessageIds.current.set(event.message.id, visualId); } } const nextMessage = visualId ? { ...event.message, id: visualId } : event.message; return current.some((message) => message.id === nextMessage.id) ? current.map((message) => message.id === nextMessage.id ? nextMessage : message) : [...current, nextMessage]; }); + if (event.message.role === "agent" && !event.message.streaming) setAgents((current) => current.map((agent) => agent.id === selectedAgentId ? { ...agent, lastMessagePreview: agentMessagePreview(event.message) || undefined } : agent)); + } + if (event.type === "approval.updated") { setApprovals((current) => { const next = current.map((approval) => approval.id === event.approval.id ? event.approval : approval); const snapshot = snapshots.current.get(selectedAgentId); snapshots.current.set(selectedAgentId, { messages: snapshot?.messages ?? [], approvals: next, computer: snapshot?.computer, cachedAt: Date.now() }); return next; }); if (event.approval.status === "pending") void window.runtaCrew?.notifications.show({ title: `${selectedAgent?.name ?? "Agent"} needs approval`, body: event.approval.title }); } + if (event.type === "activity.updated") setActivities((current) => current.some((activity) => activity.id === event.activity.id) ? current.map((activity) => activity.id === event.activity.id ? event.activity : activity) : [...current, event.activity]); if (event.type === "connection.changed") setConnection(event.state); - }); return () => subscription.unsubscribe(); - }, [client, conversationId, selectedAgent?.name]); + }); return () => { active = false; subscription.unsubscribe(); }; + }, [client, conversationId, enabled, liveAgentId, selectedAgent?.name, selectedAgentId]); return { - agents, selectedAgent, selectedAgentId, setSelectedAgentId, messages, approvals, computer, connection, loading, error, + agents, modelProviders, selectedAgent, selectedAgentId, setSelectedAgentId, messages, activities, approvals, computer, connection, loading, conversationLoading, error, dismissError: () => setError(undefined), - createAgent: async (input: CreateAgentInput) => { const agent = await client.createAgent(input); await refreshAgents(); setSelectedAgentId(agent.id); }, + createAgent: async (input: CreateAgentInput) => { const agent = await client.createAgent(input); snapshots.current.set(agent.id, { messages: [], approvals: [], cachedAt: 0 }); setLoadedAgentIds((current) => new Set(current).add(agent.id)); await refreshAgents(); setSelectedAgentId(agent.id); return agent; }, updateAgent: async (agentId: string, input: UpdateAgentInput) => { await client.updateAgent(agentId, input); await refreshAgents(); }, - deleteAgent: async (agentId: string) => { await client.deleteAgent(agentId); await refreshAgents(); }, + deleteAgent: async (agentId: string) => { + const removedAgent = agents.find((agent) => agent.id === agentId); const previousSelectedAgentId = selectedAgentId; + if (!removedAgent || deletingAgentIds.current.has(agentId)) return; + const removedIndex = agents.findIndex((agent) => agent.id === agentId); + const nextAgents = agents.filter((agent) => agent.id !== agentId); + const nextSelectedAgentId = previousSelectedAgentId === agentId ? nextAgents[Math.min(Math.max(removedIndex, 0), Math.max(nextAgents.length - 1, 0))]?.id ?? "" : previousSelectedAgentId; + deletingAgentIds.current.add(agentId); setAgents(nextAgents); setSelectedAgentId(nextSelectedAgentId); + try { + try { await client.deleteAgent(agentId); } + catch (reason) { if (!(reason instanceof CrewError && reason.code === "not_found")) throw reason; } + snapshots.current.delete(agentId); + setLoadedAgentIds((current) => { const next = new Set(current); next.delete(agentId); return next; }); + await refreshAgents(); setError((current) => current === "Resource not found" ? undefined : current); + } catch (reason) { + deletingAgentIds.current.delete(agentId); + setAgents((current) => { if (current.some((agent) => agent.id === agentId)) return current; const next = [...current]; next.splice(Math.min(removedIndex, next.length), 0, removedAgent); return next; }); + setSelectedAgentId((current) => current || (previousSelectedAgentId === agentId ? agentId : current)); + setError(reason instanceof Error ? reason.message : "Could not delete agent"); throw reason; + } + }, 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; } }, + sendMessage: async (text: string, attachments: Attachment[] = []) => { + if (!conversationId || !selectedAgentId) return; + const targetAgentId = selectedAgentId; const targetConversationId = conversationId; const nonce = `${Date.now()}:${Math.random().toString(36).slice(2)}`; + if (sendingAgentIds.current.has(targetAgentId)) return; + sendingAgentIds.current.add(targetAgentId); + const optimisticUserId = `${OPTIMISTIC_USER_PREFIX}${nonce}`; const optimisticAgentId = `${OPTIMISTIC_AGENT_PREFIX}${nonce}`; const createdAt = new Date().toISOString(); + const optimisticUser: Message = { id: optimisticUserId, conversationId: targetConversationId, role: "user", parts: [...(text ? [{ type: "text" as const, text }] : []), ...attachments.map((attachment) => ({ type: "attachment" as const, attachment }))], createdAt }; + const optimisticAgent: Message = { id: optimisticAgentId, conversationId: targetConversationId, role: "agent", parts: [{ type: "text", text: "" }], createdAt, streaming: true }; + const snapshot = snapshots.current.get(targetAgentId) ?? { messages: [], approvals: [], cachedAt: Date.now() }; + const optimisticMessages = [...snapshot.messages, optimisticUser, optimisticAgent]; + snapshots.current.set(targetAgentId, { ...snapshot, messages: optimisticMessages, cachedAt: Date.now() }); + if (selectedAgentIdRef.current === targetAgentId) { setMessages(optimisticMessages); setLiveAgentId(targetAgentId); } + try { + const message = await client.sendMessage({ conversationId: targetConversationId, text, attachments }); + visualMessageIds.current.set(message.id, optimisticUserId); + if (message.id.endsWith(":user")) visualMessageIds.current.set(`${message.id.slice(0, -":user".length)}:agent`, optimisticAgentId); + const latest = snapshots.current.get(targetAgentId) ?? snapshot; + const visualMessage = { ...message, id: optimisticUserId }; + const reconciled = latest.messages.map((item) => item.id === optimisticUserId ? visualMessage : item).filter((item, index, all) => all.findIndex((candidate) => candidate.id === item.id) === index); + snapshots.current.set(targetAgentId, { ...latest, messages: reconciled, cachedAt: Date.now() }); + if (selectedAgentIdRef.current === targetAgentId) setMessages(reconciled); + } catch (reason) { + const latest = snapshots.current.get(targetAgentId) ?? snapshot; + const rolledBack = latest.messages.filter((message) => message.id !== optimisticUserId && message.id !== optimisticAgentId); + snapshots.current.set(targetAgentId, { ...latest, messages: rolledBack, cachedAt: Date.now() }); + if (selectedAgentIdRef.current === targetAgentId) setMessages(rolledBack); + setError(reason instanceof Error ? reason.message : "Could not send message"); + throw reason; + } finally { + sendingAgentIds.current.delete(targetAgentId); + } + }, + respondToApproval: async (requestId: string, decision: "allow" | "deny", note?: string) => { const targetAgentId = selectedAgentId; const next = await client.respondToApproval({ requestId, decision, note }); const snapshot = snapshots.current.get(targetAgentId); const nextApprovals = (snapshot?.approvals ?? []).map((item) => item.id === next.id ? next : item); if (snapshot) snapshots.current.set(targetAgentId, { ...snapshot, approvals: nextApprovals, cachedAt: Date.now() }); if (selectedAgentIdRef.current === targetAgentId) setApprovals(nextApprovals); }, + openComputer: async (action: "open" | "takeover") => { if (!selectedAgentId) throw new Error("No agent is selected"); 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 dde95f2..e8d4e99 100644 --- a/src/ui/App.test.tsx +++ b/src/ui/App.test.tsx @@ -1,114 +1,138 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; -import { App } from "./App"; +import { describe, expect, it, vi } from "vitest"; +import { AgentsLanding, App } from "./App"; +import { accountDisplayName } from "./accountDisplayName"; +import { nextAgentName } from "@/domain/agentName"; +import { AgentList } from "./components/AgentList"; +import { LoginPage } from "./components/LoginPage"; 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: /^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" })); - await waitFor(() => expect(screen.queryByText("Submit vendor renewal")).not.toBeInTheDocument()); +describe("Runta Crew authentication surfaces", () => { + it("adapts the landing copy to whether agents exist", () => { + const { rerender } = render(); + expect(screen.getByText("Create your first agent to get started.")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("Choose an agent to get started.")).toBeInTheDocument(); + }); + it("assigns unique single names before deterministic word pairs", () => { + const used: string[] = []; + for (let index = 0; index < 18; index += 1) { const name = nextAgentName(used); expect(used.map((value) => value.toLowerCase())).not.toContain(name.toLowerCase()); used.push(name); } + expect(used.slice(0, 3)).toEqual(["Atlas", "Scout", "Mira"]); + expect(used[16]).toBe("Amber Brook"); + expect(used[17]).toBe("Amber Cedar"); + expect(nextAgentName(["atlas"])).toBe("Scout"); }); + it("derives a human account name from the authorized profile", () => { + expect(accountDisplayName({ email: "shiqi@runta.com" })).toBe("Shiqi"); + expect(accountDisplayName({ email: "shiqi.mei@runta.com" })).toBe("Shiqi Mei"); + expect(accountDisplayName({ email: "xydd@runta.com" })).toBe("Xydd"); + expect(accountDisplayName({ display_name: " Shiqi Mei ", email: "ignored@runta.com" })).toBe("Shiqi Mei"); + }); + it("keeps account actions in the username popover", async () => { + const opened: string[] = []; + const bridge: DesktopBridge = { + getVersion: async () => "0.1.0", openExternal: async (url) => { opened.push(url); }, + 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: () => () => undefined }, + }; + window.runtaCrew = bridge; + const user = userEvent.setup(); + render( undefined} onSelect={() => undefined} onAction={() => undefined} onCreate={() => undefined} onSettings={() => undefined} onSignIn={() => undefined} onLogout={() => undefined} />); - it("creates an agent and sends a message", async () => { - const user = userEvent.setup(); render(); await screen.findByRole("heading", { name: "Atlas", level: 1 }); - await user.click(screen.getByRole("button", { name: "New agent" })); - 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 }); - 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(); + const account = screen.getByRole("button", { name: "Shiqi Mei" }); + await user.click(account); + expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([" Settings", " Join Discord", " Logout"]); + await user.click(screen.getByRole("menuitem", { name: "Join Discord" })); + expect(opened).toEqual(["https://discord.com/invite/62d4bkaTnS"]); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + delete window.runtaCrew; }); - 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" })); - 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("shows immediate feedback while an agent is being created", () => { + render( undefined} onSelect={() => undefined} onAction={() => undefined} onCreate={() => undefined} onSettings={() => undefined} onSignIn={() => undefined} onLogout={() => undefined} />); + expect(screen.getByRole("status")).toHaveTextContent("AtlasCreating…"); + expect(screen.getByRole("button", { name: "New agent" })).toBeDisabled(); + }); + + it("does not duplicate a creating row when polling sees the new server agent first", () => { + const existing = { id: "existing", name: "Scout", role: "Cloud coding agent", goal: "Scout", status: "idle" as const, avatar: "S", lastActiveAt: new Date().toISOString(), unreadCount: 0, computerId: "existing" }; + const created = { ...existing, id: "created", status: "working" as const, computerId: "created" }; + const props = { selectedId: "", search: "", signedIn: true, userName: "Shiqi Mei", onSearch: () => undefined, onSelect: () => undefined, onAction: () => undefined, onCreate: () => undefined, onSettings: () => undefined, onSignIn: () => undefined, onLogout: () => undefined }; + const { rerender } = render(); + + expect(screen.getAllByText("Scout")).toHaveLength(2); + expect(screen.getByText("Creating…")).toBeInTheDocument(); + + rerender(); + expect(screen.getAllByText("Scout")).toHaveLength(2); + expect(screen.queryByText("Creating…")).not.toBeInTheDocument(); }); - 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("distinguishes an empty crew from an empty search result", () => { + const props = { agents: [], selectedId: "", signedIn: true, userName: "Shiqi Mei", onSearch: () => undefined, onSelect: () => undefined, onAction: () => undefined, onCreate: () => undefined, onSettings: () => undefined, onSignIn: () => undefined, onLogout: () => undefined }; + const { rerender } = render(); + expect(screen.getByText("No agents yet")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("No agents found")).toBeInTheDocument(); }); - it("selects a local attachment through the typed desktop bridge and sends it", async () => { + it("shows only Agent actions backed by the Cloud Agents API", async () => { + const user = userEvent.setup(); const atlas = { id: "atlas", name: "Atlas", role: "Cloud coding agent", goal: "Atlas", status: "idle" as const, avatar: "A", lastActiveAt: new Date().toISOString(), unreadCount: 0, computerId: "atlas" }; + render( undefined} onSelect={() => undefined} onAction={() => undefined} onCreate={() => undefined} onSettings={() => undefined} onSignIn={() => undefined} onLogout={() => undefined} />); + await user.click(screen.getByRole("button", { name: "More actions for Atlas" })); + expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([" Edit agent", " Delete agent"]); + expect(screen.queryByText("Duplicate")).not.toBeInTheDocument(); + expect(screen.queryByText("Mark as unread")).not.toBeInTheDocument(); + }); + + it("shows the standalone OAuth page when no credential exists", async () => { + const cloudRequest = vi.fn(); 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" }] }, + settings: { get: async () => ({ endpoint: "https://api.forge", dashboardUrl: "https://app.forge", theme: "light", notifications: true }), set: async (settings) => settings }, + credentials: { has: async () => false, set: async () => false }, attachments: { choose: async () => [] }, + cloud: { request: cloudRequest, subscribe: () => () => undefined }, 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(); + render(); + expect(await screen.findByRole("heading", { name: "Runta Crew", level: 1 })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Sign in" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Runta account" })).not.toBeInTheDocument(); + expect(cloudRequest).not.toHaveBeenCalled(); delete window.runtaCrew; }); - it("opens a validated agent deep link through the typed desktop bridge", async () => { - let listener: ((agentId: string) => void) | undefined; + it("does not expose Electron IPC errors on sign-in failure", 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 () => [] }, - notifications: { show: async () => true, setBadge: async () => undefined }, - deepLinks: { onOpenAgent: (next) => { listener = next; return () => { listener = undefined; }; } }, + settings: { get: async () => ({ endpoint: "https://api.forge", dashboardUrl: "https://app.forge", theme: "light", notifications: true }), set: async (settings) => settings }, + credentials: { has: async () => false, set: async () => false }, + auth: { start: async () => { throw new Error("Error invoking remote method 'auth:start': Error: Device authorization failed (401)"); }, status: async () => "error", logout: async () => true }, + attachments: { choose: async () => [] }, notifications: { show: async () => true, setBadge: async () => undefined }, deepLinks: { onOpenAgent: () => () => 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"); + window.runtaCrew = bridge; + const user = userEvent.setup(); render(); + await user.click(await screen.findByRole("button", { name: "Sign in" })); + expect(await screen.findByText("This Runta environment does not support Crew sign-in yet.")).toBeInTheDocument(); + expect(screen.queryByText(/Error invoking remote method/)).not.toBeInTheDocument(); + delete window.runtaCrew; }); - 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("keeps connection configuration behind the development gesture", () => { + render( undefined} onSettings={() => undefined} />); + expect(screen.queryByRole("button", { name: "Connection settings" })).not.toBeInTheDocument(); + for (let press = 0; press < 5; press += 1) fireEvent.keyDown(window, { key: "Control" }); + expect(screen.getByRole("button", { name: "Connection settings" })).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()); + it("never exposes connection configuration in production mode", () => { + render( undefined} onSettings={() => undefined} />); + for (let press = 0; press < 5; press += 1) fireEvent.keyDown(window, { key: "Control" }); + expect(screen.queryByRole("button", { name: "Connection settings" })).not.toBeInTheDocument(); }); }); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c98ec26..26edd33 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,35 +1,136 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { AlertCircle } from "lucide-react"; -import { MockCloudAgentsClient } from "@/clients/mock/MockCloudAgentsClient"; +import { RuntaCloudAgentsClient } from "@/clients/http/RuntaCloudAgentsClient"; import { useCrewController } from "@/state/useCrewController"; import { AgentList, type AgentAction } from "./components/AgentList"; import { Conversation } from "./components/Conversation"; import { DetailPanel } from "./components/DetailPanel"; -import { CreateAgentDialog, DeleteAgentDialog, EditAgentDialog, SettingsDialog } from "./components/Dialogs"; +import { DeleteAgentDialog, EditAgentDialog, SettingsDialog } from "./components/Dialogs"; import { CommandPalette } from "./components/CommandPalette"; +import { LoginPage } from "./components/LoginPage"; +import { AgentAvatar } from "./components/AgentAvatar"; +import { accountDisplayName } from "./accountDisplayName"; +import { nextAgentName } from "@/domain/agentName"; import type { Agent } from "@/domain/types"; +const LANDING_AGENTS = ["Atlas", "Scout", "Mira", "Nova"] as const; + +export function AgentsLanding({ hasAgents }: { hasAgents: boolean }) { + return
+ +

{hasAgents ? "Choose an agent to get started." : "Create your first agent to get started."}

+
; +} + export function App() { - const client = useMemo(() => new MockCloudAgentsClient(), []); const crew = useCrewController(client); + const forceLoading = import.meta.env.DEV && new URLSearchParams(window.location.search).get("loading") === "1"; + const [authReady, setAuthReady] = useState(false); const [signedIn, setSignedIn] = useState(false); const [authPending, setAuthPending] = useState(false); const [authError, setAuthError] = useState(); const [userName, setUserName] = useState(""); const [userEmail, setUserEmail] = useState(""); + const [client, setClient] = useState(() => new RuntaCloudAgentsClient()); const crew = useCrewController(client, signedIn); 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 [search, setSearch] = useState(""); const [detailsOpen, setDetailsOpen] = useState(false); const [detailsMounted, setDetailsMounted] = useState(false); const [creatingAgentName, setCreatingAgentName] = useState(); const [creatingAgentBaselineIds, setCreatingAgentBaselineIds] = useState>(() => new Set()); const [composerFocusRequest, setComposerFocusRequest] = useState(0); 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())); + function readableAuthError(reason: unknown) { + const raw = reason instanceof Error ? reason.message : ""; + if (/Device authorization failed \(401\)/.test(raw)) return "This Runta environment does not support Crew sign-in yet."; + if (/Device authorization failed \(502|fetch failed/i.test(raw)) return "Runta Cloud Agents is unavailable. Try again shortly."; + return raw.replace(/^Error invoking remote method '[^']+': Error:\s*/, "") || "Authorization failed. Try again."; + } + async function connectAuthenticatedAccount() { + let timeout: number | undefined; + const response = await Promise.race([ + window.runtaCrew?.cloud?.request({ method: "GET", path: "/v1/me" }).catch(() => undefined), + new Promise((resolve) => { timeout = window.setTimeout(() => resolve(undefined), 5_000); }), + ]).finally(() => { if (timeout !== undefined) window.clearTimeout(timeout); }); + if (!response) { setSignedIn(false); setUserName(""); setAuthReady(true); setAuthError("Runta session validation did not return a response."); return false; } + if (response.status !== 200) { setSignedIn(false); setUserName(""); setAuthReady(true); setAuthError(`Runta session validation failed (${response.status}).`); return false; } + const profile = response.body as { data?: { display_name?: string | null; email?: string } } | undefined; + setUserName(accountDisplayName(profile?.data)); + setUserEmail(profile?.data?.email ?? ""); + setClient(new RuntaCloudAgentsClient()); setSignedIn(true); + setAuthReady(true); setAuthError(undefined); + return true; + } + async function signIn() { + setAuthPending(true); setAuthError(undefined); + try { + const started = await window.runtaCrew?.auth?.start(); + if (!started) throw new Error("OAuth is unavailable in this environment."); + const expiresAt = Date.parse(started.expiresAt); + while (true) { + await new Promise((resolve) => window.setTimeout(resolve, 1000)); + if (Number.isFinite(expiresAt) && Date.now() >= expiresAt) throw new Error("Authorization expired. Try again."); + const status = await window.runtaCrew?.auth?.status(); + if (status === "pending") continue; + if (status === "authorized") { + let credentialReady = false; + for (let attempt = 0; attempt < 20; attempt += 1) { + if (await window.runtaCrew?.credentials.has()) { credentialReady = true; break; } + await new Promise((resolve) => window.setTimeout(resolve, 50)); + } + if (!credentialReady) throw new Error("Runta authorization completed before the local session was ready. Try again."); + await connectAuthenticatedAccount(); + return; + } + throw new Error(status === "denied" ? "Authorization was denied." : status === "expired" ? "Authorization expired. Try again." : "Authorization failed. Try again."); + } + } catch (reason) { setAuthError(readableAuthError(reason)); } + finally { setAuthPending(false); } + } + async function logout() { + setAuthError(undefined); + try { + await window.runtaCrew?.auth?.logout(); + setClient(new RuntaCloudAgentsClient()); setSignedIn(false); setAuthReady(true); setUserName(""); setUserEmail(""); + } catch (reason) { + setAuthError(reason instanceof Error ? reason.message : "Could not revoke the Runta key. Try again."); + } + } useEffect(() => { - void window.runtaCrew?.settings.get().then((settings) => { + void Promise.all([window.runtaCrew?.settings.get(), window.runtaCrew?.credentials.has()]).then(async ([settings, hasCredential]) => { + if (!settings) { setAuthReady(true); return; } document.documentElement.dataset.theme = settings.theme === "system" ? window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" : settings.theme; + if (!settings.endpoint) { setAuthReady(true); return; } + if (settings.endpoint && hasCredential) { + await connectAuthenticatedAccount(); + } else setAuthReady(true); }); }, []); 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]); + useEffect(() => { + if (detailsOpen) { setDetailsMounted(true); return; } + const timer = window.setTimeout(() => setDetailsMounted(false), 220); + return () => window.clearTimeout(timer); + }, [detailsOpen]); 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); + } + async function createDefaultAgent() { + if (creatingAgentName) return; + const name = nextAgentName(crew.agents.map((agent) => agent.name)); + setCreatingAgentBaselineIds(new Set(crew.agents.map((agent) => agent.id))); + setCreatingAgentName(name); setAuthError(undefined); + try { + const settings = await window.runtaCrew?.settings.get(); + const provider = crew.modelProviders.find((item) => item.id === settings?.modelProviderId); + if (!provider) { + setAuthError("Select a model provider in Settings before creating an agent."); + setSettingsOpen(true); + return; + } + await crew.createAgent({ name, modelProviderId: provider.id }); + setComposerFocusRequest((current) => current + 1); + } + catch (reason) { setAuthError(reason instanceof Error ? reason.message : "Could not create the agent."); } + finally { setCreatingAgentName(undefined); } } useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -38,16 +139,16 @@ export function App() { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, []); - if (crew.loading) return
Loading your crew…
; + if (forceLoading || !authReady || crew.loading) return
Getting your crew ready…
; + if (!signedIn) return <> void signIn()} onSettings={() => setSettingsOpen(true)} />{import.meta.env.DEV && settingsOpen && setSettingsOpen(false)} />}; return
- 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} />} + void createDefaultAgent()} onSettings={() => setSettingsOpen(true)} onSignIn={() => setSettingsOpen(true)} onLogout={() => void logout()} /> + {crew.selectedAgent ? setDetailsOpen((value) => !value)} /> : 0} />} + {detailsMounted && crew.selectedAgent && setDetailsOpen(false)} />} + {(crew.error || authError) &&
{crew.error || authError}
} {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)} /> + {settingsOpen && { setSettingsOpen(false); void logout(); }} onClose={() => setSettingsOpen(false)} />} + setPaletteOpen(false)} onSelectAgent={crew.setSelectedAgentId} onCreateAgent={() => void createDefaultAgent()} onSettings={() => setSettingsOpen(true)} onComputer={() => setDetailsOpen(true)} />
; } diff --git a/src/ui/accountDisplayName.ts b/src/ui/accountDisplayName.ts new file mode 100644 index 0000000..200ba05 --- /dev/null +++ b/src/ui/accountDisplayName.ts @@ -0,0 +1,7 @@ +export function accountDisplayName(profile?: { display_name?: string | null; email?: string }) { + const explicit = profile?.display_name?.trim(); + if (explicit) return explicit; + const localPart = profile?.email?.split("@", 1)[0]?.trim(); + if (!localPart) return "Runta account"; + return localPart.split(/[._-]+/).filter(Boolean).map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1).toLowerCase()}`).join(" ") || "Runta account"; +} diff --git a/src/ui/components/AgentAvatar.tsx b/src/ui/components/AgentAvatar.tsx new file mode 100644 index 0000000..8fe5ccf --- /dev/null +++ b/src/ui/components/AgentAvatar.tsx @@ -0,0 +1,18 @@ +import Avatar from "boring-avatars"; +import type { Agent } from "@/domain/types"; + +const RUNTA_AVATAR_COLORS = ["#F07818", "#FFB477", "#FFD7B5", "#F0F0F0", "#DCE5E0"]; + +export function AgentAvatar({ agent, size = 36 }: { agent: Pick; size?: number }) { + return ; +} diff --git a/src/ui/components/AgentList.tsx b/src/ui/components/AgentList.tsx index 1950ccf..b50bfee 100644 --- a/src/ui/components/AgentList.tsx +++ b/src/ui/components/AgentList.tsx @@ -1,35 +1,65 @@ -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 { LogIn, LogOut, MessageCircle, MoreHorizontal, Pencil, Plus, Search, Settings, Trash2 } from "lucide-react"; +import { lazy, Suspense, useEffect, useState } from "react"; +import type { Agent } from "@/domain/types"; +import { AgentAvatar } from "./AgentAvatar"; -export type AgentAction = "edit" | "pin" | "duplicate" | "toggle-unread" | "delete"; -const statusLabel: Record = { working: "Working", idle: "Idle", waiting_for_approval: "Needs approval", offline: "Offline" }; +const Streamdown = lazy(async () => ({ default: (await import("streamdown")).Streamdown })); -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(); +export type AgentAction = "edit" | "delete"; +export function AgentList({ agents, selectedId, search, creatingAgentName, creatingAgentBaselineIds, signedIn, userName, onSearch, onSelect, onAction, onCreate, onSettings, onSignIn, onLogout }: { agents: Agent[]; selectedId: string; search: string; creatingAgentName?: string; creatingAgentBaselineIds?: ReadonlySet; signedIn: boolean; userName: string; onSearch(value: string): void; onSelect(id: string): void; onAction(agent: Agent, action: AgentAction): void; onCreate(): void; onSettings(): void; onSignIn(): void; onLogout(): void }) { + const [menuAgentId, setMenuAgentId] = useState(); const [accountOpen, setAccountOpen] = useState(false); 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))); + useEffect(() => { + if (!accountOpen) return; + const close = () => setAccountOpen(false); + const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") close(); }; + window.addEventListener("pointerdown", close); + window.addEventListener("keydown", closeOnEscape); + return () => { + window.removeEventListener("pointerdown", close); + window.removeEventListener("keydown", closeOnEscape); + }; + }, [accountOpen]); const act = (agent: Agent, action: AgentAction) => { setMenuAgentId(undefined); onAction(agent, action); }; + const visibleAgents = creatingAgentName + ? agents.filter((agent) => agent.name !== creatingAgentName || creatingAgentBaselineIds?.has(agent.id)) + : agents; return ; } diff --git a/src/ui/components/Conversation.test.tsx b/src/ui/components/Conversation.test.tsx index 8d02a38..ae05cc7 100644 --- a/src/ui/components/Conversation.test.tsx +++ b/src/ui/components/Conversation.test.tsx @@ -1,5 +1,4 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { Agent, Message } from "@/domain/types"; import { Conversation, MessageView } from "./Conversation"; @@ -7,19 +6,137 @@ 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", () => { + it("renders agent Markdown with Streamdown semantics", async () => { + const message: Message = { id: "agent-markdown", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "Hello **team**\n\n- Build\n- Test" }], createdAt: new Date().toISOString() }; + const { container } = render(); + expect(await screen.findByRole("list")).toHaveTextContent(/Build\s+Test/); + expect(container.querySelector(".agent-markdown")).not.toHaveTextContent("**"); + }); + + it("lets long Markdown tables expand without overlapping following content", async () => { + const message: Message = { id: "agent-table", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "| Date | News |\n| --- | --- |\n| Aug 13, 2026 | A very long update that must wrap inside the table cell instead of escaping the table layout. |\n| Aug 5, 2026 | Another long update. |\n\nSources: [Runta Blog](https://runta.com/blog)\n\nWant me to dig deeper?" }], createdAt: new Date().toISOString() }; + const { container } = render(); + const table = await screen.findByRole("table"); + const wrapper = table.closest('[data-streamdown="table-wrapper"]'); + + expect(wrapper).toBeInTheDocument(); + expect(wrapper).not.toContainElement(screen.getByText(/Sources:/).closest("p")); + const tableViewport = container.querySelector('[data-streamdown="table-wrapper"] > div:last-child'); + expect(tableViewport).toBeInTheDocument(); + expect(tableViewport).not.toHaveAttribute("style"); + expect(screen.getByText("Want me to dig deeper?")).toBeInTheDocument(); + }); + + it("renders normal links and opens them in the system browser", async () => { + const openExternal = vi.fn(async () => undefined); + window.runtaCrew = { openExternal } as unknown as typeof window.runtaCrew; + const message: Message = { id: "agent-link", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "Visit [runta.com](https://runta.com/docs)." }], createdAt: new Date().toISOString() }; + render(); + const link = await screen.findByRole("link", { name: "runta.com" }); + expect(link).toHaveAttribute("href", "https://runta.com/docs"); + fireEvent.click(link); + expect(openExternal).toHaveBeenCalledWith("https://runta.com/docs"); + }); + + it("renders a system event", () => { 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} />); + const { container } = render(); 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(); + it("does not repeat agent metadata in the empty conversation intro", () => { + render( undefined} onToggleDetails={() => undefined} />); + expect(screen.getByRole("heading", { name: "Atlas", level: 2 })).toBeInTheDocument(); + expect(screen.queryByText("Find the signal")).not.toBeInTheDocument(); + }); + + it("shows a skeleton instead of the empty conversation while history loads", () => { + const { container } = render( undefined} onToggleDetails={() => undefined} />); + expect(screen.getByRole("status", { name: "Loading conversation history" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Atlas", level: 2 })).not.toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Message Atlas" })).toBeInTheDocument(); + expect(container.querySelector(".skeleton-bubble")?.parentElement).toHaveClass("skeleton-user"); + }); + + it("keeps a bottom safe area for late-rendering conversation content", () => { + const message: Message = { id: "agent-late-layout", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "A long final response" }], createdAt: new Date().toISOString() }; + const { container } = render( undefined} onToggleDetails={() => undefined} />); + + expect(container.querySelector(".message-content")).toBeInTheDocument(); + expect(container.querySelector(".message-content")?.parentElement).toHaveClass("message-scroll"); + }); + + it("stops following when the user scrolls up and offers a return to latest", () => { + const first: Message = { id: "agent-1", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "First response" }], createdAt: new Date().toISOString() }; + const second: Message = { ...first, id: "agent-2", parts: [{ type: "text", text: "New response" }] }; + const { container, rerender } = render( undefined} onToggleDetails={() => undefined} />); + const scroller = container.querySelector(".message-scroll"); + expect(scroller).toBeInTheDocument(); + Object.defineProperties(scroller!, { scrollHeight: { configurable: true, value: 1000 }, clientHeight: { configurable: true, value: 400 }, scrollTop: { configurable: true, writable: true, value: 600 } }); + const scrollTo = vi.fn(); Object.defineProperty(scroller!, "scrollTo", { configurable: true, value: scrollTo }); + + fireEvent.wheel(scroller!, { deltaY: -120 }); + scroller!.scrollTop = 240; fireEvent.scroll(scroller!); + const returnButton = screen.getByRole("button", { name: "Scroll to latest message" }); + expect(returnButton).toBeInTheDocument(); + + scrollTo.mockClear(); + rerender( undefined} onToggleDetails={() => undefined} />); + expect(scrollTo).not.toHaveBeenCalled(); + + fireEvent.click(returnButton); + expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" }); + expect(screen.queryByRole("button", { name: "Scroll to latest message" })).not.toBeInTheDocument(); + }); + + it("detects upward scrollbar dragging while an automatic scroll is in progress", () => { + const message: Message = { id: "agent-streaming", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "Streaming" }], createdAt: new Date().toISOString(), streaming: true }; + const { container } = render( undefined} onToggleDetails={() => undefined} />); + const scroller = container.querySelector(".message-scroll")!; + Object.defineProperties(scroller, { scrollHeight: { configurable: true, value: 1000 }, clientHeight: { configurable: true, value: 400 }, scrollTop: { configurable: true, writable: true, value: 600 } }); + fireEvent.scroll(scroller); + scroller.scrollTop = 220; fireEvent.scroll(scroller); + expect(screen.getByRole("button", { name: "Scroll to latest message" })).toBeInTheDocument(); + }); + + it("focuses the composer when a new-agent focus request arrives", () => { + const { rerender } = render( undefined} onToggleDetails={() => undefined} />); + const composer = screen.getByRole("textbox", { name: "Message Atlas" }); + expect(composer).not.toHaveFocus(); + rerender( undefined} onToggleDetails={() => undefined} />); + expect(composer).toHaveFocus(); + }); + + it("shows the animated agent avatar before the first token arrives", () => { + const message: Message = { id: "run-1:agent", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "" }], createdAt: new Date().toISOString(), streaming: true }; + const { container } = render(); + expect(screen.getByRole("status", { name: "Agent is working: Working" })).toBeInTheDocument(); + expect(container.querySelector(".streaming-caret")).not.toBeInTheDocument(); + }); + + it("shows the latest progress message directly on the working row", () => { + const message: Message = { id: "run-2:agent", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "Checking now" }], createdAt: new Date().toISOString(), streaming: true }; + const { container } = render(); + expect(screen.getByRole("status", { name: "Agent is working: Checking now" })).toBeInTheDocument(); + expect(screen.getByText("Checking now")).toHaveClass("agent-working-progress"); + expect(container.querySelector(".message-body")).not.toBeInTheDocument(); + }); + + it("animates a user entry and the final agent response at their actual state transitions", () => { + const animate = vi.fn(); const originalAnimate = HTMLElement.prototype.animate; + Object.defineProperty(HTMLElement.prototype, "animate", { configurable: true, value: animate }); + try { + const user: Message = { id: "optimistic-user:1", conversationId: "conversation-atlas", role: "user", parts: [{ type: "text", text: "Hello" }], createdAt: new Date().toISOString() }; + render(); + expect(animate).toHaveBeenCalledTimes(1); + + const streaming: Message = { id: "run-1:agent", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "" }], createdAt: new Date().toISOString(), streaming: true }; + const { rerender } = render(); + rerender(); + expect(animate).toHaveBeenCalledTimes(2); + } finally { + Object.defineProperty(HTMLElement.prototype, "animate", { configurable: true, value: originalAnimate }); + } }); }); diff --git a/src/ui/components/Conversation.tsx b/src/ui/components/Conversation.tsx index 23c0e36..0cb57ce 100644 --- a/src/ui/components/Conversation.tsx +++ b/src/ui/components/Conversation.tsx @@ -1,29 +1,90 @@ -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, Attachment, ConnectionState, Message, ReactionKind } from "@/domain/types"; +import { ArrowDown, Cloud, File, FileText, Globe2, LoaderCircle, Monitor, Paperclip, Terminal, X } from "lucide-react"; +import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type FormEvent, type MouseEvent } from "react"; +import type { ActivityEvent, Agent, Attachment, Message } from "@/domain/types"; +import { AgentAvatar } from "./AgentAvatar"; +import "../conversation-skeleton.css"; + +const Streamdown = lazy(async () => ({ default: (await import("streamdown")).Streamdown })); 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(""); } 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 }) { +function SubmitArrowIcon() { return ; } +function StopIcon() { return ; } +function openExternalLink(event: MouseEvent) { + if (!(event.target instanceof Element)) return; + const anchor = event.target.closest("a[href]"); + if (!anchor || !event.currentTarget.contains(anchor)) return; + try { + const url = new URL(anchor.href); + if (url.protocol !== "http:" && url.protocol !== "https:") return; + event.preventDefault(); + void window.runtaCrew?.openExternal(url.toString()); + } catch { /* Ignore malformed Agent output instead of navigating the webview. */ } +} +export function MessageView({ message, agent, activities, entering = false }: { message: Message; agent?: Pick; activities: ActivityEvent[]; entering?: boolean }) { + const text = textOf(message); + const rootRef = useRef(null); const entryAnimated = useRef(false); const previousStreaming = useRef(Boolean(message.streaming)); const relevant = message.parts.flatMap((part) => part.type === "activity" ? activities.filter((activity) => activity.id === part.activityId) : []); + const activeActivity = [...activities].reverse().find((activity) => activity.status === "running"); + const agentWorking = message.role === "agent" && Boolean(message.streaming); + const workingLabel = text.trim() || activeActivity?.title || "Working"; const attachments = message.parts.flatMap((part) => part.type === "attachment" ? [part.attachment] : []); - return
- {textOf(message) &&
{textOf(message)}{message.streaming && }
} + useLayoutEffect(() => { + const root = rootRef.current; const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + if (root && typeof root.animate === "function" && entering && !entryAnimated.current && !reducedMotion) { + entryAnimated.current = true; + const fromX = message.role === "user" ? 14 : -8; + root.animate([{ opacity: 0, transform: `translate3d(${fromX}px,7px,0) scale(.97)` }, { opacity: 1, transform: "translate3d(0,0,0) scale(1)", offset: .72 }, { opacity: 1, transform: "translate3d(0,0,0) scale(1)" }], { duration: message.role === "user" ? 320 : 280, easing: "cubic-bezier(.18,.82,.28,1)" }); + } + if (root && message.role === "agent" && previousStreaming.current && !message.streaming && !reducedMotion) { + const body = root.querySelector(".message-body"); + if (body && typeof body.animate === "function") body.animate([{ opacity: 0, transform: "translate3d(-6px,4px,0)" }, { opacity: 1, transform: "translate3d(0,0,0)" }], { duration: 260, easing: "cubic-bezier(.2,.82,.3,1)" }); + } + previousStreaming.current = Boolean(message.streaming); + }, [entering, message.role, message.streaming]); + return
+ {!agentWorking && (text || message.streaming) &&
{message.role === "agent" ? {text}}>{text} : text}
} + {agentWorking &&
{workingLabel}
} {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, 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); } } +export function Conversation({ agent, messages, activities, loading = false, focusRequest = 0, onSend, onToggleDetails }: { agent: Agent; messages: Message[]; activities: ActivityEvent[]; loading?: boolean; focusRequest?: number; onSend(text: string, attachments?: Attachment[]): Promise; onToggleDetails(): void }) { + const [draft, setDraft] = useState(""); const [attachments, setAttachments] = useState([]); const [sending, setSending] = useState(false); const [isScrolled, setIsScrolled] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [scrollbarVisible, setScrollbarVisible] = useState(false); const scrollRef = useRef(null); const contentRef = useRef(null); const composerRef = useRef(null); const sendingRef = useRef(false); const pinnedToBottomRef = useRef(true); const programmaticScrollRef = useRef(false); const lastScrollTopRef = useRef(0); const programmaticScrollTimer = useRef(undefined); const scrollHideTimer = useRef(undefined); + const knownMessageIds = useRef(new Set()); const knownAgentId = useRef(agent.id); const wasLoading = useRef(loading); const [enteringMessageIds, setEnteringMessageIds] = useState>(() => new Set()); + useLayoutEffect(() => { + const reset = knownAgentId.current !== agent.id || wasLoading.current; + knownAgentId.current = agent.id; wasLoading.current = loading; + if (loading || reset) { knownMessageIds.current = new Set(messages.map((message) => message.id)); setEnteringMessageIds(new Set()); return; } + const added = messages.filter((message) => !knownMessageIds.current.has(message.id)).map((message) => message.id); + for (const id of added) knownMessageIds.current.add(id); + setEnteringMessageIds(new Set(added)); + }, [agent.id, loading, messages]); + function scrollToBottom(behavior: ScrollBehavior) { + const element = scrollRef.current; + if (!element || typeof element.scrollTo !== "function") return; + pinnedToBottomRef.current = true; programmaticScrollRef.current = true; setShowScrollToBottom(false); + if (programmaticScrollTimer.current) window.clearTimeout(programmaticScrollTimer.current); + element.scrollTo({ top: element.scrollHeight, behavior }); + programmaticScrollTimer.current = window.setTimeout(() => { programmaticScrollTimer.current = undefined; programmaticScrollRef.current = false; }, behavior === "smooth" ? 400 : 0); + } + useEffect(() => { pinnedToBottomRef.current = true; programmaticScrollRef.current = false; lastScrollTopRef.current = 0; setShowScrollToBottom(false); requestAnimationFrame(() => scrollToBottom("auto")); }, [agent.id]); + useEffect(() => { if (pinnedToBottomRef.current) scrollToBottom("smooth"); }, [messages]); + useEffect(() => { + const element = scrollRef.current; const content = contentRef.current; + if (!element || !content || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { if (pinnedToBottomRef.current) scrollToBottom("auto"); }); + observer.observe(content); return () => observer.disconnect(); + }, [agent.id, loading]); + useEffect(() => () => { if (scrollHideTimer.current) window.clearTimeout(scrollHideTimer.current); if (programmaticScrollTimer.current) window.clearTimeout(programmaticScrollTimer.current); }, []); + useEffect(() => { if (focusRequest > 0) composerRef.current?.focus(); }, [agent.id, focusRequest]); + function revealScrollbarBriefly() { setScrollbarVisible(true); if (scrollHideTimer.current) window.clearTimeout(scrollHideTimer.current); scrollHideTimer.current = window.setTimeout(() => { scrollHideTimer.current = undefined; setScrollbarVisible(false); }, 700); } + async function submit(event: FormEvent) { event.preventDefault(); const text = draft.trim(); if ((!text && attachments.length === 0) || sendingRef.current) return; sendingRef.current = true; setDraft(""); setSending(true); try { await onSend(text, attachments); setAttachments([]); } finally { sendingRef.current = false; 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}

{connection !== "connected" && <>{connection === "connecting" ? "Connecting" : "Connection lost"}}
-
setIsScrolled(event.currentTarget.scrollTop > 0)}>{messages.length === 0 &&
{agent.avatar}

{agent.name}

{agent.goal}

}{messages.map((message) => )}
-