diff --git a/common/src/constants.ts b/common/src/constants.ts index b19358022..e27e4cd9f 100644 --- a/common/src/constants.ts +++ b/common/src/constants.ts @@ -42,3 +42,9 @@ export const DEFAULT_SERVICE: IService = { license: 'UNKNOWN', attributes: {}, } + +// Chat popout window — the boot flag and dimensions are a contract between +// the frontend (opens the window with ?chatPopout=) and electron (allows +// the window.open and sizes the child window) +export const CHAT_POPOUT_PARAM = 'chatPopout' +export const CHAT_POPOUT_SIZE = { width: 520, height: 780, minWidth: 360, minHeight: 500 } diff --git a/docs/superpowers/plans/2026-07-27-chat-org-selector.md b/docs/superpowers/plans/2026-07-27-chat-org-selector.md new file mode 100644 index 000000000..fa3dc62db --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-chat-org-selector.md @@ -0,0 +1,682 @@ +# Chat Panel Org Selector Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** An org dropdown in the AI chat panel whose selection rides on every `/api/chat` request so the ai-agent scopes org queries without hunting for org context. + +**Architecture:** Frontend (rematch model + MUI Select in `remoteit/desktop`) sends an optional `org: { id, name }` in the chat body each turn; the ai-agent service validates it, threads it through `runChatTurn` → `runAgentLoop`, and injects a sanitized "Selected organization" section into the system prompt after the cache breakpoint. Personal account = field omitted (today's default behavior). + +**Tech Stack:** React + rematch + MUI (desktop frontend), Express + vitest (ai-agent, TypeScript ESM — note `.js` import suffixes). + +**Spec:** `docs/superpowers/specs/2026-07-27-chat-org-selector-design.md` + +## Global Constraints + +- Two repos: `/Users/larrygunteriv/github/remoteit/desktop` (branch `feature/agent-chat-interface`) and `/Users/larrygunteriv/github/remoteit/ai-agent` (create branch `feature/chat-org-scope` off current HEAD, which is `feat/docker-containerization`). NEVER commit to or push `main` in either repo. +- `org` is optional end-to-end; omitted → behavior byte-identical to today. +- Org name is user-influenced data entering the system prompt: strip control chars, collapse whitespace, cap at 100 chars. Org id must match `/^[A-Za-z0-9-]{1,64}$/`. +- The org system block goes AFTER the prompt-cache breakpoint (it changes when the user switches orgs; it must not invalidate the cached prefix). +- ai-agent uses ESM imports with `.js` suffixes (`import ... from "./systemPrompt.js"`); tests run with `npx vitest run `. +- Desktop frontend has no unit-test infra; its verification is `npm run typecheck` (run in `frontend/`) plus the manual check in Task 6. + +--- + +### Task 1: `orgSystemSection` helper (ai-agent) + +**Files:** +- Modify: `src/systemPrompt.ts` (append after the `SYSTEM_PROMPT` export) +- Test: `test/systemPrompt.org.test.ts` (new) + +**Interfaces:** +- Produces: `export type OrgSelection = { id: string; name: string }` and `export function orgSystemSection(org: OrgSelection): string | null` — `null` means "omit the section". Tasks 2–3 import both from `./systemPrompt.js`. + +- [ ] **Step 1: Create the ai-agent feature branch** + +```bash +cd /Users/larrygunteriv/github/remoteit/ai-agent +git status --short # confirm no unrelated staged changes; leave any untracked files alone +git checkout -b feature/chat-org-scope +``` + +- [ ] **Step 2: Write the failing test** + +Create `test/systemPrompt.org.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { orgSystemSection } from "../src/systemPrompt.js"; + +describe("orgSystemSection", () => { + it("renders the section with name and accountId", () => { + const s = orgSystemSection({ id: "org-123-abc", name: "Acme Inc" }); + expect(s).toContain("## Selected organization"); + expect(s).toContain('organization "Acme Inc"'); + expect(s).toContain("accountId `org-123-abc`"); + expect(s).toContain("unless the user explicitly asks"); + }); + + it("strips control characters and collapses whitespace in the name", () => { + const s = orgSystemSection({ id: "org-1", name: "Acme\nInc\t\u0000 Corp" }); + expect(s).toContain('organization "Acme Inc Corp"'); + expect(s).not.toContain("Acme\nInc"); + }); + + it("caps the name at 100 characters", () => { + const s = orgSystemSection({ id: "org-1", name: "x".repeat(500) }); + expect(s).toContain(`"${"x".repeat(100)}"`); + expect(s).not.toContain("x".repeat(101)); + }); + + it("returns null for an id that fails the allowlist", () => { + expect(orgSystemSection({ id: "bad id\nwith spaces", name: "Acme" })).toBeNull(); + expect(orgSystemSection({ id: "", name: "Acme" })).toBeNull(); + expect(orgSystemSection({ id: "x".repeat(65), name: "Acme" })).toBeNull(); + }); + + it("returns null when the name is empty after sanitization", () => { + expect(orgSystemSection({ id: "org-1", name: "\u0000\u0001 \n " })).toBeNull(); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run test/systemPrompt.org.test.ts` +Expected: FAIL — `orgSystemSection` is not exported. + +- [ ] **Step 4: Implement** + +Append to `src/systemPrompt.ts`: + +```typescript +export type OrgSelection = { id: string; name: string }; + +/** + * System section for the org the user selected in the app. The name is + * user-influenced data entering the system prompt, so it is sanitized; + * returns null (omit the section) if either value doesn't survive. + */ +export function orgSystemSection(org: OrgSelection): string | null { + const id = org.id.trim(); + if (!/^[A-Za-z0-9-]{1,64}$/.test(id)) return null; + const name = org.name + .replace(/[\u0000-\u001f\u007f]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 100); + if (!name) return null; + return `## Selected organization\n\nThe user has selected organization "${name}" (accountId \`${id}\`) in the app. Use this accountId for org-scoped tools unless the user explicitly asks about a different organization or their personal account.`; +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run test/systemPrompt.org.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/systemPrompt.ts test/systemPrompt.org.test.ts +git commit -m "feat(org): sanitized system-prompt section for the selected org" +``` + +--- + +### Task 2: Org block in the agent loop's system prompt (ai-agent) + +**Files:** +- Modify: `src/agentLoop.ts` (deps interface ~line 87–107, system assembly ~line 149–158) +- Test: `test/agentLoop.org.test.ts` (new) + +**Interfaces:** +- Consumes: `orgSystemSection`, `OrgSelection` from Task 1. +- Produces: `AgentLoopDeps` gains `org?: OrgSelection`. Task 3 sets it from `runChatTurn`. + +- [ ] **Step 1: Write the failing test** + +Create `test/agentLoop.org.test.ts` (fake-anthropic pattern copied from `test/agentLoop.wait.test.ts`, extended to capture the stream params): + +```typescript +import { describe, expect, it } from "vitest"; +import type Anthropic from "@anthropic-ai/sdk"; +import { runAgentLoop } from "../src/agentLoop.js"; +import type { AuditLogger } from "../src/auditLog.js"; +import type { McpConnection } from "../src/mcp/types.js"; + +type SystemBlock = { type: string; text: string; cache_control?: { type: string } }; + +/** Fake anthropic that records each stream() call's params and ends the turn. */ +function capturingAnthropic(captured: Array<{ system: SystemBlock[] }>): Anthropic { + return { + messages: { + stream: (params: { system: SystemBlock[] }) => { + captured.push(params); + return { on: () => {}, finalMessage: async () => ({ stop_reason: "end_turn", content: [] }) }; + }, + }, + } as unknown as Anthropic; +} + +const idleMcp: McpConnection = { + listTools: async () => [], + callTool: async () => ({ text: "{}", isError: false }), + close: async () => {}, +}; + +const audit = { log: () => {} } as unknown as AuditLogger; + +function baseDeps(captured: Array<{ system: SystemBlock[] }>) { + return { + anthropic: capturingAnthropic(captured), + mcp: idleMcp, + audit, + emit: () => {}, + classify: () => "read" as const, + waitForConfirmation: async () => true, + }; +} + +const turn = [{ role: "user" as const, content: "list my devices" }]; + +describe("org scope in the system prompt", () => { + it("appends the org section after the cache breakpoint", async () => { + const captured: Array<{ system: SystemBlock[] }> = []; + await runAgentLoop( + { ...baseDeps(captured), org: { id: "org-123", name: "Acme Inc" } }, + "conv-org", + turn, + ); + const system = captured[0].system; + const last = system[system.length - 1]; + expect(last.text).toContain("accountId `org-123`"); + expect(last.cache_control).toBeUndefined(); + expect(system[system.length - 2].cache_control).toEqual({ type: "ephemeral" }); + }); + + it("without org, the last system block carries the cache breakpoint", async () => { + const captured: Array<{ system: SystemBlock[] }> = []; + await runAgentLoop(baseDeps(captured), "conv-no-org", turn); + const system = captured[0].system; + expect(system[system.length - 1].cache_control).toEqual({ type: "ephemeral" }); + expect(system.some((b) => b.text.includes("## Selected organization"))).toBe(false); + }); + + it("drops an org that fails sanitization instead of injecting it", async () => { + const captured: Array<{ system: SystemBlock[] }> = []; + await runAgentLoop( + { ...baseDeps(captured), org: { id: "bad id", name: "Acme" } }, + "conv-bad-org", + turn, + ); + const system = captured[0].system; + expect(system.some((b) => b.text.includes("## Selected organization"))).toBe(false); + expect(system[system.length - 1].cache_control).toEqual({ type: "ephemeral" }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/agentLoop.org.test.ts` +Expected: FAIL — TypeScript rejects the unknown `org` dep / the first assertion finds no org text. + +- [ ] **Step 3: Implement** + +In `src/agentLoop.ts`: + +1. Add to the imports from `./systemPrompt.js`: `orgSystemSection` and `type OrgSelection` (the file already imports `SYSTEM_PROMPT` from there). +2. Add to `AgentLoopDeps` (after `extraSystem`): + +```typescript + /** Org the user selected in the app; injected as a system section. */ + org?: OrgSelection; +``` + +3. Replace the system-assembly block (currently ends with `system[system.length - 1].cache_control = { type: "ephemeral" };`) with: + +```typescript + const system: TextBlockParam[] = [{ type: "text", text: SYSTEM_PROMPT }]; + if (deps.extraSystem) { + system.push({ + type: "text", + text: `## remote.it query cookbook (published by the MCP server)\n\n${deps.extraSystem}`, + }); + } + system[system.length - 1].cache_control = { type: "ephemeral" }; + // The org block rides after the cache breakpoint: it is tiny and changes + // when the user switches orgs, so it must not invalidate the cached prefix. + const orgSection = deps.org && orgSystemSection(deps.org); + if (orgSection) system.push({ type: "text", text: orgSection }); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/agentLoop.org.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: Run the full suite and typecheck** + +Run: `npx vitest run && npm run typecheck` +Expected: all existing tests still PASS; tsc clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/agentLoop.ts test/agentLoop.org.test.ts +git commit -m "feat(org): inject selected-org section into the agent system prompt" +``` + +--- + +### Task 3: Wire protocol — request validation and threading (ai-agent) + +**Files:** +- Modify: `src/server.ts` (`parseChatRequest` ~line 24–33, `/api/chat` route ~line 136) +- Modify: `src/chatService.ts` (`runChatTurn` signature ~line 48, `runAgentLoop` deps ~line 83–99) +- Test: `test/server.org.test.ts` (new) + +**Interfaces:** +- Consumes: `OrgSelection` from Task 1, `AgentLoopDeps.org` from Task 2. +- Produces: + - `parseChatRequest` is now exported; returns `{ conversationId: string; messages: MessageParam[]; org?: OrgSelection } | { error: string }`. + - `runChatTurn(services, ctx, conversationId, messages, emit, signal?, org?)` — new trailing optional `org?: OrgSelection`. The GraphQL transport (`src/graphql/schema.ts`) is deliberately NOT changed; it simply never passes `org`. + - Task 4's request body: `{ conversationId, messages, org? }`. + +- [ ] **Step 1: Write the failing test** + +Create `test/server.org.test.ts` (boot helper copied from `test/server.auth.test.ts`; env mode so no bearer is needed — malformed bodies are rejected before any turn machinery runs): + +```typescript +import { afterAll, describe, expect, it } from "vitest"; +import type { AddressInfo } from "node:net"; +import type http from "node:http"; +import { createServer, parseChatRequest } from "../src/server.js"; +import type { ChatServices } from "../src/chatService.js"; + +function makeServices(): ChatServices { + return { + config: { toolClassificationOverrides: {} } as ChatServices["config"], + anthropic: {} as ChatServices["anthropic"], + audit: { log: () => {}, withUser: () => ({ log: () => {} }) } as unknown as ChatServices["audit"], + tokenProvider: { getMcpAuth: async () => ({ token: "t", sub: "s", email: "e" }) } as unknown as ChatServices["tokenProvider"], + connectMcp: async () => ({ + listTools: async () => [], + callTool: async () => ({ text: "", isError: false }), + close: async () => {}, + }), + }; +} + +const servers: http.Server[] = []; + +async function boot(): Promise { + const { httpServer } = createServer(makeServices(), { mode: "env" }); + await new Promise((resolve) => httpServer.listen(0, resolve)); + servers.push(httpServer); + const { port } = httpServer.address() as AddressInfo; + return `http://127.0.0.1:${port}`; +} + +afterAll(async () => { + await Promise.all(servers.map((s) => new Promise((resolve) => s.close(resolve)))); +}); + +const validTurn = { conversationId: "c1", messages: [{ role: "user", content: "hi" }] }; + +describe("POST /api/chat org validation", () => { + it.each([ + ["non-object org", { ...validTurn, org: "acme" }], + ["missing name", { ...validTurn, org: { id: "org-1" } }], + ["empty id", { ...validTurn, org: { id: "", name: "Acme" } }], + ["whitespace name", { ...validTurn, org: { id: "org-1", name: " " } }], + ["non-string id", { ...validTurn, org: { id: 42, name: "Acme" } }], + ])("400s on %s", async (_label, body) => { + const base = await boot(); + const res = await fetch(`${base}/api/chat`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(400); + }); +}); + +describe("parseChatRequest org passthrough", () => { + it("accepts a valid org", () => { + const parsed = parseChatRequest({ ...validTurn, org: { id: "org-1", name: "Acme" } }); + expect(parsed).toMatchObject({ conversationId: "c1", org: { id: "org-1", name: "Acme" } }); + }); + + it("accepts an omitted org", () => { + const parsed = parseChatRequest(validTurn); + expect("error" in parsed).toBe(false); + expect((parsed as { org?: unknown }).org).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/server.org.test.ts` +Expected: FAIL — `parseChatRequest` is not exported; malformed-org bodies currently return 200 (SSE), not 400. + +- [ ] **Step 3: Implement** + +In `src/server.ts`: + +1. Import the type: `import type { OrgSelection } from "./systemPrompt.js";` +2. Replace `parseChatRequest` with (note the added `export`): + +```typescript +/** Validate the /api/chat body, returning a typed turn or an error message. */ +export function parseChatRequest( + body: unknown, +): { conversationId: string; messages: MessageParam[]; org?: OrgSelection } | { error: string } { + const b = body as { conversationId?: unknown; messages?: unknown; org?: unknown }; + if (typeof b.conversationId !== "string" || !Array.isArray(b.messages) || b.messages.length === 0) { + return { error: "Body must be { conversationId: string, messages: MessageParam[] }" }; + } + let org: OrgSelection | undefined; + if (b.org !== undefined) { + const o = b.org as { id?: unknown; name?: unknown }; + if ( + typeof b.org !== "object" || + b.org === null || + typeof o.id !== "string" || + !o.id.trim() || + typeof o.name !== "string" || + !o.name.trim() + ) { + return { error: "org must be { id: string, name: string } with non-empty values" }; + } + org = { id: o.id, name: o.name }; + } + return { conversationId: b.conversationId, messages: b.messages as MessageParam[], org }; +} +``` + +3. Pass it through in the `/api/chat` route: + +```typescript + await runChatTurn(services, restContext(req), parsed.conversationId, parsed.messages, emit, controller.signal, parsed.org); +``` + +In `src/chatService.ts`: + +1. Import the type: `import type { OrgSelection } from "./systemPrompt.js";` +2. Add the trailing parameter to `runChatTurn`: + +```typescript +export async function runChatTurn( + services: ChatServices, + ctx: TokenRequestContext, + conversationId: string, + messages: MessageParam[], + emit: AgentEmitter, + signal?: AbortSignal, + org?: OrgSelection, +): Promise { +``` + +3. Inside the tools branch, after `const turnAudit = audit.withUser({ sub, email });`, add the audit entry: + +```typescript + if (org) turnAudit.log({ event: "org_scope", conversationId, detail: org.id }); +``` + +4. Add `org` to the `runAgentLoop` deps object (next to `extraSystem`): + +```typescript + extraSystem: cookbook ?? undefined, + org, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/server.org.test.ts` +Expected: PASS (7 tests). + +- [ ] **Step 5: Run the full suite and typecheck** + +Run: `npx vitest run && npm run typecheck` +Expected: all PASS; tsc clean (the unchanged GraphQL call site is fine — `org` is optional). + +- [ ] **Step 6: Commit** + +```bash +git add src/server.ts src/chatService.ts test/server.org.test.ts +git commit -m "feat(org): accept and thread the selected org through /api/chat" +``` + +--- + +### Task 4: Frontend client — send `org` on the wire (desktop) + +**Files:** +- Modify: `frontend/src/services/agent.ts` (types ~line 58–67, `streamChat` ~line 69–81) + +**Interfaces:** +- Consumes: the Task 3 body shape `{ conversationId, messages, org? }`. +- Produces: `export type OrgSelection = { id: string; name: string }` and `streamChat` options gain `org?: OrgSelection`. Task 5 imports `OrgSelection` from `../services/agent`. + +- [ ] **Step 1: Implement** + +In `frontend/src/services/agent.ts`, add the type next to `AgentMessageParam`: + +```typescript +export type OrgSelection = { id: string; name: string } +``` + +Extend `streamChat`'s options and body (only the changed lines shown): + +```typescript +export async function streamChat(options: { + conversationId: string + messages: AgentMessageParam[] + org?: OrgSelection + signal?: AbortSignal + onEvent: (event: AgentEvent) => void +}): Promise { + const { conversationId, messages, org, signal, onEvent } = options + const response = await fetch(`${AGENT_URL}/api/chat`, { + method: 'POST', + headers: agentHeaders(), + body: JSON.stringify(org ? { conversationId, messages, org } : { conversationId, messages }), + signal, + }) +``` + +- [ ] **Step 2: Typecheck** + +Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck` +Expected: clean (no errors introduced; callers pass `org` as optional). + +- [ ] **Step 3: Commit** + +```bash +cd /Users/larrygunteriv/github/remoteit/desktop +git add frontend/src/services/agent.ts +git commit -m "feat(chat): optional org field on the agent chat request" +``` + +--- + +### Task 5: Chat model — org state, defaulting, and send integration (desktop) + +**Files:** +- Modify: `frontend/src/models/chat.ts` (state ~line 27–47, `send` effect ~line 110–134, new `syncOrg` effect) + +**Interfaces:** +- Consumes: `OrgSelection` from Task 4; app state `state.user.id`, `state.accounts.activeId`, `state.accounts.membership` (items have `account.id`), `state.organization.accounts` (lookup by account id, has `.name`) — the same sources `frontend/src/components/OrganizationSelect.tsx` uses. +- Produces: `IChatState.orgId: string | null`; effect `dispatch.chat.syncOrg()` (Task 6 calls it when the panel opens); `dispatch.chat.set({ orgId })` (Task 6's dropdown calls it). + +- [ ] **Step 1: Implement state** + +In `frontend/src/models/chat.ts`: + +1. Add `OrgSelection` to the imports from `'../services/agent'`. +2. Add to `IChatState` (after `conversationId`): + +```typescript + /** Org the agent is scoped to; null = uninitialized, user id = personal */ + orgId: string | null +``` + +3. Add to `defaultChatState`: `orgId: null,` + +- [ ] **Step 2: Implement `syncOrg`** + +Add to `effects` (after `send`). Runs when the panel opens: adopt the app's active org unless the current selection is still valid, so the chat org defaults to what the user is looking at but can diverge afterward: + +```typescript + /* Default the chat org to the app's active org when unset or no longer valid */ + async syncOrg(_: void, state) { + const userId = state.user.id + const validIds = new Set([userId, ...state.accounts.membership.map(m => m.account.id)]) + if (!state.chat.orgId || !validIds.has(state.chat.orgId)) { + dispatch.chat.set({ orgId: state.accounts.activeId || userId }) + } + }, +``` + +- [ ] **Step 3: Implement send integration** + +In the `send` effect, before the `streamChat` call, resolve the selection (personal account → `undefined`, per spec decision 4; a selection whose org data is missing → `undefined` rather than a value the server would 400 on): + +```typescript + const orgId = state.chat.orgId + let org: OrgSelection | undefined + if (orgId && orgId !== state.user.id) { + const name = state.organization.accounts[orgId]?.name + const isMember = state.accounts.membership.some(m => m.account.id === orgId) + if (name && isMember) org = { id: orgId, name } + } +``` + +and pass it through: + +```typescript + await streamChat({ + conversationId, + messages, + org, + signal: abortController.signal, + onEvent: event => dispatch.chat.applyEvent(event), + }) +``` + +- [ ] **Step 4: Typecheck** + +Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck` +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/larrygunteriv/github/remoteit/desktop +git add frontend/src/models/chat.ts +git commit -m "feat(chat): org selection state scoped to the chat panel" +``` + +--- + +### Task 6: Dropdown UI + panel wiring + manual verification (desktop) + +**Files:** +- Create: `frontend/src/components/Chat/ChatOrgSelect.tsx` +- Modify: `frontend/src/components/Chat/ChatPanel.tsx` (open effect ~line 23–28, header ~line 53–66) + +**Interfaces:** +- Consumes: `state.chat.orgId`, `dispatch.chat.set({ orgId })`, `dispatch.chat.syncOrg()` from Task 5; membership/org-name state as in Task 5. +- Produces: ``, rendered directly below the chat header. + +- [ ] **Step 1: Create the component** + +Create `frontend/src/components/Chat/ChatOrgSelect.tsx`. Mirrors `OrganizationSelect.tsx`'s data sourcing: memberships joined to `organization.accounts` for names, orgs whose data hasn't loaded are skipped, sorted by name; hidden entirely when the user has no orgs. + +```tsx +import React from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { Box, TextField, MenuItem } from '@mui/material' +import { State, Dispatch } from '../../store' + +/* Org the agent is scoped to — defaults to the app's active org (models/chat + syncOrg) but diverges freely; a change applies from the next turn */ +export const ChatOrgSelect: React.FC = () => { + const dispatch = useDispatch() + const orgId = useSelector((state: State) => state.chat.orgId) + const userId = useSelector((state: State) => state.user.id) + const memberships = useSelector((state: State) => state.accounts.membership) + const organizations = useSelector((state: State) => state.organization.accounts) + + const options = memberships + .map(m => ({ id: m.account.id, name: organizations[m.account.id]?.name || '' })) + .filter(o => o.name) + .sort((a, b) => a.name.localeCompare(b.name)) + + if (!options.length) return null + + return ( + + dispatch.chat.set({ orgId: event.target.value })} + > + Personal + {options.map(o => ( + + {o.name} + + ))} + + + ) +} +``` + +- [ ] **Step 2: Wire into the panel** + +In `frontend/src/components/Chat/ChatPanel.tsx`: + +1. Import: `import { ChatOrgSelect } from './ChatOrgSelect'` +2. Add `dispatch.chat.syncOrg()` to the open effect: + +```typescript + useEffect(() => { + if (chat.open) { + dispatch.chat.resetTransient() + dispatch.chat.syncOrg() + dispatch.chat.checkHealth() + } + }, [chat.open]) +``` + +3. Render `` immediately after the header `` (the one closing at line 66), before the health notices. + +- [ ] **Step 3: Typecheck** + +Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck` +Expected: clean. + +- [ ] **Step 4: Manual verification** + +1. Start the agent service: `cd /Users/larrygunteriv/github/remoteit/ai-agent && npm run dev` (port 3001). +2. Start the frontend: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm start` (port 3003), sign in, open the chat panel. +3. Verify the dropdown sits below the header and defaults to the org the app sidebar has active (or Personal). +4. With devtools → Network open, send a message with an org selected: the `/api/chat` request body contains `"org":{"id":...,"name":...}`. +5. Select Personal, send again: the body has no `org` key. +6. Switch org mid-conversation and send: transcript is kept; the new org appears in the next request body. +7. Ask the agent to "list this organization's devices": it should use the accountId directly (tool call input shows the selected org id) without a `whoami`/membership lookup first. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/larrygunteriv/github/remoteit/desktop +git add frontend/src/components/Chat/ChatOrgSelect.tsx frontend/src/components/Chat/ChatPanel.tsx +git commit -m "feat(chat): org selector dropdown in the chat panel" +``` diff --git a/docs/superpowers/plans/2026-07-28-chat-popout-window.md b/docs/superpowers/plans/2026-07-28-chat-popout-window.md new file mode 100644 index 000000000..4238e85e8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-chat-popout-window.md @@ -0,0 +1,665 @@ +# Chat Popout Window Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Pop the chat panel out into its own window (browser + Electron) with move semantics — the docked panel hides while popped out and the conversation hands back intact. + +**Architecture:** The popout loads the same app bundle with a `?chatPopout` boot flag; `App.tsx` renders a bare `ChatWindow` instead of the app shell. A BroadcastChannel (`remoteit-chat-popout`) carries the hand-off protocol (hello/adopt/handback/ping/alive/signout) with dependency-injected handlers so the service never imports the store (no circular imports). Electron's `setWindowOpenHandler` gains an allow-branch for the app's own popout URL. + +**Tech Stack:** React + rematch + MUI, BroadcastChannel API, Electron BrowserWindow options. + +**Spec:** `docs/superpowers/specs/2026-07-28-chat-popout-window-design.md` + +## Global Constraints + +- Repo `/Users/larrygunteriv/github/remoteit/desktop`, branch `feature/agent-chat-interface`. NEVER commit to or push main. +- The repo has UNRELATED uncommitted changes (`.npmrc`, `frontend/package.json`, `frontend/src/components/Icon.tsx`) — never touch or stage them. +- Everything stays behind the existing `MODE === 'development'` gate; mobile (`browser.isMobile`) never shows the pop-out button. +- Move semantics: popping out hides the docked panel; hand-off payloads travel IN the BroadcastChannel messages, never via storage ordering (persistence is localForage/IndexedDB and both windows write the same key). +- Channel name `remoteit-chat-popout`; window name `remoteit-chat`; window features `popup=yes,width=520,height=780`; Electron override `{ width: 520, height: 780, minWidth: 360, minHeight: 500, autoHideMenuBar: true }`. +- Frontend verification is `cd frontend && npm run typecheck` (no unit-test infra). Electron verification is `cd electron && npm run typecheck`. +- Run `npx prettier --write ` (from `frontend/`) before each frontend commit. + +--- + +### Task 1: Extract `ChatBody` from `ChatPanel` + +**Files:** +- Create: `frontend/src/components/Chat/ChatBody.tsx` +- Modify: `frontend/src/components/Chat/ChatPanel.tsx` + +**Interfaces:** +- Consumes: existing `ChatOrgSelect`, `ChatMessages`, `ChatApproval`, `ChatInput`, `Notice` components; `state.chat` slice. +- Produces: `export const ChatBody: React.FC` (no props) — the org select, health notices, message list w/ approval + error, and input. Tasks 2–3 render it from `ChatWindow` and `ChatPanel`. + +- [ ] **Step 1: Create ChatBody** + +Create `frontend/src/components/Chat/ChatBody.tsx` — this is a pure move of ChatPanel's content below the header (currently `ChatPanel.tsx:69-110`): + +```tsx +import React from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { Button } from '@mui/material' +import { State, Dispatch } from '../../store' +import { ChatMessages } from './ChatMessages' +import { ChatApproval } from './ChatApproval' +import { ChatInput } from './ChatInput' +import { ChatOrgSelect } from './ChatOrgSelect' +import { Notice } from '../Notice' + +/* Everything below the chat header — shared by the docked panel and the + popout window */ +export const ChatBody: React.FC = () => { + const chat = useSelector((state: State) => state.chat) + const dispatch = useDispatch() + + return ( + <> + + {chat.health === 'unreachable' && ( + + Agent unreachable — is the dev service running on :3001? + + )} + {chat.health === 'unauthorized' && ( + + <> + The AI agent needs its own sign-in to act on your behalf. + + + + )} + + {chat.pendingConfirmation && ( + dispatch.chat.confirm(approved)} + /> + )} + {chat.error && ( + dispatch.chat.set({ error: null })}> + {chat.error} + + )} + + dispatch.chat.send(text)} + onStop={() => dispatch.chat.stop()} + /> + + ) +} +``` + +- [ ] **Step 2: Use it in ChatPanel** + +In `frontend/src/components/Chat/ChatPanel.tsx`, replace everything after the header `` (the ``, both `Notice` blocks, ``, and `` — currently lines 69–110) with: + +```tsx + +``` + +and update imports: add `import { ChatBody } from './ChatBody'`; remove the now-unused imports `Button` (keep `Box`, `Typography` from @mui/material), `ChatMessages`, `ChatApproval`, `ChatInput`, `ChatOrgSelect`, and `Notice`. + +- [ ] **Step 3: Typecheck** + +Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck` +Expected: clean — this is a pure extraction. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npx prettier --write src/components/Chat/ChatBody.tsx src/components/Chat/ChatPanel.tsx +cd /Users/larrygunteriv/github/remoteit/desktop +git add frontend/src/components/Chat/ChatBody.tsx frontend/src/components/Chat/ChatPanel.tsx +git commit -m "refactor(chat): extract ChatBody shared by panel and popout" +``` + +--- + +### Task 2: Boot flag, popout service skeleton, and `ChatWindow` + +**Files:** +- Create: `frontend/src/services/chatPopout.ts` +- Create: `frontend/src/components/Chat/ChatWindow.tsx` +- Modify: `frontend/src/components/App.tsx` + +**Interfaces:** +- Consumes: `ChatBody` from Task 1; `MODE` from `../constants`; `store` (components only, never the service). +- Produces (Task 3 relies on these exact names): + - `chatPopout.ts`: `isChatPopout: boolean`, `CHAT_POPOUT_FLAG = 'chatPopout'`, `type ChatHandoff = { messages: ChatTranscriptMessage[]; conversationId: string; orgId: string | null }`. + - `ChatWindow: React.FC` — full-page chat for the popout. + +- [ ] **Step 1: Create the service with the boot flag** + +Create `frontend/src/services/chatPopout.ts`: + +```ts +// import type only: the chat model value-imports this service (signout +// broadcast), so a value import here would create a runtime cycle +import type { ChatTranscriptMessage } from '../models/chat' + +/** + * Chat popout: the panel moves into its own window (same bundle, boot flag) + * and the conversation hands off over a BroadcastChannel. This module owns + * the flag, the channel, and the protocol; it never imports the store — + * callers inject handlers (avoids store/model import cycles). + */ +export const CHAT_POPOUT_FLAG = 'chatPopout' + +// Captured at module-evaluation time, before any routing can touch the URL +// (same pattern as the hydra ?code capture in services/hydra.ts) +export const isChatPopout = new URLSearchParams(window.location.search).has(CHAT_POPOUT_FLAG) + +export type ChatHandoff = { + messages: ChatTranscriptMessage[] + conversationId: string + orgId: string | null +} +``` + +- [ ] **Step 2: Create ChatWindow** + +Create `frontend/src/components/Chat/ChatWindow.tsx` (protocol wiring comes in Task 3 — this step renders a working standalone chat): + +```tsx +import React, { useEffect } from 'react' +import { useDispatch } from 'react-redux' +import { Box, Typography } from '@mui/material' +import { Dispatch } from '../../store' +import { IconButton } from '../../buttons/IconButton' +import { ChatBody } from './ChatBody' + +/* Full-page chat for the popped-out window (?chatPopout boot flag). The + window chrome provides close; pop-in wiring lands with the protocol. */ +export const ChatWindow: React.FC = () => { + const dispatch = useDispatch() + + useEffect(() => { + document.title = 'remote.it chat' + dispatch.chat.resetTransient() + dispatch.chat.syncOrg() + dispatch.chat.checkHealth() + }, []) + + return ( + + + + New Chat + + dispatch.chat.clearConversation()} /> + + + + ) +} +``` + +- [ ] **Step 3: Branch in App.tsx** + +In `frontend/src/components/App.tsx`: + +1. Add imports: + +```tsx +import { ChatWindow } from './Chat/ChatWindow' +import { isChatPopout } from '../services/chatPopout' +``` + +2. Replace the final `return` block's PersistGate content (currently the layout `` + `{showBottomMenu && }`) so the popout renders only the chat: + +```tsx + return ( + + + }> + {MODE === 'development' && isChatPopout ? ( + + ) : ( + <> + + {hideSidebar ? : } + + {MODE === 'development' && } + + {showBottomMenu && } + + )} + + + ) +``` + +All pre-auth gates above the final return stay untouched (sign-in still works in the popout if needed). + +- [ ] **Step 4: Typecheck and verify render** + +Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck` +Expected: clean. + +If the vite dev server is running, open `http://localhost:3003/?chatPopout` in a browser tab — the bare chat should render (transcript rehydrates from persistence), no sidebar/router. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npx prettier --write src/services/chatPopout.ts src/components/Chat/ChatWindow.tsx src/components/App.tsx +cd /Users/larrygunteriv/github/remoteit/desktop +git add frontend/src/services/chatPopout.ts frontend/src/components/Chat/ChatWindow.tsx frontend/src/components/App.tsx +git commit -m "feat(chat): standalone chat window behind ?chatPopout boot flag" +``` + +--- + +### Task 3: Hand-off protocol, pop-out/pop-in buttons, crash resilience + +**Files:** +- Modify: `frontend/src/services/chatPopout.ts` +- Modify: `frontend/src/models/chat.ts` +- Modify: `frontend/src/components/Chat/ChatPanel.tsx` +- Modify: `frontend/src/components/Chat/ChatWindow.tsx` + +**Interfaces:** +- Consumes: Task 2's `ChatHandoff`, `CHAT_POPOUT_FLAG`, `isChatPopout`; chat model reducers `set`, `adoptTranscript` (new). +- Produces: + - Service: `openChatPopout(): boolean`, `initChatPopoutMain(handlers: PopoutMainHandlers): void`, `checkPopoutPresence(handlers: PopoutMainHandlers): void`, `initChatPopoutWindow(handlers: PopoutWindowHandlers): void`, `popIn(payload: ChatHandoff): void`, `broadcastChatSignout(): void`. + - Model: `IChatState.poppedOut: boolean`; reducer `adoptTranscript(state, payload: ChatHandoff)`. + +- [ ] **Step 1: Model additions** + +In `frontend/src/models/chat.ts`: + +1. Add to `IChatState` (after `orgId`) and to `defaultChatState` (`poppedOut: false`): + +```ts + /** Conversation currently lives in the popout window (main window only) */ + poppedOut: boolean +``` + +2. Add the import at the top: `import { ChatHandoff, broadcastChatSignout } from '../services/chatPopout'` + +3. Add reducer (next to `clearConversation`): + +```ts + /* Hand-off: replace the conversation with the other window's copy */ + adoptTranscript(state: IChatState, payload: ChatHandoff) { + state.messages = payload.messages + state.conversationId = payload.conversationId + state.orgId = payload.orgId + return state + }, +``` + +4. In the `signOut` effect, broadcast to the popout FIRST (it closes without a handback; sign-out clears the transcript anyway): + +```ts + async signOut() { + broadcastChatSignout() + abortController?.abort() + abortController = null + dispatch.chat.reset() + await agentSignOut() + }, +``` + +Note: `services/chatPopout.ts` must not import the store or any model — the import direction is model → service only. + +- [ ] **Step 2: Protocol implementation in the service** + +Append to `frontend/src/services/chatPopout.ts`: + +```ts +type PopoutMessage = + | { type: 'hello' } + | { type: 'adopt'; payload: ChatHandoff } + | { type: 'handback'; payload: ChatHandoff } + | { type: 'ping' } + | { type: 'alive' } + | { type: 'signout' } + +export type PopoutMainHandlers = { + getHandoff: () => ChatHandoff + /** handback arrived: apply the transcript and reopen the dock */ + adopt: (payload: ChatHandoff) => void + /** popout said hello: hide the dock */ + onPopoutOpened: () => void + /** popout vanished without a handback: reopen the dock as-is */ + onPopoutLost: () => void + /** boot reconciliation: does a popout exist right now? */ + onPresence: (present: boolean) => void +} + +export type PopoutWindowHandlers = { + adopt: (payload: ChatHandoff) => void + getHandoff: () => ChatHandoff + onSignout: () => void +} + +const CHANNEL = 'remoteit-chat-popout' +const WINDOW_NAME = 'remoteit-chat' +const WINDOW_FEATURES = 'popup=yes,width=520,height=780' +const POLL_INTERVAL = 2000 +const PRESENCE_TIMEOUT = 500 + +const channel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(CHANNEL) : null +const post = (message: PopoutMessage) => channel?.postMessage(message) + +let popoutWindow: Window | null = null +let pollTimer: number | undefined +let alivePending = false +let suppressHandback = false + +/* ---------- main-window side ---------- */ + +export function openChatPopout(): boolean { + const opened = window.open(`${window.location.origin}/?${CHAT_POPOUT_FLAG}`, WINDOW_NAME, WINDOW_FEATURES) + if (!opened) return false // popup blocked — dock stays; hello never arrives + popoutWindow = opened + return true +} + +export function initChatPopoutMain(handlers: PopoutMainHandlers): void { + if (!channel) return + channel.addEventListener('message', (event: MessageEvent) => { + switch (event.data.type) { + case 'hello': + post({ type: 'adopt', payload: handlers.getHandoff() }) + handlers.onPopoutOpened() + startPolling(handlers) + break + case 'handback': + stopPolling() + handlers.adopt(event.data.payload) + break + case 'alive': + alivePending = false + break + } + }) +} + +/* Ask whether a popout survives from a previous page load; corrects a stale + persisted poppedOut flag either way */ +export function checkPopoutPresence(handlers: PopoutMainHandlers): void { + if (!channel) { + handlers.onPresence(false) + return + } + alivePending = true + post({ type: 'ping' }) + window.setTimeout(() => { + if (alivePending) { + handlers.onPresence(false) + } else { + handlers.onPresence(true) + startPolling(handlers) + } + }, PRESENCE_TIMEOUT) +} + +export function broadcastChatSignout(): void { + post({ type: 'signout' }) +} + +/* Crash net: a popout that dies without beforeunload still restores the + dock. Uses the window handle when we have one (same page load), pings + otherwise (main was reloaded while popped out). */ +function startPolling(handlers: PopoutMainHandlers) { + if (pollTimer) return + pollTimer = window.setInterval(() => { + if (popoutWindow) { + if (popoutWindow.closed) lost(handlers) + return + } + alivePending = true + post({ type: 'ping' }) + window.setTimeout(() => { + if (alivePending && pollTimer) lost(handlers) + }, PRESENCE_TIMEOUT) + }, POLL_INTERVAL) +} + +function stopPolling() { + if (pollTimer) window.clearInterval(pollTimer) + pollTimer = undefined + popoutWindow = null +} + +function lost(handlers: PopoutMainHandlers) { + stopPolling() + handlers.onPopoutLost() +} + +/* ---------- popout-window side ---------- */ + +export function initChatPopoutWindow(handlers: PopoutWindowHandlers): void { + if (!channel) return + channel.addEventListener('message', (event: MessageEvent) => { + switch (event.data.type) { + case 'adopt': + // Main's copy is authoritative at hand-off; until it arrives the + // window shows its own rehydrated (persisted) transcript + handlers.adopt(event.data.payload) + break + case 'ping': + post({ type: 'alive' }) + break + case 'signout': + suppressHandback = true // sign-out clears the transcript; nothing to hand back + handlers.onSignout() + break + } + }) + window.addEventListener('beforeunload', () => { + if (!suppressHandback) post({ type: 'handback', payload: handlers.getHandoff() }) + }) + post({ type: 'hello' }) +} + +export function popIn(payload: ChatHandoff): void { + post({ type: 'handback', payload }) + suppressHandback = true // beforeunload would duplicate it (harmless but noisy) + window.close() +} +``` + +- [ ] **Step 3: Wire the main window (ChatPanel)** + +In `frontend/src/components/Chat/ChatPanel.tsx`: + +1. Imports: add `browser` service, popout service, and store: + +```tsx +import browser from '../../services/browser' +import { store, State, Dispatch } from '../../store' +import { openChatPopout, initChatPopoutMain, checkPopoutPresence, PopoutMainHandlers, ChatHandoff } from '../../services/chatPopout' +``` + +2. Above the component, the handoff snapshot helper: + +```tsx +const currentHandoff = (): ChatHandoff => { + const c = store.getState().chat + return { messages: c.messages, conversationId: c.conversationId, orgId: c.orgId } +} +``` + +3. Inside the component, replace the existing mount effect (the one calling `handleSignInCallback`) with one that also wires the protocol: + +```tsx + // Completes a Hydra sign-in redirect if this page load carries ?code — + // runs on mount regardless of whether the panel is open + useEffect(() => { + dispatch.chat.handleSignInCallback() + const handlers: PopoutMainHandlers = { + getHandoff: currentHandoff, + adopt: payload => { + dispatch.chat.adoptTranscript(payload) + dispatch.chat.set({ poppedOut: false, open: true }) + }, + onPopoutOpened: () => dispatch.chat.set({ open: false, poppedOut: true }), + onPopoutLost: () => dispatch.chat.set({ poppedOut: false, open: true }), + onPresence: present => dispatch.chat.set(present ? { poppedOut: true, open: false } : { poppedOut: false }), + } + initChatPopoutMain(handlers) + checkPopoutPresence(handlers) + }, []) +``` + +4. Add the pop-out button to the header, before the New Chat button (browser/Electron only — never mobile): + +```tsx + {!browser.isMobile && ( + openChatPopout()} /> + )} +``` + +The dock hides when the popout's `hello` arrives — a blocked popup therefore changes nothing. + +- [ ] **Step 4: Wire the popout window (ChatWindow)** + +In `frontend/src/components/Chat/ChatWindow.tsx`: + +1. Imports: add `import { store } from '../../store'` (extend the existing store import) and `import { initChatPopoutWindow, popIn, ChatHandoff } from '../../services/chatPopout'`. + +2. Above the component: + +```tsx +const currentHandoff = (): ChatHandoff => { + const c = store.getState().chat + return { messages: c.messages, conversationId: c.conversationId, orgId: c.orgId } +} +``` + +3. In the mount effect, after `checkHealth()`: + +```tsx + initChatPopoutWindow({ + adopt: payload => dispatch.chat.adoptTranscript(payload), + getHandoff: currentHandoff, + onSignout: () => window.close(), + }) +``` + +4. Add the pop-in button after the New Chat button (stop any stream first — the open message is marked interrupted by the existing stop path): + +```tsx + { + await dispatch.chat.stop() + popIn(currentHandoff()) + }} + /> +``` + +- [ ] **Step 5: Typecheck** + +Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck` +Expected: clean. + +- [ ] **Step 6: Manual verification (browser)** + +With vite (`:3003`) and the ai-agent service (`:3001`) running, signed in, chat open with some transcript: + +1. Click **Pop out** → window opens with the transcript and org selection; docked panel hides. +2. Send a message in the popout (org scoping still applies), click **Pop back in** → window closes, dock returns with the full conversation. +3. Pop out again, close the popout with the window's X → dock returns with the conversation. +4. Pop out, then reload the MAIN window → dock stays hidden (presence ping); popout unaffected. +5. Kill the popout without unload (e.g. from a task manager, or fake it: DevTools on the popout → `window.stop()` won't do it — acceptable to skip if awkward; the `window.closed` poll path is exercised by step 3 when beforeunload is raced). +6. Sign out of the app in the main window → popout closes. + +Record what you verified in your report; note any step you could not perform. + +- [ ] **Step 7: Commit** + +```bash +cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npx prettier --write src/services/chatPopout.ts src/models/chat.ts src/components/Chat/ChatPanel.tsx src/components/Chat/ChatWindow.tsx +cd /Users/larrygunteriv/github/remoteit/desktop +git add frontend/src/services/chatPopout.ts frontend/src/models/chat.ts frontend/src/components/Chat/ChatPanel.tsx frontend/src/components/Chat/ChatWindow.tsx +git commit -m "feat(chat): pop the chat out to its own window with transcript hand-off" +``` + +--- + +### Task 4: Electron window-open allow-branch + +**Files:** +- Modify: `electron/src/ElectronApp.ts:248-252` (the `setWindowOpenHandler` block) + +**Interfaces:** +- Consumes: the popout URL shape from Task 2 (`/?chatPopout`); `this.getStartUrl()` (`ElectronApp.ts:339`). +- Produces: popout opens as a native BrowserWindow in Electron; all other URLs keep opening externally. + +- [ ] **Step 1: Implement the branch** + +Replace the current handler (`ElectronApp.ts:248-252`): + +```ts + this.window.webContents.setWindowOpenHandler(({ url }) => { + // The dev chat panel pops out into its own window (?chatPopout on our + // own origin); every other window.open goes to the system browser. + try { + const parsed = new URL(url) + if (parsed.origin === new URL(this.getStartUrl()).origin && parsed.searchParams.has('chatPopout')) { + return { + action: 'allow', + overrideBrowserWindowOptions: { + width: 520, + height: 780, + minWidth: 360, + minHeight: 500, + autoHideMenuBar: true, + }, + } + } + } catch {} + Logger.info('OPEN EXTERNAL URL', { url }) + electron.shell.openExternal(url) + return { action: 'deny' } + }) +``` + +- [ ] **Step 2: Typecheck** + +Run: `cd /Users/larrygunteriv/github/remoteit/desktop/electron && npm run typecheck` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/larrygunteriv/github/remoteit/desktop +git add electron/src/ElectronApp.ts +git commit -m "feat(electron): open the chat popout as a native window" +``` diff --git a/docs/superpowers/specs/2026-07-27-chat-org-selector-design.md b/docs/superpowers/specs/2026-07-27-chat-org-selector-design.md new file mode 100644 index 000000000..fecfda9f4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-chat-org-selector-design.md @@ -0,0 +1,106 @@ +# Chat Panel Organization Selector — Design + +**Date:** 2026-07-27 +**Repos:** `remoteit/desktop` (frontend), `remoteit/ai-agent` (backend) +**Branch:** `feature/agent-chat-interface` + +## Purpose + +Let the user pick which organization the AI agent chat is scoped to, and pass +that org to the ai-agent service so the agent no longer has to resolve org +context itself (via `whoami` + membership queries) before making org-scoped +GraphQL/MCP calls. + +## Decisions (from brainstorming) + +1. **Independent dropdown** in the chat panel, below the header. Defaults to + the app's active org (`accounts.activeId`) but can diverge from it. Not + persisted across reloads. +2. **Backend consumes the org via system prompt injection** — no MCP or tool + layer changes; the agent stays free to query other orgs when explicitly + asked. +3. **Switching org mid-conversation keeps the chat**; the new org simply + applies from the next turn. No transcript divider, no reset. +4. **Personal account = omit**: when the selection is the user's personal + account, the frontend omits the `org` field entirely. The agent's default + behavior is already personal-account scope, so nothing needs to be said. + +## Frontend (`remoteit/desktop`) + +### State — `frontend/src/models/chat.ts` + +- Add `orgId: string | null` to `IChatState` (default `null`). +- When the panel opens (existing `chat.open` effect path): if `orgId` is null + or no longer matches the user's id or any membership, set it to + `accounts.activeId`. +- `send()` resolves `orgId` to `{ id, name }`: + - Name lookup via `state.organization.accounts[orgId]?.name`, membership via + `state.accounts.membership` (same sources as `OrganizationSelect.tsx`). + - If `orgId` equals the user's own id (personal account), pass `undefined`. +- Passes `org` to `streamChat` each turn (service is stateless). + +### UI — new `frontend/src/components/Chat/ChatOrgSelect.tsx` + +- Compact MUI `Select`, rendered in `ChatPanel.tsx` directly below the header + row. +- Options: "Personal" first (value = user id), then org memberships sorted by + name (skip memberships whose org data hasn't loaded, mirroring the + `disabled: !org.id` guard in `OrganizationSelect.tsx`). +- Always enabled, including while streaming — a change only affects the next + turn. +- `onChange` → `dispatch.chat.set({ orgId })`. + +### Client — `frontend/src/services/agent.ts` + +- `streamChat` options gain `org?: { id: string; name: string }`. +- Included in the `/api/chat` POST body alongside `conversationId` and + `messages` when present. + +## Backend (`remoteit/ai-agent`) + +### `src/server.ts` + +- `parseChatRequest` accepts optional `org`. Validation: if present it must be + `{ id: string, name: string }` with non-empty strings — otherwise 400. + +### `src/chatService.ts` + +- `runChatTurn` gains an optional `org` parameter, threaded to the agent loop + / prompt assembly. +- Audit log entries for the turn include the org id. + +### System prompt + +- When `org` is present, append a section to the system prompt: + + > ## Selected organization + > The user has selected organization "" (accountId ``) in the + > app. Use this accountId for org-scoped tools unless the user explicitly + > asks about a different organization or their personal account. + +- **Sanitization:** the org name is user-influenced data entering the system + prompt. Strip newlines/control characters and cap length (~100 chars) + before injection. The id is validated as a plausible id string (no + whitespace/newlines). + +## Error handling + +- `org` is optional end-to-end; omitted → behavior identical to today. +- Malformed `org` → 400 from the server (matches existing body validation + style). +- Frontend never blocks a send on org resolution. The dropdown only offers + orgs whose data has loaded, so the name lookup should always succeed; if + state is inconsistent anyway (name missing), the frontend omits the `org` + field for that turn rather than sending a value the server would reject. + +## Testing + +- **ai-agent (vitest):** + - `/api/chat` accepts a valid `org` and the assembled system prompt + contains the org section. + - Malformed `org` (wrong types, empty strings) → 400. + - Omitted `org` → prompt unchanged from today. + - Sanitization: newlines and over-long names are cleaned before injection. +- **desktop frontend:** `npm run typecheck`; manual verification in the dev + panel (select org → agent call carries it; personal → field absent; + mid-conversation switch applies next turn). diff --git a/docs/superpowers/specs/2026-07-28-chat-popout-window-design.md b/docs/superpowers/specs/2026-07-28-chat-popout-window-design.md new file mode 100644 index 000000000..bd4de0899 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-chat-popout-window-design.md @@ -0,0 +1,126 @@ +# Chat Panel Popout Window — Design + +**Date:** 2026-07-28 +**Repo:** `remoteit/desktop` +**Branch:** `feature/agent-chat-interface` + +## Purpose + +Let the user pop the AI chat panel out of the app into its own window (per +mockup: pop-out button in the docked chat header; standalone chat window +with a pop-in button), and bring it back with the conversation intact. + +## Decisions (from brainstorming) + +1. **Move semantics** — popping out hides the docked panel; the standalone + window owns the conversation. Popping back in (button or window close) + returns it, transcript intact. No live mirroring between windows. +2. **Environments: browser + Electron.** Mobile never shows the button. + The whole feature remains behind the existing `MODE === 'development'` + gate, matching the chat panel itself. +3. **Mechanism: boot flag + BroadcastChannel.** The popout loads the same + app bundle with a `?chatPopout` boot flag; a BroadcastChannel performs + the conversation hand-off. No second Vite entry; no reliance on + redux-persist write ordering (both windows persist to the same + localStorage key, so storage alone is racy). + +## Architecture + +### Boot flag and rendering + +- The `chatPopout` query param is captured at module scope on boot (same + pattern as the hydra `?code` capture in `services/hydra.ts`), so hash + routing cannot clobber it. +- `App.tsx`: when the flag is set (and `MODE === 'development'`), render a + bare `` in place of the app shell (sidebar/router). All + pre-auth gates (loading, sign-in) behave as today; in practice the + popout is already authenticated because Amplify and agent tokens live in + shared localStorage. +- Component split: the chat internals (health notices, org select, + messages, approval, input) are extracted from `ChatPanel` into a shared + piece. `ChatPanel` (docked column) and `ChatWindow` (full-page popout) + both render it: + - `ChatPanel` header: expand, new chat, **pop out** (new), close. + - `ChatWindow` header: new chat, **pop in**. The window's own chrome + provides close. No expand button, no panel-close button. + - `ChatWindow` ignores `chat.open` (it always shows). + +### Popout service — `frontend/src/services/chatPopout.ts` + +Owns `window.open`, the BroadcastChannel (`remoteit-chat-popout`), and the +hand-off protocol. Message types: + +| Message | Direction | Payload | Effect | +|---|---|---|---| +| `hello` | popout → main | — | main replies `adopt`, then sets `open: false, poppedOut: true` | +| `adopt` | main → popout | `{ messages, conversationId, orgId }` | popout replaces its chat slice with the payload | +| `handback` | popout → main | `{ messages, conversationId, orgId }` | main applies payload, sets `poppedOut: false, open: true` | +| `ping` | main → popout | — | presence check on main boot | +| `alive` | popout → main | — | main keeps dock hidden (`poppedOut: true`) | +| `signout` | main → popout | — | popout closes itself WITHOUT sending `handback` (sign-out clears the transcript anyway) | + +- `openChatPopout()`: `window.open(origin + '/?chatPopout', 'remoteit-chat', + 'popup,width=520,height=780')`. +- Popout boot: send `hello`; if no `adopt` arrives within 300 ms, fall + back to the redux-persisted transcript (covers popout refresh / main + gone). A late `adopt` after the fallback is still applied — main's copy + is authoritative at hand-off. +- Pop-in or `beforeunload`: abort any active stream first (same path as + the Stop button; the open message is marked interrupted), then send + `handback`, then close. +- Crash resilience: while `poppedOut`, main polls `popoutWindow.closed` + (~2s). Closed without a `handback` → restore `open: true` from the + persisted transcript. Poll and `handback` are idempotent together. +- Main boot: `ping`; only an `alive` reply keeps `poppedOut: true` + (corrects stale persisted state). +- App sign-out (`chat.signOut`): broadcast `signout` before clearing. + +### Model — `frontend/src/models/chat.ts` + +- `poppedOut: boolean` added to `IChatState` (default false; value is + authoritative only after the boot ping settles). +- Effects for the protocol reactions (adopt/handback application) so all + state changes stay in the model; the service holds no state of its own + beyond the channel and window handle. + +### Electron — `electron/src/ElectronApp.ts` + +`setWindowOpenHandler` gains one branch: a URL on the app's own origin +carrying the `chatPopout` flag returns + +``` +{ action: 'allow', overrideBrowserWindowOptions: + { width: 520, height: 780, minWidth: 360, minHeight: 500, autoHideMenuBar: true } } +``` + +All other URLs keep the existing deny + `shell.openExternal` behavior. +BroadcastChannel works across the two windows unchanged (same origin and +session partition). + +## Edge handling + +- **Main window closes/reloads while popped out** — popout keeps working + (own store, shared tokens). Next main boot pings; `alive` keeps the dock + hidden. +- **Mid-stream pop-in/close** — stream aborted, message marked + interrupted, transcript preserved in the `handback`. +- **Popout opened twice** — the named window (`'remoteit-chat'`) is + reused by `window.open`, so a second click focuses the existing popout. +- **Mobile / non-dev builds** — button absent (`MODE` gate + no button on + mobile via `browser.isMobile`). + +## Verification + +Typecheck (`cd frontend && npm run typecheck`) plus a manual script: + +1. Pop out → docked panel hides, window opens with transcript and org + selection intact. +2. Converse in the popout (org scoping still applies), pop in → dock + returns with the full transcript. +3. Close the popout with the window X → same as pop-in. +4. Kill the popout process / crash it → dock restores within ~2s. +5. Reload the main window while popped out → dock stays hidden; popout + unaffected. +6. Sign out of the app → popout closes. +7. Electron dev build: pop out opens a native window with the specified + size; external links still open in the system browser. diff --git a/electron/src/ElectronApp.ts b/electron/src/ElectronApp.ts index f23b110e4..87c290407 100644 --- a/electron/src/ElectronApp.ts +++ b/electron/src/ElectronApp.ts @@ -1,4 +1,5 @@ import electron, { Menu, dialog } from 'electron' +import { CHAT_POPOUT_PARAM, CHAT_POPOUT_SIZE } from '@common/constants' import path from 'path' import AutoUpdater from './AutoUpdater' import TrayMenu from './TrayMenu' @@ -248,11 +249,39 @@ export default class ElectronApp { }) this.window.webContents.setWindowOpenHandler(({ url }) => { - Logger.info('OPEN EXTERNAL URL', { url }) - electron.shell.openExternal(url) + // The dev chat panel pops out into its own window (?chatPopout on our + // own origin); every other window.open goes to the system browser. + try { + const parsed = new URL(url) + if (parsed.origin === new URL(this.getStartUrl()).origin && parsed.searchParams.has(CHAT_POPOUT_PARAM)) { + return { + action: 'allow', + overrideBrowserWindowOptions: { ...CHAT_POPOUT_SIZE, autoHideMenuBar: true }, + } + } + } catch {} + this.openExternal(url) return { action: 'deny' } }) + // The allowed chat popout is a real child window: give it the same + // external-URL discipline as the main window, or window.open / + // target=_blank / link navigation inside it spawns unguarded native + // windows on remote content instead of the system browser + this.window.webContents.on('did-create-window', child => { + child.webContents.setWindowOpenHandler(({ url }) => { + this.openExternal(url) + return { action: 'deny' } + }) + child.webContents.on('will-navigate', (event, url) => { + try { + if (new URL(url).origin === new URL(this.getStartUrl()).origin) return + } catch {} + event.preventDefault() + this.openExternal(url) + }) + }) + this.window.webContents.on('will-navigate', (event, url) => { if (url.includes('auth.remote.it')) { Logger.info('AUTH NAVIGATION DETECTED') @@ -278,6 +307,13 @@ export default class ElectronApp { this.logWebErrors() } + /* The external-URL discipline: everything leaving the renderer opens in + the system browser, logged */ + private openExternal(url: string) { + Logger.info('OPEN EXTERNAL URL', { url }) + electron.shell.openExternal(url) + } + private validateWindowState(state?: IPreferences['windowState']): IPreferences['windowState'] { const defaults = preferences.windowDefaultState ?? { width: 1280, height: 800 } diff --git a/frontend/package.json b/frontend/package.json index c541bfb49..80bca7e20 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -61,10 +61,12 @@ "react-dropzone": "^14.3.8", "react-gtm-module": "^2.0.11", "react-i18next": "^12.3.1", + "react-markdown": "^9.0.1", "react-redux": "^9.2.0", "react-router-dom": "^5.3.4", "react-select": "^5.10.2", "react-string-replace": "^1.1.1", + "remark-gfm": "^4.0.0", "reaptcha": "^1.12.1", "reconnecting-websocket": "^4.4.0", "redux": "^5.0.1", diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 775b14416..214c07040 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -12,9 +12,7 @@ import { useSelector, useDispatch } from 'react-redux' import { HIDE_SIDEBAR_WIDTH, HIDE_TWO_PANEL_WIDTH, - SIDEBAR_WIDTH, MOBILE_WIDTH, - ORGANIZATION_BAR_WIDTH, REGEX_FIRST_PATH, SHOW_TRIPLE_PANEL_WIDTH, } from '../constants' @@ -27,12 +25,19 @@ import { SidebarMenu } from './SidebarMenu' import { SignInPage } from '../pages/SignInPage' import { BottomMenu } from './BottomMenu' import { Sidebar } from './Sidebar' +import { useChatEnabled, useChatDocked, useChatWidth, useSidebarWidth } from '../hooks/useChatEnabled' import { Router } from '../routers/Router' import { Page } from '../pages/Page' import { Logo } from '@common/brand/Logo' import { ViewAsBanner } from './ViewAsBanner' import { AnnouncementDialog } from './AnnouncementDialog' import { AnnouncementBanner } from './AnnouncementBanner' +import { isChatPopout } from '../services/chatPopout' + +// Lazy: keeps the chat surface (and its react-markdown dependency tree) out +// of the startup bundle — the feature is dev/Test-UI gated +const ChatPanel = React.lazy(() => import('./Chat/ChatPanel').then(m => ({ default: m.ChatPanel }))) +const ChatWindow = React.lazy(() => import('./Chat/ChatWindow').then(m => ({ default: m.ChatWindow }))) export const App: React.FC = () => { // Subscribe the whole app to i18next language changes and lazy-locale loads, so @@ -49,13 +54,20 @@ export const App: React.FC = () => { const installed = useSelector((state: State) => state.binaries.installed) const waitMessage = useSelector((state: State) => state.ui.waitMessage) const showOrgs = useSelector((state: State) => !!state.accounts.membership.length) + const chatEnabled = useChatEnabled() + const chatDocked = useChatDocked() + const chatWidth = useChatWidth() + const sidebarWidth = useSidebarWidth() const reseller = useSelector(selectResellerRef) const dispatch = useDispatch() const hideSidebar = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) const singlePanel = useMediaQuery(`(max-width:${HIDE_TWO_PANEL_WIDTH}px)`) const triplePanel = useMediaQuery(`(min-width:${SHOW_TRIPLE_PANEL_WIDTH}px)`) const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) - const sidePanelWidth = hideSidebar ? 0 : SIDEBAR_WIDTH + (showOrgs ? ORGANIZATION_BAR_WIDTH : 0) + // The docked chat column reserves layout space the same way the sidebar + // does; useChatDocked only docks when the panels still fit beside it — + // otherwise the chat renders as an overlay and reserves nothing + const sidePanelWidth = sidebarWidth + (chatDocked ? chatWidth : 0) const isRootMenu = location.pathname.match(REGEX_FIRST_PATH)?.[0] === location.pathname const showBottomMenu = (mobile || browser.isMobile) && isRootMenu && hideSidebar const needsUserHydration = authenticated && !user @@ -119,21 +131,37 @@ export const App: React.FC = () => { }> - - {hideSidebar ? : } - - - {showBottomMenu && } + {/* isChatPopout is a boot constant — the window only exists because + chat opened it, so no feature-flag gate (chatEnabled depends on + async-restored testUI and would flash the full app in the popup) */} + {isChatPopout ? ( + + + + ) : ( + <> + + {hideSidebar ? : } + + {chatEnabled && ( + + + + )} + + {showBottomMenu && } + + )} diff --git a/frontend/src/components/Chat/ChatApproval.tsx b/frontend/src/components/Chat/ChatApproval.tsx new file mode 100644 index 000000000..750e09d7a --- /dev/null +++ b/frontend/src/components/Chat/ChatApproval.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { Paper, Typography, Button, Box } from '@mui/material' + +type Props = { + toolName: string + input: Record + onRespond: (approved: boolean) => void +} + +/* Inline card shown when the agent pauses on a write tool awaiting approval */ +export const ChatApproval: React.FC = ({ toolName, input, onRespond }) => { + const { t } = useTranslation() + return ( + + + }} + /> + + + {JSON.stringify(input, null, 2)} + + + + + + + ) +} diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx new file mode 100644 index 000000000..320896cff --- /dev/null +++ b/frontend/src/components/Chat/ChatBody.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector, useDispatch } from 'react-redux' +import { Button, Typography } from '@mui/material' +import { State, Dispatch } from '../../store' +import { ChatMessages } from './ChatMessages' +import { ChatApproval } from './ChatApproval' +import { ChatInput } from './ChatInput' +import { ChatOrgLabel } from './ChatOrgLabel' +import { Notice } from '../Notice' +import { Body } from '../Body' +import { Icon } from '../Icon' +import { isChatPopout } from '../../services/chatPopout' + +/* Everything below the chat header — shared by the docked panel and the + popout window */ +export const ChatBody: React.FC = () => { + const { t } = useTranslation() + const messages = useSelector((state: State) => state.chat.messages) + const streaming = useSelector((state: State) => state.chat.streaming) + const health = useSelector((state: State) => state.chat.health) + const pendingConfirmation = useSelector((state: State) => state.chat.pendingConfirmation) + const error = useSelector((state: State) => state.chat.error) + const dispatch = useDispatch() + const signedOut = health === 'unauthorized' + const unreachable = health === 'unreachable' + // Literal default: the i18next parser can't extract a value passed as a variable + const unavailableMessage = t( + 'chat.unavailable', + 'Mycal is temporarily unavailable. Check your internet connection or try again in a few minutes.' + ) + + return ( + <> + + {unreachable && !!messages.length && ( + + {unavailableMessage} + + )} + {signedOut ? ( + + + + {t('chat.signInNeeded', 'The AI agent needs its own sign-in to act on your behalf.')} + {isChatPopout && ` ${t('chat.signInFromMain', 'Sign in from the main app window.')}`} + + {!isChatPopout && ( + + )} + + ) : unreachable && !messages.length ? ( + + + + {unavailableMessage} + + + ) : ( + + {pendingConfirmation && ( + dispatch.chat.confirm(approved)} + /> + )} + {error && ( + dispatch.chat.set({ error: null })}> + {error} + + )} + + )} + dispatch.chat.send(text)} + onStop={() => dispatch.chat.stop()} + /> + + ) +} diff --git a/frontend/src/components/Chat/ChatHeader.tsx b/frontend/src/components/Chat/ChatHeader.tsx new file mode 100644 index 000000000..bc262ae88 --- /dev/null +++ b/frontend/src/components/Chat/ChatHeader.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { Box, Typography } from '@mui/material' +import { Dispatch } from '../../store' +import { IconButton } from '../../buttons/IconButton' + +/* Title row shared by the docked panel and the popout window — the + window-specific buttons render as children in each caller's order */ +export const ChatHeader: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( + + + Mycal + + {children} + +) + +export const NewChatButton: React.FC = () => { + const { t } = useTranslation() + const dispatch = useDispatch() + return dispatch.chat.clearConversation()} /> +} diff --git a/frontend/src/components/Chat/ChatInput.tsx b/frontend/src/components/Chat/ChatInput.tsx new file mode 100644 index 000000000..935d17e9b --- /dev/null +++ b/frontend/src/components/Chat/ChatInput.tsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Box, InputBase } from '@mui/material' +import { fontSizes, radius } from '../../styling' +import { IconButton } from '../../buttons/IconButton' + +type Props = { + disabled: boolean + placeholder?: string + streaming: boolean + onSend: (text: string) => void + onStop: () => void +} + +export const ChatInput: React.FC = ({ disabled, placeholder, streaming, onSend, onStop }) => { + const { t } = useTranslation() + const [text, setText] = useState('') + const submit = () => { + const trimmed = text.trim() + if (!trimmed || disabled || streaming) return + onSend(trimmed) + setText('') + } + return ( + + + setText(event.target.value)} + onKeyDown={event => { + // isComposing: Enter is confirming an IME candidate (ja/zh/ko), + // not submitting — sending here would post half-composed text + if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { + event.preventDefault() + submit() + } + }} + /> + {streaming ? ( + + ) : ( + + )} + + + ) +} diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx new file mode 100644 index 000000000..72ca879af --- /dev/null +++ b/frontend/src/components/Chat/ChatMessageItem.tsx @@ -0,0 +1,103 @@ +import React from 'react' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { useTranslation } from 'react-i18next' +import { Box, Typography } from '@mui/material' +import { fontSizes } from '../../styling' +import { ChatTranscriptMessage } from '../../models/chat' +import { ChatToolCalls } from './ChatToolCalls' +import { scrollbarStyles } from './chatScrollbar' + +// Links open in a new tab: a bare anchor is a top-level navigation, which in +// Electron replaces the app window with the external site (will-navigate only +// guards auth.remote.it); target=_blank routes through setWindowOpenHandler → +// shell.openExternal instead +const markdownComponents = { + a: ({ node, ...props }: any) => , +} + +// Memoized: immer keeps unchanged message refs stable, so during streaming +// only the tail message re-renders instead of re-parsing every message's +// markdown on each delta +export const ChatMessageItem = React.memo<{ message: ChatTranscriptMessage }>(({ message }) => { + const { t } = useTranslation() + if (message.role === 'user') + return ( + + + + {message.text} + + + + ) + + return ( + + + ({ '& pre, & table': scrollbarStyles(theme) }), + { + fontSize: fontSizes.base, + lineHeight: 1.5, + wordBreak: 'break-word', + '& p': { marginY: 0.75 }, + '& ul, & ol': { paddingLeft: 3, marginY: 0.5 }, + '& li': { marginY: 0.25 }, + '& h1, & h2, & h3, & h4': { fontSize: 15, marginTop: 1.5, marginBottom: 0.5 }, + '& a': { color: 'primary.main' }, + '& code': { + fontFamily: "'Roboto Mono', monospace", + fontSize: fontSizes.sm, + bgcolor: 'grayLightest.main', + borderRadius: 1, + paddingX: 0.5, + paddingY: 0.25, + }, + '& pre': { + overflowX: 'auto', + bgcolor: 'grayLightest.main', + borderRadius: 2, + padding: 1.5, + '& code': { padding: 0, bgcolor: 'transparent' }, + }, + '& table': { + display: 'block', + overflowX: 'auto', + borderCollapse: 'collapse', + fontSize: fontSizes.sm, + marginY: 1, + }, + '& th, & td': { + border: '1px solid', + borderColor: 'grayLighter.main', + paddingX: 1, + paddingY: 0.5, + textAlign: 'left', + whiteSpace: 'nowrap', + }, + '& blockquote': { + borderLeft: '3px solid', + borderColor: 'grayLighter.main', + marginX: 0, + paddingLeft: 1.5, + color: 'grayDark.main', + }, + }, + ]} + > + + {message.text} + + + {message.interrupted && ( + + {t('chat.interrupted', 'Interrupted')} + + )} + + ) +}) + +ChatMessageItem.displayName = 'ChatMessageItem' diff --git a/frontend/src/components/Chat/ChatMessages.tsx b/frontend/src/components/Chat/ChatMessages.tsx new file mode 100644 index 000000000..33feaef1f --- /dev/null +++ b/frontend/src/components/Chat/ChatMessages.tsx @@ -0,0 +1,37 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Box } from '@mui/material' +import { ChatTranscriptMessage } from '../../models/chat' +import { ChatMessageItem } from './ChatMessageItem' +import { scrollbarStyles } from './chatScrollbar' + +type Props = { + messages: ChatTranscriptMessage[] + streaming: boolean + children?: React.ReactNode +} + +export const ChatMessages: React.FC = ({ messages, streaming, children }) => { + const ref = useRef(null) + const [pinned, setPinned] = useState(true) + + // Follow the stream, but release when the user scrolls up to read + useEffect(() => { + if (pinned) ref.current?.scrollTo({ top: ref.current.scrollHeight }) + }, [messages, streaming, pinned, children]) + + return ( + { + const el = ref.current + if (el) setPinned(el.scrollHeight - el.scrollTop - el.clientHeight < 40) + }} + sx={[{ flexGrow: 1, overflowY: 'auto', paddingX: 2 }, scrollbarStyles]} + > + {messages.map((message, index) => ( + + ))} + {children} + + ) +} diff --git a/frontend/src/components/Chat/ChatOrgLabel.tsx b/frontend/src/components/Chat/ChatOrgLabel.tsx new file mode 100644 index 000000000..e4ca9e12b --- /dev/null +++ b/frontend/src/components/Chat/ChatOrgLabel.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { Box, Typography } from '@mui/material' +import { resolveChatOrg } from '../../models/chat' + +/* Read-only display of the org the agent is scoped to. The chat follows the + app's active org (the sidebar org selector); the popout window shows the + org handed off with the conversation. Resolved by the same lookup send() + uses, so the label and the org sent to the agent can never disagree. */ +export const ChatOrgLabel: React.FC = () => { + const { t } = useTranslation() + const org = useSelector(resolveChatOrg, (a, b) => a?.id === b?.id && a?.name === b?.name) + const orgName = org ? org.name || t('chat.organization', 'Organization') : t('chat.personal', 'Personal') + + return ( + + + {t('chat.currentOrg', 'Current Org')} + + {orgName} + + ) +} diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx new file mode 100644 index 000000000..e987fa678 --- /dev/null +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector, useDispatch } from 'react-redux' +import { Box } from '@mui/material' +import { State, Dispatch } from '../../store' +import { useChatDocked, useChatWidth } from '../../hooks/useChatEnabled' +import { useChatMainSync } from '../../hooks/useChatSync' +import { IconButton } from '../../buttons/IconButton' +import { ChatHeader, NewChatButton } from './ChatHeader' +import { ChatBody } from './ChatBody' +import browser from '../../services/browser' + +/* Display-only: lifecycle, popout protocol, and org mirroring live in + useChatMainSync; user actions dispatch chat model effects */ +export const ChatPanel: React.FC = () => { + const { t } = useTranslation() + const open = useSelector((state: State) => state.chat.open) + const expanded = useSelector((state: State) => state.chat.expanded) + const insets = useSelector((state: State) => state.ui.layout.insets) + const showBottomMenu = useSelector((state: State) => state.ui.layout.showBottomMenu) + const docked = useChatDocked() + const chatWidth = useChatWidth() + const dispatch = useDispatch() + + useChatMainSync() + + if (!open) return null + + return ( + + + {docked && ( + dispatch.chat.set({ expanded: !expanded })} + /> + )} + {!browser.isMobile && ( + dispatch.chat.popOut()} + /> + )} + + dispatch.chat.set({ open: false })} /> + + + + ) +} diff --git a/frontend/src/components/Chat/ChatToolCalls.tsx b/frontend/src/components/Chat/ChatToolCalls.tsx new file mode 100644 index 000000000..17020fe9e --- /dev/null +++ b/frontend/src/components/Chat/ChatToolCalls.tsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Box, ButtonBase, Collapse, Typography, CircularProgress } from '@mui/material' +import { ChatToolCall } from '../../models/chat' +import { Icon } from '../Icon' + +export const ChatToolCalls: React.FC<{ toolCalls: ChatToolCall[] }> = ({ toolCalls }) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + if (!toolCalls.length) return null + const running = toolCalls.some(c => c.status === 'running') + return ( + + setOpen(!open)} sx={{ borderRadius: 1, paddingX: 0.5, color: 'grayDark.main' }}> + {running ? ( + + ) : ( + + )} + + {t('chat.toolsUsed', { + count: toolCalls.length, + defaultValue_one: 'Used {{count}} tool', + defaultValue_other: 'Used {{count}} tools', + })} + + + + {toolCalls.map(call => ( + + + {call.name} + {call.status === 'running' ? ' …' : ''} + + {call.result && ( + + {call.result.slice(0, 200)} + + )} + + ))} + + + ) +} diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx new file mode 100644 index 000000000..4c5bff261 --- /dev/null +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { Box } from '@mui/material' +import { Dispatch } from '../../store' +import { IconButton } from '../../buttons/IconButton' +import { useChatPopoutSync } from '../../hooks/useChatSync' +import { ChatHeader, NewChatButton } from './ChatHeader' +import { ChatBody } from './ChatBody' + +/* Full-page chat for the popped-out window (?chatPopout boot flag). Display + only: the handoff protocol lives in useChatPopoutSync, user actions in the + chat model. The window chrome provides close. */ +export const ChatWindow: React.FC = () => { + const { t } = useTranslation() + const dispatch = useDispatch() + + useChatPopoutSync() + + return ( + + + + dispatch.chat.popIn()} + /> + + + + ) +} diff --git a/frontend/src/components/Chat/chatScrollbar.ts b/frontend/src/components/Chat/chatScrollbar.ts new file mode 100644 index 000000000..dec8fd065 --- /dev/null +++ b/frontend/src/components/Chat/chatScrollbar.ts @@ -0,0 +1,16 @@ +import { Theme } from '@mui/material/styles' + +/* Slim, theme-matched scrollbars for the chat panel's scroll surfaces, + replacing the default browser bars */ +export const scrollbarStyles = (theme: Theme) => ({ + scrollbarWidth: 'thin' as const, // Firefox + scrollbarColor: `${theme.palette.grayLight.main} transparent`, // Firefox + '&::-webkit-scrollbar': { width: 8, height: 8, WebkitAppearance: 'none' as const }, + '&::-webkit-scrollbar-track': { background: 'transparent' }, + '&::-webkit-scrollbar-thumb': { + borderRadius: 4, + backgroundColor: theme.palette.grayLight.main, + '&:hover': { backgroundColor: theme.palette.gray.main }, + }, + '&::-webkit-scrollbar-corner': { background: 'transparent' }, +}) diff --git a/frontend/src/components/DoublePanel.tsx b/frontend/src/components/DoublePanel.tsx index d967a3373..65616c5ec 100644 --- a/frontend/src/components/DoublePanel.tsx +++ b/frontend/src/components/DoublePanel.tsx @@ -29,7 +29,9 @@ export const DoublePanel: React.FC = ({ left, right, layout, header = tru const getMaxWidth = useCallback( () => { const fullWidth = primaryRef.current?.parentElement?.offsetWidth || 1000 - return fullWidth - secondaryMinWidth - sidePanelWidth + // Never below the minimum: a max < min makes usePanelDrag oscillate and + // emit negative widths when reserved chrome exceeds the window + return Math.max(MIN_WIDTH, fullWidth - secondaryMinWidth - sidePanelWidth) }, [secondaryMinWidth, sidePanelWidth] ) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 85ab21f08..c093a8eb6 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,4 +1,5 @@ import { REGEX_FIRST_PATH, HIDE_SIDEBAR_WIDTH, MOBILE_WIDTH } from '../../constants' +import { useChatEnabled } from '../../hooks/useChatEnabled' import React, { useState, useRef } from 'react' import { useTranslation } from 'react-i18next' import useNavigationUp from '../../hooks/useNavigationUp' @@ -29,6 +30,9 @@ export const Header: React.FC = ({ panels = 1 }) => { const { t } = useTranslation() const { searched } = useSelector(selectDeviceModelAttributes) const permissions = useSelector(selectPermissions) + const chatOpen = useSelector((state: State) => state.chat.open) + const chatPoppedOut = useSelector((state: State) => state.chat.poppedOut) + const chatEnabled = useChatEnabled() const layout = useSelector((state: State) => state.ui.layout) const overlapHeader = layout.hideSidebar && browser.isElectron && browser.isMac @@ -44,7 +48,15 @@ export const Header: React.FC = ({ panels = 1 }) => { const menu = location.pathname.match(REGEX_FIRST_PATH)?.[0] // Admin pages have two-level roots: /admin/users and /admin/partners (without IDs) - const adminRootPages = ['/admin/users', '/admin/admins', '/admin/partners', '/admin/enterprise-licenses', '/admin/devices', '/admin/notices', '/partner-stats'] + const adminRootPages = [ + '/admin/users', + '/admin/admins', + '/admin/partners', + '/admin/enterprise-licenses', + '/admin/devices', + '/admin/notices', + '/partner-stats', + ] const isAdminRootPage = adminRootPages.includes(location.pathname) const isRootMenu = menu === location.pathname || isAdminRootPage @@ -80,6 +92,16 @@ export const Header: React.FC = ({ panels = 1 }) => { color="grayDarker" /> )} + {chatEnabled && !chatPoppedOut && ( + dispatch.chat.set({ open: !chatOpen })} + /> + )} {!showSearch && } {sidebarHidden && ( diff --git a/frontend/src/components/TriplePanel.tsx b/frontend/src/components/TriplePanel.tsx index 11124b7f3..cff367d2d 100644 --- a/frontend/src/components/TriplePanel.tsx +++ b/frontend/src/components/TriplePanel.tsx @@ -64,7 +64,9 @@ export const TriplePanel: React.FC = ({ left, center, right, layout, head () => { const fullWidth = primaryRef.current?.parentElement?.offsetWidth || 1000 const secondaryWidth = secondaryRef.current?.offsetWidth || MIN_WIDTH - return fullWidth - secondaryWidth - MIN_WIDTH - sidePanelWidth + // Never below the minimum: a max < min makes usePanelDrag oscillate and + // emit negative widths when reserved chrome exceeds the window + return Math.max(MIN_WIDTH, fullWidth - secondaryWidth - MIN_WIDTH - sidePanelWidth) }, [sidePanelWidth] ) @@ -73,7 +75,7 @@ export const TriplePanel: React.FC = ({ left, center, right, layout, head () => { const fullWidth = secondaryRef.current?.parentElement?.offsetWidth || 1000 const primaryWidth = primaryRef.current?.offsetWidth || MIN_WIDTH - return fullWidth - primaryWidth - MIN_WIDTH - sidePanelWidth + return Math.max(MIN_WIDTH, fullWidth - primaryWidth - MIN_WIDTH - sidePanelWidth) }, [sidePanelWidth] ) diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index d66ef15ec..71209cb27 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -91,6 +91,8 @@ export const SIGN_OUT_BACKEND_TIMEOUT = 3000 export const MAX_CONNECTION_NAME_LENGTH = 62 export const MAX_DESCRIPTION_LENGTH = 1024 export const SIDEBAR_WIDTH = 250 +export const CHAT_PANEL_WIDTH = 400 +export const CHAT_PANEL_WIDTH_EXPANDED = 640 export const ORGANIZATION_BAR_WIDTH = 70 export const HIDE_SIDEBAR_WIDTH = 1150 export const HIDE_TWO_PANEL_WIDTH = 750 diff --git a/frontend/src/helpers/DateTransform.ts b/frontend/src/helpers/DateTransform.ts index 61f93600b..59163cccf 100644 --- a/frontend/src/helpers/DateTransform.ts +++ b/frontend/src/helpers/DateTransform.ts @@ -28,7 +28,12 @@ const DateTransform = createTransform( return obj } return convertDates(outboundState) - } + }, + + // Chat transcripts are free-form user/agent text — a message or tool result + // that happens to look like a timestamp must rehydrate as a string, not a + // Date (rendering a Date as a React child crashes the app) + { blacklist: ['chat'] } ) export default DateTransform diff --git a/frontend/src/hooks/useChatEnabled.ts b/frontend/src/hooks/useChatEnabled.ts new file mode 100644 index 000000000..337cd6d53 --- /dev/null +++ b/frontend/src/hooks/useChatEnabled.ts @@ -0,0 +1,46 @@ +import { useMediaQuery } from '@mui/material' +import { useSelector } from 'react-redux' +import { State } from '../store' +import { + MODE, + CHAT_PANEL_WIDTH, + CHAT_PANEL_WIDTH_EXPANDED, + HIDE_TWO_PANEL_WIDTH, + HIDE_SIDEBAR_WIDTH, + SIDEBAR_WIDTH, + ORGANIZATION_BAR_WIDTH, +} from '../constants' + +/* Mycal is always on in local dev builds; in deployed builds it soft-launches + behind the hidden Test UI (shift+option on the avatar menu → Test UI). */ +export const useChatEnabled = (): boolean => { + const testUI = useSelector((state: State) => state.ui.testUI) + return MODE === 'development' || !!testUI +} + +/* Width the docked chat column occupies — single source for the fits-check + below, App's reserved layout width, and ChatPanel's rendered width */ +export const useChatWidth = (): number => { + const expanded = useSelector((state: State) => state.chat.expanded) + return expanded ? CHAT_PANEL_WIDTH_EXPANDED : CHAT_PANEL_WIDTH +} + +/* Width of the left chrome (sidebar + org bar) the layout reserves — + shared by App's sidePanelWidth and the chat docking fit-check */ +export const useSidebarWidth = (): number => { + const showOrgs = useSelector((state: State) => !!state.accounts.membership.length) + const hideSidebar = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) + return hideSidebar ? 0 : SIDEBAR_WIDTH + (showOrgs ? ORGANIZATION_BAR_WIDTH : 0) +} + +/* Whether the open chat reserves layout width (docked) or floats as a + full-screen overlay. Docked only when the window still fits two content + panels beside the sidebar chrome and the chat column — reserving width + past that point drives the panel resize math below its minimums. */ +export const useChatDocked = (): boolean => { + const enabled = useChatEnabled() + const open = useSelector((state: State) => state.chat.open) + const required = useChatWidth() + HIDE_TWO_PANEL_WIDTH + useSidebarWidth() + const fits = useMediaQuery(`(min-width:${required}px)`) + return enabled && open && fits +} diff --git a/frontend/src/hooks/useChatSync.ts b/frontend/src/hooks/useChatSync.ts new file mode 100644 index 000000000..3523b2298 --- /dev/null +++ b/frontend/src/hooks/useChatSync.ts @@ -0,0 +1,75 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector, useDispatch } from 'react-redux' +import { store, State, Dispatch } from '../store' +import { toChatHandoff } from '../models/chat' +import { initChatPopoutMain, initChatPopoutWindow, checkPopoutPresence, PopoutMainHandlers } from '../services/chatPopout' + +const currentHandoff = () => toChatHandoff(store.getState().chat) + +/* Main-window chat lifecycle — everything ChatPanel needs to happen but that + isn't display: completing a Hydra sign-in redirect, wiring the popout + handoff protocol, re-checking agent health when the dock opens, and + mirroring the app's active org. */ +export const useChatMainSync = (): void => { + const open = useSelector((state: State) => state.chat.open) + const activeId = useSelector((state: State) => state.accounts.activeId) + const dispatch = useDispatch() + + useEffect(() => { + // Mount-only: streaming state must not survive a reload, but reopening + // the panel must not reset a still-running stream (closing the panel + // deliberately leaves the stream running) + dispatch.chat.resetTransient() + // Completes a Hydra sign-in redirect if this page load carries ?code — + // runs on mount regardless of whether the panel is open + dispatch.chat.handleSignInCallback() + const handlers: PopoutMainHandlers = { + getHandoff: currentHandoff, + adopt: payload => { + dispatch.chat.adoptTranscript(payload) + dispatch.chat.set({ poppedOut: false, open: true }) + }, + onPopoutOpened: () => { + dispatch.chat.stop() + dispatch.chat.set({ open: false, poppedOut: true }) + }, + onPopoutLost: () => dispatch.chat.set({ poppedOut: false, open: true }), + onPresence: present => dispatch.chat.set(present ? { poppedOut: true, open: false } : { poppedOut: false }), + } + const unsubscribe = initChatPopoutMain(handlers) + checkPopoutPresence(handlers) + return unsubscribe + }, []) + + useEffect(() => { + if (open) dispatch.chat.checkHealth() + }, [open]) + + // The chat follows the app's active org from the sidebar selector + useEffect(() => { + dispatch.chat.syncOrg() + }, [activeId]) +} + +/* Popout-window chat lifecycle: adopt the handed-off conversation, answer + liveness pings, and hand the transcript back on unload — keeps ChatWindow + display-only. */ +export const useChatPopoutSync = (): void => { + const { t } = useTranslation() + const dispatch = useDispatch() + + useEffect(() => { + document.title = t('chat.windowTitle', 'remote.it chat') + dispatch.chat.resetTransient() + // No syncOrg here: the popout keeps the org handed off with the + // conversation (it has no sidebar to change it with) + dispatch.chat.checkHealth() + const unsubscribe = initChatPopoutWindow({ + adopt: payload => dispatch.chat.adoptTranscript(payload), + getHandoff: currentHandoff, + onSignout: () => window.close(), + }) + return unsubscribe + }, []) +} diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index c08d86288..d4c1f83a6 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -183,6 +183,31 @@ "noticeTitle": "Hinweis", "title": "Passwort ändern" }, + "chat": { + "approve": "Genehmigen", + "close": "Schließen", + "collapse": "Verkleinern", + "currentOrg": "Aktuelle Organisation", + "deny": "Ablehnen", + "expand": "Vergrößern", + "interrupted": "Unterbrochen", + "newChat": "Neuer Chat", + "organization": "Organisation", + "personal": "Persönlich", + "popIn": "Wieder andocken", + "popOut": "In eigenem Fenster öffnen", + "send": "Senden", + "signIn": "Mit remote.it anmelden", + "signInFromMain": "Melden Sie sich im Hauptfenster der App an.", + "signInNeeded": "Der KI-Agent benötigt eine eigene Anmeldung, um in Ihrem Namen zu handeln.", + "stop": "Stopp", + "toolRequest": "Der Agent möchte {{tool}} ausführen", + "toolsUsed_one": "{{count}} Tool verwendet", + "toolsUsed_other": "{{count}} Tools verwendet", + "unavailable": "Mycal ist vorübergehend nicht verfügbar. Überprüfen Sie Ihre Internetverbindung oder versuchen Sie es in einigen Minuten erneut.", + "waitingApproval": "Warten auf Genehmigung…", + "windowTitle": "remote.it Chat" + }, "claimDevice": { "claim": "Beanspruchen", "claimCode": "Anspruchscode", @@ -774,6 +799,7 @@ "stepOf": "{{step}} von {{total}}" }, "header": { + "aiAgent": "KI-Agent", "back": "Zurück", "create": "", "deviceSearch": "Gerätesuche", @@ -1931,6 +1957,8 @@ "testPage": { "addQueryHeader": "Abfrage-Header hinzufügen", "addQueryHeaderPlaceholder": "Beispiel: \"key:value\"", + "agentURL": "Agentendienst-URL", + "agentURLInvalid": "Die Agentendienst-URL muss mit https:// beginnen", "clearViewedAnnouncements": "Angesehene Ankündigungen löschen", "clearViewedAnnouncementsHint": "Markiert alle geladenen Ankündigungen für dieses Konto als ungelesen.", "disableTestUI": "Test-UI deaktivieren", @@ -1940,6 +1968,9 @@ "hideTestUIBackgrounds": "Test-UI-Hintergründe ausblenden", "licenseMessageCleared": "Lizenzmeldung gelöscht", "licensingOptions": "Lizenzierungsoptionen", + "mcpAudience": "Agent-MCP-Audience", + "overrideAgent": "Agentendienst überschreiben", + "overrideAgentSub": "Richtet den Mycal-Chat auf einen bereitgestellten Agenten (nur https). dev-ai-agent gehört zur Audience https://mcp.demo.remote.it/mcp. Melden Sie sich nach Änderungen erneut beim Agenten an.", "overrideDefaultAPIs": "Standard-APIs überschreiben", "overrideLicensesAndLimits": "Lizenzen und Limits überschreiben", "reset": "Zurücksetzen", diff --git a/frontend/src/i18n/locales/de/notices.json b/frontend/src/i18n/locales/de/notices.json index 045eefd62..aca31a99c 100644 --- a/frontend/src/i18n/locales/de/notices.json +++ b/frontend/src/i18n/locales/de/notices.json @@ -9,6 +9,12 @@ "loginFailed": "Anmeldung fehlgeschlagen.", "passwordChanged": "Passwort erfolgreich geändert." }, + "chat": { + "authRequired": "Agentenauthentifizierung erforderlich — melden Sie sich an, um fortzufahren.", + "popupBlocked": "Pop-out blockiert — erlauben Sie Pop-ups für diese Seite und versuchen Sie es erneut.", + "sessionExpired": "Agentensitzung abgelaufen — melden Sie sich erneut an, um fortzufahren.", + "signInFailed": "Agentenanmeldung fehlgeschlagen — {{error}}" + }, "connection": { "surveyFailed": "Die Übermittlung der Verbindungsumfrage ist fehlgeschlagen. Bitte wenden Sie sich an den Support." }, diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index f57d6be6b..34b3378c9 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -183,6 +183,31 @@ "noticeTitle": "Notice", "title": "Change Password" }, + "chat": { + "approve": "Approve", + "close": "Close", + "collapse": "Collapse", + "currentOrg": "Current Org", + "deny": "Deny", + "expand": "Expand", + "interrupted": "Interrupted", + "newChat": "New Chat", + "organization": "Organization", + "personal": "Personal", + "popIn": "Pop back in", + "popOut": "Pop out", + "send": "Send", + "signIn": "Sign in with remote.it", + "signInFromMain": "Sign in from the main app window.", + "signInNeeded": "The AI agent needs its own sign-in to act on your behalf.", + "stop": "Stop", + "toolRequest": "The agent wants to run {{tool}}", + "toolsUsed_one": "Used {{count}} tool", + "toolsUsed_other": "Used {{count}} tools", + "unavailable": "Mycal is temporarily unavailable. Check your internet connection or try again in a few minutes.", + "waitingApproval": "Waiting for approval…", + "windowTitle": "remote.it chat" + }, "claimDevice": { "claim": "Claim", "claimCode": "Claim Code", @@ -790,6 +815,7 @@ "stepOf": "{{step}} of {{total}}" }, "header": { + "aiAgent": "AI Agent", "back": "Back", "create": "Create", "deviceSearch": "Device Search", @@ -1977,6 +2003,8 @@ "testPage": { "addQueryHeader": "Add query header", "addQueryHeaderPlaceholder": "Example: \"key:value\"", + "agentURL": "Agent service URL", + "agentURLInvalid": "Agent service URL must start with https://", "clearViewedAnnouncements": "Clear viewed announcements", "clearViewedAnnouncementsHint": "Marks all loaded announcements unread for this account.", "disableTestUI": "Disable Test UI", @@ -1986,6 +2014,9 @@ "hideTestUIBackgrounds": "Hide test UI backgrounds", "licenseMessageCleared": "License message cleared", "licensingOptions": "Licensing Options", + "mcpAudience": "Agent MCP audience", + "overrideAgent": "Override agent service", + "overrideAgentSub": "Point the Mycal chat at a deployed agent (https only). dev-ai-agent pairs with audience https://mcp.demo.remote.it/mcp. Sign in to the agent again after changing these.", "overrideDefaultAPIs": "Override default APIs", "overrideLicensesAndLimits": "Override licenses and limits", "reset": "Reset", diff --git a/frontend/src/i18n/locales/en/notices.json b/frontend/src/i18n/locales/en/notices.json index aa11dc69b..ea0991f2d 100644 --- a/frontend/src/i18n/locales/en/notices.json +++ b/frontend/src/i18n/locales/en/notices.json @@ -9,6 +9,12 @@ "loginFailed": "Login failed.", "passwordChanged": "Password changed successfully." }, + "chat": { + "authRequired": "Agent authentication required — sign in to continue.", + "popupBlocked": "Pop out was blocked — allow popups for this site and try again.", + "sessionExpired": "Agent session expired — sign in again to continue.", + "signInFailed": "Agent sign-in failed — {{error}}" + }, "connection": { "surveyFailed": "Connection survey submission failed. Please contact support." }, diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index 33c74547e..0a8a63375 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -186,6 +186,32 @@ "noticeTitle": "Aviso", "title": "Cambiar contraseña" }, + "chat": { + "approve": "Aprobar", + "close": "Cerrar", + "collapse": "Contraer", + "currentOrg": "Organización actual", + "deny": "Denegar", + "expand": "Expandir", + "interrupted": "Interrumpido", + "newChat": "Nuevo chat", + "organization": "Organización", + "personal": "Personal", + "popIn": "Volver a acoplar", + "popOut": "Abrir en ventana propia", + "send": "Enviar", + "signIn": "Iniciar sesión con remote.it", + "signInFromMain": "Inicie sesión desde la ventana principal de la aplicación.", + "signInNeeded": "El agente de IA necesita su propio inicio de sesión para actuar en su nombre.", + "stop": "Detener", + "toolRequest": "El agente quiere ejecutar {{tool}}", + "toolsUsed_one": "{{count}} herramienta utilizada", + "toolsUsed_many": "{{count}} de herramientas utilizadas", + "toolsUsed_other": "{{count}} herramientas utilizadas", + "unavailable": "Mycal no está disponible temporalmente. Compruebe su conexión a internet o inténtelo de nuevo en unos minutos.", + "waitingApproval": "Esperando aprobación…", + "windowTitle": "Chat de remote.it" + }, "claimDevice": { "claim": "Reclamar", "claimCode": "Código de reclamo", @@ -783,6 +809,7 @@ "stepOf": "{{step}} de {{total}}" }, "header": { + "aiAgent": "Agente de IA", "back": "Atrás", "create": "", "deviceSearch": "Búsqueda de dispositivos", @@ -1966,6 +1993,8 @@ "testPage": { "addQueryHeader": "Agregar encabezado de consulta", "addQueryHeaderPlaceholder": "Ejemplo: \"key:value\"", + "agentURL": "URL del servicio del agente", + "agentURLInvalid": "La URL del servicio del agente debe empezar por https://", "clearViewedAnnouncements": "Borrar anuncios vistos", "clearViewedAnnouncementsHint": "Marca todos los anuncios cargados como no leídos para esta cuenta.", "disableTestUI": "Deshabilitar la interfaz de prueba", @@ -1975,6 +2004,9 @@ "hideTestUIBackgrounds": "Ocultar fondos de la interfaz de prueba", "licenseMessageCleared": "Mensaje de licencia borrado", "licensingOptions": "Opciones de licencia", + "mcpAudience": "Audiencia MCP del agente", + "overrideAgent": "Anular servicio del agente", + "overrideAgentSub": "Apunta el chat de Mycal a un agente desplegado (solo https). dev-ai-agent se empareja con la audiencia https://mcp.demo.remote.it/mcp. Vuelva a iniciar sesión en el agente después de cambiar estos valores.", "overrideDefaultAPIs": "Anular las API predeterminadas", "overrideLicensesAndLimits": "Anular licencias y límites", "reset": "Restablecer", diff --git a/frontend/src/i18n/locales/es/notices.json b/frontend/src/i18n/locales/es/notices.json index c8f540837..c79a1067c 100644 --- a/frontend/src/i18n/locales/es/notices.json +++ b/frontend/src/i18n/locales/es/notices.json @@ -9,6 +9,12 @@ "loginFailed": "Error al iniciar sesión.", "passwordChanged": "Contraseña cambiada correctamente." }, + "chat": { + "authRequired": "Se requiere autenticación del agente — inicie sesión para continuar.", + "popupBlocked": "Ventana emergente bloqueada — permita las ventanas emergentes para este sitio e inténtelo de nuevo.", + "sessionExpired": "La sesión del agente ha expirado — inicie sesión de nuevo para continuar.", + "signInFailed": "Error al iniciar sesión en el agente — {{error}}" + }, "connection": { "surveyFailed": "No se pudo enviar la encuesta de conexión. Ponte en contacto con soporte." }, diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index de56b6f7b..8eb206576 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -180,6 +180,30 @@ "noticeTitle": "注意", "title": "パスワードを変更" }, + "chat": { + "approve": "承認", + "close": "閉じる", + "collapse": "縮小", + "currentOrg": "現在の組織", + "deny": "拒否", + "expand": "拡大", + "interrupted": "中断されました", + "newChat": "新しいチャット", + "organization": "組織", + "personal": "個人", + "popIn": "元に戻す", + "popOut": "別ウィンドウで開く", + "send": "送信", + "signIn": "remote.it でサインイン", + "signInFromMain": "メインのアプリウィンドウからサインインしてください。", + "signInNeeded": "AI エージェントがユーザーに代わって操作を行うには、専用のサインインが必要です。", + "stop": "停止", + "toolRequest": "エージェントが {{tool}} の実行を求めています", + "toolsUsed_other": "{{count}} 個のツールを使用", + "unavailable": "Mycal は一時的に利用できません。インターネット接続を確認するか、しばらくしてからもう一度お試しください。", + "waitingApproval": "承認を待っています…", + "windowTitle": "remote.it チャット" + }, "claimDevice": { "claim": "登録", "claimCode": "登録コード", @@ -765,6 +789,7 @@ "stepOf": "{{total}}件中{{step}}件目" }, "header": { + "aiAgent": "AI エージェント", "back": "戻る", "create": "", "deviceSearch": "デバイス検索", @@ -1896,6 +1921,8 @@ "testPage": { "addQueryHeader": "クエリヘッダーを追加", "addQueryHeaderPlaceholder": "例: \"key:value\"", + "agentURL": "エージェントサービス URL", + "agentURLInvalid": "エージェントサービス URL は https:// で始まる必要があります", "clearViewedAnnouncements": "閲覧済みのお知らせをクリア", "clearViewedAnnouncementsHint": "このアカウントで読み込まれたすべてのお知らせを未読としてマークします。", "disableTestUI": "テストUIを無効にする", @@ -1905,6 +1932,9 @@ "hideTestUIBackgrounds": "テストUIの背景を非表示にする", "licenseMessageCleared": "ライセンスメッセージがクリアされました", "licensingOptions": "ライセンスオプション", + "mcpAudience": "エージェント MCP オーディエンス", + "overrideAgent": "エージェントサービスを上書き", + "overrideAgentSub": "Mycal チャットをデプロイ済みエージェントに向けます(https のみ)。dev-ai-agent はオーディエンス https://mcp.demo.remote.it/mcp とペアです。変更後はエージェントに再サインインしてください。", "overrideDefaultAPIs": "デフォルトのAPIをオーバーライド", "overrideLicensesAndLimits": "ライセンスと制限をオーバーライド", "reset": "リセット", diff --git a/frontend/src/i18n/locales/ja/notices.json b/frontend/src/i18n/locales/ja/notices.json index 15b1762be..16c2ca103 100644 --- a/frontend/src/i18n/locales/ja/notices.json +++ b/frontend/src/i18n/locales/ja/notices.json @@ -9,6 +9,12 @@ "loginFailed": "ログインに失敗しました。", "passwordChanged": "パスワードを変更しました。" }, + "chat": { + "authRequired": "エージェントの認証が必要です — サインインして続行してください。", + "popupBlocked": "ポップアウトがブロックされました — このサイトのポップアップを許可してから、もう一度お試しください。", + "sessionExpired": "エージェントのセッションの期限が切れました — もう一度サインインして続行してください。", + "signInFailed": "エージェントのサインインに失敗しました — {{error}}" + }, "connection": { "surveyFailed": "接続アンケートの送信に失敗しました。サポートにお問い合わせください。" }, diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 904a2a8e0..9417aeffc 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -273,10 +273,18 @@ export default createModel()({ * Gets called when the backend signs the user out */ async signedOut(_: void, state) { + // Agent (Hydra) session goes with the app session — clears stored + // tokens synchronously, revoke is fire-and-forget so sign-out never + // blocks on it. Runs before the purge (and the transcript reset joins + // the model resets below) so nothing dispatches between purge and a + // signOut-triggered reload — a store write there makes redux-persist + // re-save the pre-signout state for the next user of the machine. + dispatch.chat.signOut() await persistor.purge() // purge has to happen before signOut because signOut can trigger a reload await state.auth.authService?.signOut() await dispatch.auth.set({ user: undefined }) + dispatch.chat.reset() dispatch.user.reset() dispatch.organization.reset() dispatch.networks.reset() diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts new file mode 100644 index 000000000..86a192a76 --- /dev/null +++ b/frontend/src/models/chat.ts @@ -0,0 +1,333 @@ +import { createModel } from '@rematch/core' +import { RootModel } from '.' +import { + streamChat, + confirmTool, + agentHealth, + AgentAuthError, + AgentEvent, + AgentHealth, + AgentMessageParam, + OrgSelection, +} from '../services/agent' +import { startAgentSignIn, handleAgentSignInCallback, ensureFreshAgentToken, agentSignOut } from '../services/hydra' +import { + ChatHandoff, + broadcastChatSignout, + openChatPopout, + popIn as closePopoutWithHandback, +} from '../services/chatPopout' +// Value import is deref'd only inside effects, so the store/model cycle is +// safe (same pattern as services/hydra.ts) +import { store } from '../store' +import type { State } from '../store' +import i18n from '../i18n' + +export type ChatToolCall = { + id: string + name: string + input: Record + status: 'running' | 'done' | 'error' + result?: string +} + +export type ChatTranscriptMessage = + | { role: 'user'; text: string } + | { role: 'assistant'; text: string; toolCalls: ChatToolCall[]; interrupted?: boolean } + +export type IChatState = { + open: boolean + expanded: boolean + messages: ChatTranscriptMessage[] + conversationId: string + /** Org the agent is scoped to; null = uninitialized, user id = personal */ + orgId: string | null + /** Conversation currently lives in the popout window (main window only) */ + poppedOut: boolean + streaming: boolean + pendingConfirmation: { toolUseId: string; toolName: string; input: Record } | null + error: string | null + health: 'unknown' | AgentHealth +} + +export const defaultChatState: IChatState = { + open: false, + expanded: false, + messages: [], + conversationId: '', + orgId: null, + poppedOut: false, + streaming: false, + pendingConfirmation: null, + error: null, + health: 'unknown', +} + +/* Reduce one agent stream event into chat state. Mutates the immer draft. */ +function applyAgentEvent(state: IChatState, event: AgentEvent): IChatState { + const last = state.messages[state.messages.length - 1] + let assistant = last?.role === 'assistant' ? last : undefined + const ensureAssistant = () => { + if (!assistant) { + assistant = { role: 'assistant', text: '', toolCalls: [] } + state.messages.push(assistant) + } + return assistant + } + + switch (event.type) { + case 'text_delta': + ensureAssistant().text += event.text + break + case 'tool_call_start': + ensureAssistant().toolCalls.push({ id: event.id, name: event.name, input: event.input, status: 'running' }) + break + case 'tool_call_result': { + const call = assistant?.toolCalls.find(c => c.id === event.id) + if (call) { + call.status = event.isError ? 'error' : 'done' + call.result = event.result + } + break + } + case 'confirmation_required': + state.pendingConfirmation = { toolUseId: event.id, toolName: event.name, input: event.input } + break + case 'done': + state.streaming = false + state.pendingConfirmation = null + break + case 'error': + // The backend prefixes auth failures so the client knows a retry is + // pointless until the token is refreshed (e.g. it expired mid-turn). + if (event.message.startsWith('reauth_required')) { + state.error = i18n.t('notices:chat.sessionExpired', { + defaultValue: 'Agent session expired — sign in again to continue.', + }) + state.health = 'unauthorized' + } else { + state.error = event.message + } + state.streaming = false + state.pendingConfirmation = null + if (assistant) assistant.interrupted = true + break + } + return state +} + +/* The agent service is stateless: resend the transcript as role/content pairs each turn */ +function toMessageParams(messages: ChatTranscriptMessage[]): AgentMessageParam[] { + return messages.filter(m => m.text.trim().length > 0).map(m => ({ role: m.role, content: m.text })) +} + +/* Single source of truth for the org the chat is scoped to (null = personal). + Membership decides the scope, so the Current Org label and the org sent + with each turn can never disagree; the name falls back to the membership + record when organization.accounts hasn't loaded. */ +export function resolveChatOrg(state: State): OrgSelection | null { + const orgId = state.chat.orgId + if (!orgId || orgId === state.user.id) return null + const membership = state.accounts.membership.find(m => m.account.id === orgId) + if (!membership) return null + const name = (state.organization.accounts[orgId]?.name || membership.name || '').trim() + return { id: orgId, name } +} + +/* The handoff payload the main window and popout exchange — one definition so + the two sides can never serialize different field sets */ +export const toChatHandoff = (chat: IChatState): ChatHandoff => ({ + messages: chat.messages, + conversationId: chat.conversationId, + orgId: chat.orgId, +}) + +const authRequiredError = () => + i18n.t('notices:chat.authRequired', { defaultValue: 'Agent authentication required — sign in to continue.' }) + +let abortController: AbortController | null = null + +export default createModel()({ + state: { ...defaultChatState }, + effects: dispatch => ({ + async send(text: string, state) { + if (state.chat.streaming || state.chat.pendingConfirmation) return + const conversationId = state.chat.conversationId || crypto.randomUUID() + const messages = toMessageParams([...state.chat.messages, { role: 'user', text }]) + dispatch.chat.addUserMessage(text) + dispatch.chat.set({ conversationId, streaming: true, error: null }) + abortController = new AbortController() + // Same resolution the Current Org label renders, so the scope shown is + // always the scope sent — membership decides, name falls back + const resolved = resolveChatOrg(state) + const org = resolved ? { ...resolved, name: resolved.name || 'Organization' } : undefined + // Coalesce text deltas: one dispatch per ~50ms window instead of one + // per SSE chunk, so streaming doesn't re-render the app per token + let deltaBuffer = '' + let flushTimer: number | null = null + const flushDeltas = () => { + if (flushTimer !== null) window.clearTimeout(flushTimer) + flushTimer = null + if (deltaBuffer) { + dispatch.chat.applyEvent({ type: 'text_delta', text: deltaBuffer }) + deltaBuffer = '' + } + } + try { + await ensureFreshAgentToken() + await streamChat({ + conversationId, + messages, + org, + signal: abortController.signal, + onEvent: event => { + if (event.type === 'text_delta') { + deltaBuffer += event.text + if (flushTimer === null) flushTimer = window.setTimeout(flushDeltas, 50) + } else { + // Buffered text must land before the next non-text event + flushDeltas() + dispatch.chat.applyEvent(event) + } + }, + }) + } catch (error) { + flushDeltas() + if (error instanceof AgentAuthError) dispatch.chat.set({ error: authRequiredError(), health: 'unauthorized' }) + else if ((error as Error).name !== 'AbortError') + dispatch.chat.applyEvent({ type: 'error', message: (error as Error).message }) + } finally { + flushDeltas() + abortController = null + dispatch.chat.set({ streaming: false }) + } + }, + /* The chat follows the app's active org (the sidebar selector) — the main + window mirrors it here whenever it changes. The popout window never + calls this: it keeps the org handed off with the conversation. */ + async syncOrg(_: void, state) { + dispatch.chat.set({ orgId: state.accounts.activeId || state.user.id }) + }, + async confirm(approved: boolean, state) { + const pending = state.chat.pendingConfirmation + if (!pending) return + // Clear synchronously so a double click (or an Approve chased by a + // Deny) can't post a second, contradictory decision while in flight + dispatch.chat.set({ pendingConfirmation: null }) + try { + await ensureFreshAgentToken() + await confirmTool({ + conversationId: state.chat.conversationId, + toolUseId: pending.toolUseId, + approved, + }) + } catch (error) { + // Restore the card so the decision isn't lost with the error + if (error instanceof AgentAuthError) + dispatch.chat.set({ pendingConfirmation: pending, error: authRequiredError(), health: 'unauthorized' }) + else dispatch.chat.set({ pendingConfirmation: pending, error: (error as Error).message }) + } + }, + async stop() { + abortController?.abort() + abortController = null + dispatch.chat.set({ streaming: false, pendingConfirmation: null }) + }, + /* Move the conversation to its own window; the dock hides when the popout + says hello. A blocked popup is surfaced instead of silently ignored. */ + async popOut() { + if (!openChatPopout()) + dispatch.chat.set({ + error: i18n.t('notices:chat.popupBlocked', { + defaultValue: 'Pop out was blocked — allow popups for this site and try again.', + }), + }) + }, + /* Hand the conversation back to the main window and close this popout. + Reads the handoff after stop() so the final flushed text is included. */ + async popIn() { + await dispatch.chat.stop() + closePopoutWithHandback(toChatHandoff(store.getState().chat)) + }, + async checkHealth() { + await ensureFreshAgentToken() + dispatch.chat.set({ health: await agentHealth() }) + }, + /* Full-page redirect to the Hydra login (registers a client first if + needed); handleSignInCallback picks up the return after reload */ + async signIn() { + try { + await startAgentSignIn() + } catch (error) { + dispatch.chat.set({ error: (error as Error).message }) + } + }, + /* Complete a sign-in redirect if this page load carries one */ + async handleSignInCallback(_: void, state) { + const result = await handleAgentSignInCallback() + if (!result) return + // Don't yank the dock open if the conversation currently lives in the + // popout window — the popout is the active surface, not the panel. + const openIfDocked = state.chat.poppedOut ? {} : { open: true } + if (result.ok) dispatch.chat.set({ error: null, ...openIfDocked }) + else + dispatch.chat.set({ + error: i18n.t('notices:chat.signInFailed', { + defaultValue: 'Agent sign-in failed — {{error}}', + error: result.error, + }), + ...openIfDocked, + }) + await dispatch.chat.checkHealth() + }, + /* App sign-out tears the agent session down with it: revoke + clear the + Hydra credentials. The transcript reset is dispatched by auth.signedOut + alongside the other model resets — dispatching it here would land in the + purge-to-reload window and re-persist the pre-signout state. */ + async signOut() { + broadcastChatSignout() + abortController?.abort() + abortController = null + await agentSignOut() + }, + }), + reducers: { + set(state: IChatState, params: Partial) { + Object.assign(state, params) + return state + }, + addUserMessage(state: IChatState, text: string) { + state.messages.push({ role: 'user', text }) + return state + }, + applyEvent(state: IChatState, event: AgentEvent) { + return applyAgentEvent(state, event) + }, + // Streaming state must not survive a reload — called when the panel mounts + resetTransient(state: IChatState) { + state.streaming = false + state.pendingConfirmation = null + state.error = null + state.health = 'unknown' + return state + }, + /* Hand-off: replace the conversation with the other window's copy */ + adoptTranscript(state: IChatState, payload: ChatHandoff) { + state.messages = payload.messages + state.conversationId = payload.conversationId + state.orgId = payload.orgId + return state + }, + clearConversation(state: IChatState) { + state.messages = [] + state.conversationId = '' + state.streaming = false + state.pendingConfirmation = null + state.error = null + return state + }, + reset() { + return { ...defaultChatState } + }, + }, +}) diff --git a/frontend/src/models/index.ts b/frontend/src/models/index.ts index 140dfea56..a99ba139d 100644 --- a/frontend/src/models/index.ts +++ b/frontend/src/models/index.ts @@ -13,6 +13,7 @@ import backend from './backend' import billing from './billing' import binaries from './binaries' import bluetooth from './bluetooth' +import chat from './chat' import connections from './connections' import contacts from './contacts' import devices from './devices' @@ -50,6 +51,7 @@ export interface RootModel extends Models { billing: typeof billing binaries: typeof binaries bluetooth: typeof bluetooth + chat: typeof chat connections: typeof connections contacts: typeof contacts devices: typeof devices @@ -88,6 +90,7 @@ export const models: RootModel = { billing, binaries, bluetooth, + chat, connections, contacts, devices, diff --git a/frontend/src/models/ui.ts b/frontend/src/models/ui.ts index ea192ea3d..4a125b9cd 100644 --- a/frontend/src/models/ui.ts +++ b/frontend/src/models/ui.ts @@ -49,6 +49,10 @@ export type UIState = { apiGraphqlURL?: IPreferences['apiGraphqlURL'] webSocketURL?: IPreferences['webSocketURL'] apiURL?: IPreferences['apiURL'] + // Test UI: point the Mycal chat at a deployed agent (https only) + switchAgent?: boolean + agentURL?: string + mcpAudience?: string } layout: ILayout silent: string | null diff --git a/frontend/src/pages/TestPage.tsx b/frontend/src/pages/TestPage.tsx index 058db8070..7e8580069 100644 --- a/frontend/src/pages/TestPage.tsx +++ b/frontend/src/pages/TestPage.tsx @@ -14,6 +14,8 @@ import { PortalUI } from '../components/PortalUI' import { Title } from '../components/Title' import { Quote } from '../components/Quote' import { emit } from '../services/Controller' +import { isSecureAgentURL } from '../services/agent' +import { MCP_AUDIENCE } from '../services/hydra' export const TestPage: React.FC = () => { const { t } = useTranslation() @@ -31,6 +33,12 @@ export const TestPage: React.FC = () => { emit('preferences', { ...preferences, [key]: value }) } + // Agent overrides are browser-only (the chat never touches the desktop + // backend), so no preference emit + async function setAgentPreference(key: string, value: string | boolean) { + await dispatch.ui.setPersistent({ apis: { ...apis, [key]: value } }) + } + return ( { + setAgentPreference('switchAgent', !apis.switchAgent)} + toggle={!!apis.switchAgent} + /> + + + + { + const value = url.toString().trim() + // Reject rather than store a value agentURL() would silently + // ignore while the audience override still applies + if (value && !isSecureAgentURL(value)) { + dispatch.ui.set({ + errorMessage: t('testPage.agentURLInvalid', 'Agent service URL must start with https://'), + }) + return + } + setAgentPreference('agentURL', value) + }} + hideIcon + /> + setAgentPreference('mcpAudience', value.toString().trim())} + hideIcon + /> + + + {t('testPage.features', 'Features')} diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts new file mode 100644 index 000000000..13b80d559 --- /dev/null +++ b/frontend/src/services/agent.ts @@ -0,0 +1,177 @@ +/** + * Client for the ai-agent service (REST + SSE). The service is stateless: + * the client holds the transcript and resends it each turn. + */ +import { store } from '../store' +import { encryptString, decryptString, isEncrypted } from './secureStorage' + +/* The override must be https — the app's CSP blocks plain http. Shared with + the Test Settings validation so what saves is exactly what engages. */ +export const isSecureAgentURL = (url: string): boolean => /^https:\/\//i.test(url) + +/* Base URL for the agent service, resolved per request. The Test UI override + wins (Test Settings → Override agent service). Otherwise dev rides the vite + proxy (same-origin, CSP-clean) even when VITE_AGENT_URL is set, staying out + of CORS; builds have no proxy and use the deployed agent domain from + VITE_AGENT_URL. */ +export function agentURL(): string { + const { switchAgent, agentURL: override } = store.getState().ui.apis + if (switchAgent && override && isSecureAgentURL(override)) return override.replace(/\/+$/, '') + return import.meta.env.DEV ? '/agent' : import.meta.env.VITE_AGENT_URL || '/agent' +} + +// Hydra credentials for the agent service (AUTH_MODE=hydra), written by the +// in-app sign-in flow (services/hydra.ts) — or a token pasted from the +// ai-agent dev harness as a fallback. Stored in localStorage (shared with the +// popout window) encrypted at rest via secureStorage. +const AGENT_TOKEN_KEY = 'agentToken' +const AGENT_SESSION_KEY = 'agentSession' + +export type AgentSession = { + refresh_token: string + expires_at: number + client_id: string +} + +/* Tokens are encrypted at rest (secureStorage) so localStorage never holds + them in clear text. Reads fall back to plaintext for a token pasted from + the ai-agent dev harness and for values stored before encryption landed — + the next write re-encrypts. */ + +export async function decodeAgentToken(raw: string | null): Promise { + if (!raw) return null + return isEncrypted(raw) ? await decryptString(raw) : raw +} + +export async function decodeAgentSession(raw: string | null): Promise { + if (!raw) return null + try { + const json = isEncrypted(raw) ? await decryptString(raw) : raw + return json ? (JSON.parse(json) as AgentSession) : null + } catch { + return null + } +} + +export const getAgentToken = (): Promise => + decodeAgentToken(window.localStorage.getItem(AGENT_TOKEN_KEY)) + +export async function setAgentToken(token: string | null): Promise { + if (token?.trim()) + window.localStorage.setItem(AGENT_TOKEN_KEY, await encryptString(token.trim().replace(/^Bearer\s+/i, ''))) + else window.localStorage.removeItem(AGENT_TOKEN_KEY) +} + +export const getAgentSession = (): Promise => + decodeAgentSession(window.localStorage.getItem(AGENT_SESSION_KEY)) + +export async function setAgentSession(session: AgentSession | null): Promise { + if (session) window.localStorage.setItem(AGENT_SESSION_KEY, await encryptString(JSON.stringify(session))) + else window.localStorage.removeItem(AGENT_SESSION_KEY) +} + +/* Synchronous read-and-clear for sign-out: the stored credentials must be + gone before any await gives a signOut-triggered reload a chance to + interrupt; the raw values are returned so revoke can still decode them */ +export function takeAgentCredentials(): { token: string | null; session: string | null } { + const token = window.localStorage.getItem(AGENT_TOKEN_KEY) + const session = window.localStorage.getItem(AGENT_SESSION_KEY) + window.localStorage.removeItem(AGENT_TOKEN_KEY) + window.localStorage.removeItem(AGENT_SESSION_KEY) + return { token, session } +} + +/* The agent rejected our credential (401 reauth_required) — sign in again */ +export class AgentAuthError extends Error { + constructor() { + super('Agent authentication required') + } +} + +async function agentHeaders(json = true): Promise> { + const headers: Record = json ? { 'Content-Type': 'application/json' } : {} + const token = await getAgentToken() + if (token) headers.Authorization = `Bearer ${token}` + return headers +} + +export type AgentEvent = + | { type: 'text_delta'; text: string } + | { type: 'tool_call_start'; id: string; name: string; input: Record } + | { type: 'tool_call_result'; id: string; name: string; result: string; isError: boolean; durationMs: number } + | { type: 'confirmation_required'; id: string; name: string; input: Record } + | { type: 'done'; stopReason: string | null } + | { type: 'error'; message: string } + +export type AgentMessageParam = { role: 'user' | 'assistant'; content: string } + +export type OrgSelection = { id: string; name: string } + +/* Stream one chat turn. Events arrive as SSE: `event: \ndata: \n\n` */ +export async function streamChat(options: { + conversationId: string + messages: AgentMessageParam[] + org?: OrgSelection + signal?: AbortSignal + onEvent: (event: AgentEvent) => void +}): Promise { + const { conversationId, messages, org, signal, onEvent } = options + const response = await fetch(`${agentURL()}/api/chat`, { + method: 'POST', + headers: await agentHeaders(), + body: JSON.stringify(org ? { conversationId, messages, org } : { conversationId, messages }), + signal, + }) + if (response.status === 401) throw new AgentAuthError() + if (!response.ok || !response.body) throw new Error(`Agent request failed (${response.status})`) + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + let index: number + while ((index = buffer.indexOf('\n\n')) !== -1) { + const block = buffer.slice(0, index) + buffer = buffer.slice(index + 2) + let event = 'message' + const dataLines: string[] = [] + for (const line of block.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim() + else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart()) + } + if (dataLines.length) onEvent({ type: event, ...JSON.parse(dataLines.join('\n')) } as AgentEvent) + } + } +} + +/* Approve or deny a write tool the agent paused on */ +export async function confirmTool(options: { + conversationId: string + toolUseId: string + approved: boolean +}): Promise { + const response = await fetch(`${agentURL()}/api/chat/confirm`, { + method: 'POST', + headers: await agentHeaders(), + body: JSON.stringify(options), + }) + if (response.status === 401) throw new AgentAuthError() + if (!response.ok) throw new Error(`Confirm failed (${response.status})`) +} + +export type AgentHealth = 'ok' | 'unauthorized' | 'unreachable' + +export async function agentHealth(): Promise { + try { + const response = await fetch(`${agentURL()}/api/health`, { headers: await agentHeaders(false) }) + if (response.status === 401) return 'unauthorized' + if (!response.ok) return 'unreachable' + const body = (await response.json()) as { ok?: boolean } + return body.ok ? 'ok' : 'unreachable' + } catch { + return 'unreachable' + } +} diff --git a/frontend/src/services/chatPopout.ts b/frontend/src/services/chatPopout.ts new file mode 100644 index 000000000..6ff548cfa --- /dev/null +++ b/frontend/src/services/chatPopout.ts @@ -0,0 +1,224 @@ +// import type only: the chat model value-imports this service (signout +// broadcast), so a value import here would create a runtime cycle +import type { ChatTranscriptMessage } from '../models/chat' +// Shared with electron/src/ElectronApp.ts, which allows and sizes the window +import { CHAT_POPOUT_PARAM, CHAT_POPOUT_SIZE } from '@common/constants' + +/** + * Chat popout: the panel moves into its own window (same bundle, boot flag) + * and the conversation hands off over a BroadcastChannel. This module owns + * the flag, the channel, and the protocol; it never imports the store — + * callers inject handlers (avoids store/model import cycles). + */ +const OWNER_KEY = 'chatPopoutOwner' + +// Captured at module-evaluation time, before any routing can touch the URL +// (same pattern as the hydra ?code capture in services/hydra.ts) +const bootQuery = new URLSearchParams(window.location.search) +export const isChatPopout = bootQuery.has(CHAT_POPOUT_PARAM) +// The popout's identity — the flag's value ties it to the one tab that opened +// it. Every main tab hears the shared channel, so directed messages carry +// this id and non-owner tabs ignore them. +const popoutId = bootQuery.get(CHAT_POPOUT_PARAM) || '' + +// Per-tab (sessionStorage survives a reload of the owning tab, but no other +// tab has it): the id of the popout this tab opened, if any +const ownerId = (): string | null => window.sessionStorage.getItem(OWNER_KEY) + +export type ChatHandoff = { + messages: ChatTranscriptMessage[] + conversationId: string + orgId: string | null +} + +// Every message except the broadcast 'signout' is directed: it carries the +// popout's id so only the owning tab and its popout react to each other +type PopoutMessage = + | { type: 'hello'; id: string } + | { type: 'adopt'; id: string; payload: ChatHandoff } + | { type: 'handback'; id: string; payload: ChatHandoff } + | { type: 'ping'; id: string } + | { type: 'alive'; id: string } + | { type: 'signout' } + +export type PopoutMainHandlers = { + getHandoff: () => ChatHandoff + /** handback arrived: apply the transcript and reopen the dock */ + adopt: (payload: ChatHandoff) => void + /** popout said hello: hide the dock */ + onPopoutOpened: () => void + /** popout vanished without a handback: reopen the dock as-is */ + onPopoutLost: () => void + /** boot reconciliation: does a popout exist right now? */ + onPresence: (present: boolean) => void +} + +export type PopoutWindowHandlers = { + adopt: (payload: ChatHandoff) => void + getHandoff: () => ChatHandoff + onSignout: () => void +} + +const CHANNEL = 'remoteit-chat-popout' +const WINDOW_NAME = 'remoteit-chat' +const WINDOW_FEATURES = `popup=yes,width=${CHAT_POPOUT_SIZE.width},height=${CHAT_POPOUT_SIZE.height}` +const POLL_INTERVAL = 2000 +const PRESENCE_TIMEOUT = 500 + +const channel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(CHANNEL) : null +const post = (message: PopoutMessage) => channel?.postMessage(message) + +let popoutWindow: Window | null = null +let pollTimer: number | undefined +let aliveResolve: ((alive: boolean) => void) | null = null +let missedPings = 0 +let suppressHandback = false + +/* One liveness probe: resolves true on the popout's 'alive' reply, false + after PRESENCE_TIMEOUT. The boot presence check and the crash-net poll + both await this instead of threading shared timing flags. */ +const pingPopout = (id: string): Promise => + new Promise(resolve => { + aliveResolve?.(false) // a superseded probe counts as unanswered + aliveResolve = alive => { + aliveResolve = null + resolve(alive) + } + post({ type: 'ping', id }) + window.setTimeout(() => aliveResolve?.(false), PRESENCE_TIMEOUT) + }) + +/* ---------- main-window side ---------- */ + +export function openChatPopout(): boolean { + // Reuse the stored id so re-clicking Pop out re-targets the same named + // window instead of orphaning it under a new identity + const id = ownerId() || crypto.randomUUID().slice(0, 8) + const opened = window.open(`${window.location.origin}/?${CHAT_POPOUT_PARAM}=${id}`, WINDOW_NAME, WINDOW_FEATURES) + if (!opened) return false // popup blocked — dock stays; hello never arrives + window.sessionStorage.setItem(OWNER_KEY, id) + popoutWindow = opened + return true +} + +export function initChatPopoutMain(handlers: PopoutMainHandlers): () => void { + if (!channel) return () => {} + const listener = (event: MessageEvent) => { + const message = event.data + // Only the tab that owns this popout speaks its protocol; every other + // tab hears the channel too and must not adopt, hide its dock, or poll + if (message.type === 'signout' || message.id !== ownerId()) return + switch (message.type) { + case 'hello': + post({ type: 'adopt', id: message.id, payload: handlers.getHandoff() }) + handlers.onPopoutOpened() + startPolling(handlers) + break + case 'handback': + stopPolling() + handlers.adopt(message.payload) + break + case 'alive': + aliveResolve?.(true) + break + } + } + channel.addEventListener('message', listener) + return () => channel.removeEventListener('message', listener) +} + +/* Ask whether a popout survives from a previous page load; corrects a stale + persisted poppedOut flag either way */ +export function checkPopoutPresence(handlers: PopoutMainHandlers): void { + const id = ownerId() + if (!channel || !id) { + // Not this tab's popout (or no channel) — treat as absent for this tab + handlers.onPresence(false) + return + } + pingPopout(id).then(present => { + handlers.onPresence(present) + if (present) startPolling(handlers) + }) +} + +export function broadcastChatSignout(): void { + post({ type: 'signout' }) + // The popout is being closed deliberately — a lagging poll must not + // race in afterward and force `open: true` into freshly-reset state. + stopPolling() +} + +/* Crash net: a popout that dies without beforeunload still restores the + dock. Uses the window handle when we have one (same page load), pings + otherwise (main was reloaded while popped out). Two consecutive missed + replies are required before declaring it lost, so one slow reply doesn't + false-positive. */ +function startPolling(handlers: PopoutMainHandlers) { + if (pollTimer) return + missedPings = 0 + pollTimer = window.setInterval(() => { + if (popoutWindow) { + if (popoutWindow.closed) lost(handlers) + return + } + pingPopout(ownerId() || '').then(alive => { + if (!pollTimer) return + if (alive) missedPings = 0 + else if (++missedPings >= 2) lost(handlers) + }) + }, POLL_INTERVAL) +} + +function stopPolling() { + if (pollTimer) window.clearInterval(pollTimer) + pollTimer = undefined + popoutWindow = null + missedPings = 0 +} + +function lost(handlers: PopoutMainHandlers) { + stopPolling() + handlers.onPopoutLost() +} + +/* ---------- popout-window side ---------- */ + +export function initChatPopoutWindow(handlers: PopoutWindowHandlers): () => void { + if (!channel) return () => {} + const messageListener = (event: MessageEvent) => { + const message = event.data + // Directed messages must come from the owning tab; sign-out is broadcast + if (message.type !== 'signout' && message.id !== popoutId) return + switch (message.type) { + case 'adopt': + // Main's copy is authoritative at hand-off; until it arrives the + // window shows its own boot-time transcript + handlers.adopt(message.payload) + break + case 'ping': + post({ type: 'alive', id: popoutId }) + break + case 'signout': + suppressHandback = true // sign-out clears the transcript; nothing to hand back + handlers.onSignout() + break + } + } + const beforeUnloadListener = () => { + if (!suppressHandback) post({ type: 'handback', id: popoutId, payload: handlers.getHandoff() }) + } + channel.addEventListener('message', messageListener) + window.addEventListener('beforeunload', beforeUnloadListener) + post({ type: 'hello', id: popoutId }) + return () => { + channel.removeEventListener('message', messageListener) + window.removeEventListener('beforeunload', beforeUnloadListener) + } +} + +export function popIn(payload: ChatHandoff): void { + post({ type: 'handback', id: popoutId, payload }) + suppressHandback = true // beforeunload would duplicate it (harmless but noisy) + window.close() +} diff --git a/frontend/src/services/hydra.ts b/frontend/src/services/hydra.ts new file mode 100644 index 000000000..55853b356 --- /dev/null +++ b/frontend/src/services/hydra.ts @@ -0,0 +1,298 @@ +/** + * In-app Hydra sign-in for the agent service — OAuth 2.1 authorization code + + * PKCE, with self-service Dynamic Client Registration. Mirrors the MCP demo + * SPA in the authentication repo (hydra-login-consent/scripts/demo-spa). + * + * Flow: ensure a DCR client for this origin → full-page redirect to the Hydra + * login/consent pages → return to the app root with ?code → exchange for + * tokens → store access token + refresh session (services/agent.ts) → + * silently refresh before expiry. + * + * The register/token calls go through the dev vite proxy at /hydra + * (same-origin, so no CORS); the login redirect itself is a top-level + * navigation to the real issuer. Packaged builds need the app origin on the + * OAuth front's CORS allow-list, or the exchange moved to the Electron main + * process. + */ +import { + getAgentSession, + getAgentToken, + setAgentSession, + setAgentToken, + takeAgentCredentials, + decodeAgentSession, + decodeAgentToken, +} from './agent' +import { encryptString, decryptString } from './secureStorage' +import { store } from '../store' + +export const HYDRA_ISSUER = import.meta.env.VITE_HYDRA_ISSUER_URL || 'https://login.dev.remote.it' +export const MCP_AUDIENCE = import.meta.env.VITE_MCP_AUDIENCE || 'https://mcp.beta.remote.it/mcp' +const SCOPE = 'openid offline email device:read device:write device:connect device:execute' +const LIFESPAN = '30m' // access-token TTL override, verified accepted via DCR + +// OAuth fetches (DCR register, token exchange, revoke) always use the +// same-origin /hydra path: the vite proxy serves it in dev, and the Amplify +// rewrite rule serves it on deployed previews (login.dev.remote.it does not +// answer CORS preflights, so direct browser calls are blocked). The login +// redirect is a top-level navigation to the issuer and needs neither. +const OAUTH_API = '/hydra' + +const CLIENT_KEY = 'agentOauthClient' +const FLOW_KEY = 'agentOauthFlow' + +// Captured synchronously at module-evaluation time: the app's Cognito side +// (Amplify, configured with an oauth block) installs a URL listener that +// consumes and strips ?code/state params for ITS authorization-code flow. +// Our Hydra callback uses the same param names on the same origin, so we must +// grab them before Amplify boots — and only claim them when this tab actually +// started an agent sign-in (flow state present), so a genuine Cognito +// callback is left untouched. +// The callback is claimed only when its `state` matches the flow this tab +// started — a Cognito callback (same origin, same param names) never matches, +// so even a stale flow key left by an abandoned agent sign-in can't hijack it. +const bootParams = new URLSearchParams(window.location.search) +// The flow record keeps `state` readable (it is public — it rides the URL) +// so this synchronous gate can run before Amplify boots; the verifier and +// client id live encrypted in `data` and are only decrypted in the handler. +type StoredFlow = { state?: string; data?: string } +let bootFlow: StoredFlow | null = null +try { + bootFlow = JSON.parse(window.sessionStorage.getItem(FLOW_KEY) || 'null') +} catch {} +const isAgentCallback = + !!bootFlow?.state && bootParams.get('state') === bootFlow.state && (bootParams.has('code') || bootParams.has('error')) +if (isAgentCallback) { + // Strip immediately: hides the single-use code from Amplify's listener and + // from any reload. The hash route is preserved. + window.history.replaceState({}, '', window.location.pathname + window.location.hash) +} + +const b64url = (bytes: ArrayBuffer | Uint8Array): string => + btoa(String.fromCharCode(...new Uint8Array(bytes))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + +const randomString = (length: number): string => { + const bytes = new Uint8Array(length) + crypto.getRandomValues(bytes) + return b64url(bytes) +} + +const sha256 = async (value: string): Promise => + b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))) + +// The redirect must land somewhere this SPA is served; the app uses hash +// routing, so the root URL with a ?code query never collides with a route. +const redirectUri = (): string => `${window.location.origin}/` + +type StoredClient = { client_id: string; key: string } + +// One public client per (issuer, origin, scope, audience) — the cache key +// busts when the requested grant changes, like the demo SPA. +/* Effective audience for agent tokens: the Test UI override wins (Test + Settings → Override agent service) so tokens match the deployment the + tester pointed the chat at; changing it busts the client cache below. */ +const mcpAudience = (): string => { + const { switchAgent, mcpAudience: override } = store.getState().ui.apis + return (switchAgent && override?.trim()) || MCP_AUDIENCE +} + +const clientCacheKey = (): string => `${HYDRA_ISSUER}|${window.location.origin}|${SCOPE}|${mcpAudience()}` + +async function ensureClient(): Promise { + try { + const cached = JSON.parse(window.localStorage.getItem(CLIENT_KEY) || 'null') as StoredClient | null + if (cached?.client_id && cached.key === clientCacheKey()) return cached.client_id + } catch {} + + const response = await fetch(`${OAUTH_API}/oauth2/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'remote.it desktop agent chat', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + redirect_uris: [redirectUri()], + scope: SCOPE, + token_endpoint_auth_method: 'none', + authorization_code_grant_access_token_lifespan: LIFESPAN, + refresh_token_grant_access_token_lifespan: LIFESPAN, + }), + }) + if (!response.ok) throw new Error(`Agent sign-in registration failed (${response.status}): ${await response.text()}`) + const { client_id } = (await response.json()) as { client_id: string } + window.localStorage.setItem(CLIENT_KEY, JSON.stringify({ client_id, key: clientCacheKey() })) + return client_id +} + +/* Token-endpoint rejection with the HTTP status attached, so callers can + distinguish a definitive denial from a transient failure structurally + instead of parsing the message */ +class TokenRequestError extends Error { + constructor(public status: number, message: string) { + super(message) + } +} + +async function tokenRequest(params: Record): Promise<{ + access_token: string + refresh_token?: string + expires_in?: number +}> { + const response = await fetch(`${OAUTH_API}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(params), + }) + const text = await response.text() + if (!response.ok) throw new TokenRequestError(response.status, `Agent token exchange failed (${response.status}): ${text}`) + return JSON.parse(text) +} + +async function storeTokens( + clientId: string, + tokens: { access_token: string; refresh_token?: string; expires_in?: number } +) { + await setAgentToken(tokens.access_token) + await setAgentSession({ + refresh_token: tokens.refresh_token || (await getAgentSession())?.refresh_token || '', + // Missing expires_in falls back to the requested LIFESPAN — an expires_at + // of "now" would make every subsequent call fire a refresh grant + expires_at: Date.now() + (tokens.expires_in ?? 1800) * 1000, + client_id: clientId, + }) +} + +/* Kick off the sign-in: registers the client if needed, then navigates the + whole window to the Hydra login page. The app reloads on return. */ +export async function startAgentSignIn(): Promise { + const clientId = await ensureClient() + const verifier = randomString(32) + const state = randomString(16) + // The PKCE verifier (and client id) are encrypted at rest; only `state` + // stays plaintext for the synchronous boot-time callback gate + const data = await encryptString(JSON.stringify({ verifier, clientId })) + window.sessionStorage.setItem(FLOW_KEY, JSON.stringify({ state, data })) + + const auth = new URL(`${HYDRA_ISSUER}/oauth2/auth`) + auth.searchParams.set('response_type', 'code') + auth.searchParams.set('client_id', clientId) + auth.searchParams.set('redirect_uri', redirectUri()) + auth.searchParams.set('scope', SCOPE) + auth.searchParams.set('state', state) + auth.searchParams.set('code_challenge', await sha256(verifier)) + auth.searchParams.set('code_challenge_method', 'S256') + // RFC 8707: binds the access token's audience to the MCP resource + auth.searchParams.set('resource', mcpAudience()) + window.location.assign(auth.toString()) +} + +let callbackConsumed = false + +/* Complete the flow after the redirect back. Call once on app boot; returns + null when this page load carries no agent sign-in response. Reads the + module-scope capture, not the live URL (already stripped above). */ +export async function handleAgentSignInCallback(): Promise<{ ok: boolean; error?: string } | null> { + if (!isAgentCallback || callbackConsumed) return null + callbackConsumed = true + const code = bootParams.get('code') + const error = bootParams.get('error') + + // Consume the flow before any branch can return — a leftover key would stay + // armed and claim a later, unrelated OAuth callback on this origin. The + // state match is already guaranteed by the isAgentCallback gate above. + const stored = bootFlow + window.sessionStorage.removeItem(FLOW_KEY) + + if (error) { + // A client cached from before a scope/resource change can be rejected at + // authorize (e.g. invalid_target); drop it so the next attempt re-registers. + window.localStorage.removeItem(CLIENT_KEY) + return { ok: false, error: `${error}: ${bootParams.get('error_description') || ''}` } + } + + let flow: { verifier: string; clientId: string } | null = null + try { + const json = stored?.data ? await decryptString(stored.data) : null + flow = json ? JSON.parse(json) : null + } catch {} + if (!flow) return { ok: false, error: 'Sign-in expired — try again.' } + + try { + const tokens = await tokenRequest({ + grant_type: 'authorization_code', + code: code!, + redirect_uri: redirectUri(), + client_id: flow.clientId, + code_verifier: flow.verifier, + }) + await storeTokens(flow.clientId, tokens) + return { ok: true } + } catch (err) { + return { ok: false, error: (err as Error).message } + } +} + +/* Sign the agent out alongside the app: drop the local credentials, then + best-effort revoke the refresh token so Hydra's otherwise never-expiring + refresh chain dies server-side too. The DCR client registration is kept — + it belongs to the app origin, not the user. */ +export async function agentSignOut(): Promise { + // Clear synchronously first — a signOut-triggered reload must never find + // credentials still stored; the raw values are decoded after for revoke + const raw = takeAgentCredentials() + const session = await decodeAgentSession(raw.session) + const token = await decodeAgentToken(raw.token) + const revoke = (value: string, clientId: string) => + fetch(`${OAUTH_API}/oauth2/revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token: value, client_id: clientId }), + }) + try { + if (session?.refresh_token) await revoke(session.refresh_token, session.client_id) + else if (token && session?.client_id) await revoke(token, session.client_id) + } catch { + // Offline or proxy unavailable — locals are already cleared; the access + // token dies at its 30m TTL. + } +} + +let refreshPromise: Promise | null = null + +/* Refresh the access token when it is missing or close to expiry. Silent + no-op when there is nothing to refresh (e.g. a hand-pasted token). + Single-flight: concurrent callers (panel-open health check racing a send) + share one request — presenting a rotating refresh token twice trips + Hydra's reuse detection and revokes the whole chain. */ +export async function ensureFreshAgentToken(): Promise { + if (refreshPromise) return refreshPromise + const session = await getAgentSession() + if (!session?.refresh_token) return + const fresh = (await getAgentToken()) && Date.now() < session.expires_at - 60_000 + if (fresh) return + // Re-check after the async reads above: another caller may have started a + // refresh while we were reading — join it instead of racing a second grant + if (refreshPromise) return refreshPromise + refreshPromise = (async () => { + try { + const tokens = await tokenRequest({ + grant_type: 'refresh_token', + refresh_token: session.refresh_token, + client_id: session.client_id, + }) + storeTokens(session.client_id, tokens) + } catch (error) { + // Only a definitive rejection means the chain is dead (revoked or + // reuse-detection) — then clear so the UI falls back to the sign-in + // prompt. A transient network/proxy failure keeps the session so a + // later call can retry instead of forcing a full re-sign-in. + if (error instanceof TokenRequestError && [400, 401, 403].includes(error.status)) setAgentSession(null) + } finally { + refreshPromise = null + } + })() + return refreshPromise +} diff --git a/frontend/src/services/secureStorage.ts b/frontend/src/services/secureStorage.ts new file mode 100644 index 000000000..20c76193c --- /dev/null +++ b/frontend/src/services/secureStorage.ts @@ -0,0 +1,89 @@ +/** + * At-rest encryption for sensitive values persisted to localStorage (the + * agent's OAuth tokens). The AES-GCM key is generated non-extractable and + * lives only in IndexedDB: running code on this origin can ask it to + * encrypt/decrypt, but the key material itself can never be read out — so a + * copied localStorage (backups, disk images, extensions reading storage) + * yields ciphertext only. Both app windows (main and chat popout) share the + * key through the same origin-scoped database. + */ + +const DB_NAME = 'remoteit-secure' +const STORE = 'keys' +const KEY_ID = 'at-rest' +const PREFIX = 'enc.v1.' +const IV_LENGTH = 12 + +export const isEncrypted = (value: string): boolean => value.startsWith(PREFIX) + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = window.indexedDB.open(DB_NAME, 1) + request.onupgradeneeded = () => request.result.createObjectStore(STORE) + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) +} + +/* Get-or-create inside one readwrite transaction so concurrent windows can't + race two different keys into existence (the freshly generated key is + discarded when another window won) */ +async function loadOrStoreKey(fresh: CryptoKey): Promise { + const db = await openDb() + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite') + const keys = tx.objectStore(STORE) + const existing = keys.get(KEY_ID) + existing.onsuccess = () => { + if (existing.result) resolve(existing.result as CryptoKey) + else { + keys.put(fresh, KEY_ID) + resolve(fresh) + } + } + tx.onerror = () => reject(tx.error) + }) + } finally { + db.close() + } +} + +let keyPromise: Promise | null = null + +function atRestKey(): Promise { + keyPromise ??= crypto.subtle + .generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']) + .then(loadOrStoreKey) + .catch(error => { + keyPromise = null // e.g. IndexedDB unavailable — let a later call retry + throw error + }) + return keyPromise +} + +export async function encryptString(plain: string): Promise { + const key = await atRestKey() + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)) + const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(plain)) + const bytes = new Uint8Array(IV_LENGTH + cipher.byteLength) + bytes.set(iv) + bytes.set(new Uint8Array(cipher), IV_LENGTH) + let binary = '' + bytes.forEach(byte => (binary += String.fromCharCode(byte))) + return PREFIX + btoa(binary) +} + +/* null when the value isn't ours to read: wrong/rotated key, corrupt data, + or not an encrypted value at all — callers treat it as signed out */ +export async function decryptString(value: string): Promise { + if (!isEncrypted(value)) return null + try { + const bytes = Uint8Array.from(atob(value.slice(PREFIX.length)), c => c.charCodeAt(0)) + const key = await atRestKey() + const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: bytes.slice(0, IV_LENGTH) }, key, bytes.slice(IV_LENGTH)) + return new TextDecoder().decode(plain) + } catch { + return null + } +} diff --git a/frontend/src/store.ts b/frontend/src/store.ts index c820815be..8f3eb9519 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1,8 +1,10 @@ import { numericVersion } from './helpers/versionHelper' import { models, RootModel } from './models' +import { defaultChatState, IChatState } from './models/chat' +import { isChatPopout } from './services/chatPopout' import { createLogger, ReduxLoggerOptions } from 'redux-logger' import { init, RematchDispatch, RematchRootState } from '@rematch/core' -import { PersistConfig } from 'redux-persist' +import { createTransform, PersistConfig } from 'redux-persist' import persistPlugin, { getPersistor } from '@rematch/persist' import DateTransform from './helpers/DateTransform' import immerPlugin from '@rematch/immer' @@ -12,29 +14,50 @@ const loggerConfig: ReduxLoggerOptions = { predicate: () => !!(window as any).stateLogging, } +// Persist only the durable chat fields — streaming/pendingConfirmation/error/ +// health are runtime-only and must never survive a reload +const chatTransform = createTransform( + (inbound: IChatState) => ({ + messages: inbound.messages, + conversationId: inbound.conversationId, + orgId: inbound.orgId, + open: inbound.open, + expanded: inbound.expanded, + poppedOut: inbound.poppedOut, + }), + (outbound: Partial) => ({ ...defaultChatState, ...outbound }), + { whitelist: ['chat'] } +) + const persistConfig: PersistConfig = { key: 'app', version: numericVersion(), storage: localForage, - whitelist: [ - 'accounts', - 'announcements', - 'applicationTypes', - 'connections', - 'contacts', - 'devices', - 'files', - 'jobs', - 'networks', - 'organization', - 'plans', - 'products', - 'sessions', - 'tags', - 'user', - ], + // The chat popout window is a second full app instance on the same storage + // key; it adopts its transcript over the BroadcastChannel handoff and must + // never write, or the two windows clobber each other (last-writer-wins) + whitelist: isChatPopout + ? [] + : [ + 'accounts', + 'announcements', + 'applicationTypes', + 'chat', + 'connections', + 'contacts', + 'devices', + 'files', + 'jobs', + 'networks', + 'organization', + 'plans', + 'products', + 'sessions', + 'tags', + 'user', + ], throttle: 1000, - transforms: [DateTransform], + transforms: [DateTransform, chatTransform], } export const store = init({ diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index eaba04531..8bd850ff3 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,28 +1,61 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' // https://vitejs.dev/config/ -export default defineConfig(({ mode }) => ({ - build: { - outDir: 'build', - minify: mode === 'production', - emptyOutDir: true, - sourcemap: true, - assetsInlineLimit: 0, - rollupOptions: { - output: { - manualChunks(id) { - if (id.includes('node_modules')) { - return 'vendor' - } +export default defineConfig(({ mode }) => { + // loadEnv sees frontend/.env files; process.env would only see shell vars + const env = loadEnv(mode, __dirname, '') + return { + build: { + outDir: 'build', + minify: mode === 'production', + emptyOutDir: true, + sourcemap: true, + assetsInlineLimit: 0, + rollupOptions: { + output: { + manualChunks(id: string) { + if (id.includes('node_modules')) { + // Keep the markdown renderer's parser tree out of the always- + // loaded vendor chunk — it belongs to the lazy-loaded chat panel + // (small shared utils it pulls in may still land in vendor) + if (/[\\/]node_modules[\\/](react-markdown|remark-|rehype-|micromark|mdast-|unified|hast-|vfile|unist-)/.test(id)) + return + return 'vendor' + } + }, }, }, }, - }, - plugins: [react()], - resolve: { - alias: { '@common': path.resolve(__dirname, '../common/src') }, - }, - type: 'module', -})) + plugins: [react()], + resolve: { + alias: { '@common': path.resolve(__dirname, '../common/src') }, + }, + server: { + // Dev-only: same-origin path to the ai-agent service, so the app's CSP + // ('self') passes without loosening. Defaults to the local dev service; + // set AGENT_PROXY_TARGET in frontend/.env to point at a deployed agent + // (e.g. http://dev-ai-agent.remote.it — its ALB is HTTP-only for now, so + // the same-origin proxy also sidesteps the CSP https:-only rule). + // Staging/prod builds set VITE_AGENT_URL instead — no proxy in builds. + proxy: { + '/agent': { + target: env.AGENT_PROXY_TARGET || 'http://localhost:3001', + changeOrigin: true, + rewrite: (p: string) => p.replace(/^\/agent/, ''), + }, + // Dev-only: same-origin path to the Hydra OAuth front so the browser's + // DCR + token-exchange calls avoid CORS entirely (top-level login + // redirects go to the real domain and don't need this). Packaged builds + // need the origin CORS-allow-listed or a main-process exchange instead. + '/hydra': { + target: env.VITE_HYDRA_ISSUER_URL || 'https://login.dev.remote.it', + changeOrigin: true, + rewrite: (p: string) => p.replace(/^\/hydra/, ''), + }, + }, + }, + type: 'module', + } +}) diff --git a/package-lock.json b/package-lock.json index fbd3ff8c6..82983fa3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -216,6 +216,7 @@ "react-dropzone": "^14.3.8", "react-gtm-module": "^2.0.11", "react-i18next": "^12.3.1", + "react-markdown": "^9.0.1", "react-redux": "^9.2.0", "react-router-dom": "^5.3.4", "react-select": "^5.10.2", @@ -225,6 +226,7 @@ "redux": "^5.0.1", "redux-logger": "^3.0.6", "redux-persist": "^6.0.0", + "remark-gfm": "^4.0.0", "reselect": "^5.1.1", "screenfull": "^6.0.2", "seedrandom": "^3.0.5", @@ -5956,7 +5958,6 @@ "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/ms": "*" @@ -5966,9 +5967,17 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "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/express": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", @@ -6031,6 +6040,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/history": { "version": "4.7.11", "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", @@ -6159,6 +6177,15 @@ "dev": true, "license": "MIT" }, + "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/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -6183,7 +6210,6 @@ "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": { @@ -6465,6 +6491,12 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "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/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -7467,6 +7499,16 @@ "@babel/core": "^7.0.0" } }, + "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": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -8197,6 +8239,16 @@ "@capacitor/core": ">=7.0.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/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8224,6 +8276,46 @@ "node": ">=10" } }, + "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/charenc": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", @@ -8685,6 +8777,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": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -9703,6 +9805,19 @@ "node": ">=0.10.0" } }, + "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", @@ -9847,6 +9962,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -9890,6 +10014,19 @@ "integrity": "sha512-ndLq+hZriMCFgF/6eTYt8x+oe1O0F2AaIREWhupxu000y+rP5tswLzQfBbhXRBt5corTeNHGbbhkZRwPqJBU7A==", "license": "MIT" }, + "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/diff": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", @@ -10899,6 +11036,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/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -11127,6 +11274,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "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/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -12368,6 +12521,46 @@ "node": ">= 0.4" } }, + "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-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/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -12497,6 +12690,16 @@ "void-elements": "3.1.0" } }, + "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/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -12955,6 +13158,12 @@ "node": ">=10" } }, + "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/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -13000,6 +13209,30 @@ "node": ">=8" } }, + "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-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -13159,6 +13392,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -13268,6 +13511,16 @@ "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-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -14765,6 +15018,16 @@ "node": ">= 12.0.0" } }, + "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/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -14859,6 +15122,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -14928,117 +15201,387 @@ "is-buffer": "~1.1.6" } }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "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", - "engines": { - "node": ">= 0.6" + "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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", - "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "license": "MIT" - }, - "node_modules/memorystream": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", - "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", - "dev": true, - "engines": { - "node": ">= 0.10.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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/meow": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", - "integrity": "sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==", + "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": { - "@types/minimist": "^1.2.0", - "camelcase-keys": "^6.2.2", - "decamelize-keys": "^1.1.0", - "hard-rejection": "^2.1.0", - "minimist-options": "4.1.0", - "normalize-package-data": "^3.0.0", - "read-pkg-up": "^7.0.1", - "redent": "^3.0.0", - "trim-newlines": "^3.0.0", - "type-fest": "^0.18.0", - "yargs-parser": "^20.2.3" - }, - "engines": { - "node": ">=10" + "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": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/meow/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "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": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "@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" }, - "engines": { - "node": ">=8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/meow/node_modules/hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", - "license": "ISC" - }, - "node_modules/meow/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "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": { - "p-locate": "^4.1.0" + "@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" }, - "engines": { - "node": ">=8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/meow/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "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": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/meow/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "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": { - "p-limit": "^2.2.0" + "@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" }, - "engines": { - "node": ">=8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/meow/node_modules/read-pkg": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", - "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "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/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/meow": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", + "integrity": "sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==", + "license": "MIT", + "dependencies": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.18.0", + "yargs-parser": "^20.2.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "license": "ISC" + }, + "node_modules/meow/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", "license": "MIT", "dependencies": { "@types/normalize-package-data": "^2.4.0", @@ -15106,52 +15649,615 @@ "semver": "bin/semver" } }, - "node_modules/meow/node_modules/type-fest": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", - "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node_modules/meow/node_modules/type-fest": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "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", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "micromark-util-types": "^2.0.0" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "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", - "engines": { - "node": ">= 8" + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "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", - "engines": { - "node": ">= 0.6" + "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/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -16267,6 +17373,31 @@ "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/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -16907,6 +18038,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/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -17338,6 +18479,33 @@ "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-native-url-polyfill": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-1.3.0.tgz", @@ -17772,6 +18940,72 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/remoteit": { "resolved": "electron", "link": true @@ -19282,6 +20516,16 @@ "node": ">=0.10.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/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -19575,6 +20819,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", @@ -19654,6 +20912,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -20038,6 +21314,16 @@ "tree-kill": "cli.js" } }, + "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/trim-newlines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", @@ -20056,6 +21342,16 @@ "node": ">= 14.0.0" } }, + "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", @@ -20421,6 +21717,37 @@ "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==", "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/unified/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/unique-string": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", @@ -20436,6 +21763,74 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/universal-cookie": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.2.2.tgz", @@ -20699,6 +22094,34 @@ "node": ">= 0.8" } }, + "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-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/vinyl": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", @@ -21559,6 +22982,16 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, + "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" + } + }, "node_modules/zxcvbn": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz",