diff --git a/.agents/plugins/agent-native-turn-into-app/.codex-plugin/plugin.json b/.agents/plugins/agent-native-turn-into-app/.codex-plugin/plugin.json index 45d6125fb4..2288c6bb69 100644 --- a/.agents/plugins/agent-native-turn-into-app/.codex-plugin/plugin.json +++ b/.agents/plugins/agent-native-turn-into-app/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-native-turn-into-app", - "version": "1.0.0+codex.5430c9cab656", + "version": "1.0.0+codex.c50dbd31a986", "description": "Turn visible project context, a proven thread, skill, or workflow into a runnable Agent-Native app. On Claude or ChatGPT Web, it hands a bounded source brief to Builder through Dispatch; local code agents can build and verify in a workspace.", "author": { "name": "Agent-Native", "url": "https://agent-native.com" }, "homepage": "https://dispatch.agent-native.com", diff --git a/.agents/plugins/agent-native-turn-into-app/plugin.json b/.agents/plugins/agent-native-turn-into-app/plugin.json index 4919d63c0d..588a17727a 100644 --- a/.agents/plugins/agent-native-turn-into-app/plugin.json +++ b/.agents/plugins/agent-native-turn-into-app/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "agent-native-turn-into-app", - "version": "1.0.0+codex.5430c9cab656", + "version": "1.0.0+codex.c50dbd31a986", "description": "Turn visible project context, a proven thread, skill, or workflow into a runnable Agent-Native app. On Claude or ChatGPT Web, it hands a bounded source brief to Builder through Dispatch; local code agents can build and verify in a workspace.", "author": { "name": "Agent-Native", "url": "https://agent-native.com" }, "homepage": "https://dispatch.agent-native.com", diff --git a/.agents/plugins/agent-native-turn-into-app/skills/turn-into-app/SKILL.md b/.agents/plugins/agent-native-turn-into-app/skills/turn-into-app/SKILL.md index 6ff17ee236..9c3652c6f6 100644 --- a/.agents/plugins/agent-native-turn-into-app/skills/turn-into-app/SKILL.md +++ b/.agents/plugins/agent-native-turn-into-app/skills/turn-into-app/SKILL.md @@ -3,8 +3,9 @@ name: turn-into-app description: >- Turn visible project context, a proven thread, skill, or workflow into a runnable Agent-Native app with simple buttons, visible agent steps, preview, - and deployment handoff. Use when a user invokes `/turn-into-app` or asks to - make a workflow into an app, including from Claude or ChatGPT on the web. + and deployment handoff. Use when a user invokes `/turn-into-app`, + `/make-into-app`, or asks to make a workflow into an app, including from + Claude or ChatGPT on the web. user-invocable: true scope: both metadata: @@ -38,6 +39,15 @@ The build location depends on the host. Treat this as a hard routing rule: workspace, follow the local implementation steps below. The local agent may scaffold, edit, run, and verify the app there. +Once the source brief is sufficient to identify a repeatable workflow, the +handoff is non-interactive. Do not ask the user for visual, product, copy, +layout, template, integration, or implementation choices that can be resolved +from the source. Select the source's recommended option; otherwise choose the +most direct conventional default and record the assumption for later review. +Only stop for a genuine hard blocker such as missing authorization, a +destructive external action, an ambiguous target workspace, or no identifiable +workflow at all. + ## Default behavior For a local code-agent runtime this is an end-to-end build skill, not a request @@ -104,14 +114,16 @@ before creating the app. Read the host-provided context in this order: Record the brief with these headings before handoff: source and provenance, project goal, configuration and constraints, knowledge sources, repeatable workflow, inputs and outputs, judgment and review points, representative runs, -integrations and permissions, and unknowns. This is the compact contract for +integrations and permissions, and unknowns and assumptions. This is the compact contract for the app. It keeps the new app useful without pretending that hidden Project history was imported. See [the fresh Project reference](references/fresh-project.md) for the host setup and brief template. -If the visible Project context has no concrete repeatable job, ask for one -focused clarification or a representative artifact. Do not fall back to a -generic “what app do you want to make?” builder. +If the visible Project context has no concrete repeatable job and no primary +goal can be inferred, ask for one focused clarification or a representative +artifact. Otherwise use the project's primary goal and source conventions; do +not ask a questionnaire and do not fall back to a generic “what app do you +want to make?” builder. ## Source selection guard diff --git a/.agents/plugins/agent-native-turn-into-app/skills/turn-into-app/references/fresh-project.md b/.agents/plugins/agent-native-turn-into-app/skills/turn-into-app/references/fresh-project.md index f88beb1702..53fb46c3b5 100644 --- a/.agents/plugins/agent-native-turn-into-app/skills/turn-into-app/references/fresh-project.md +++ b/.agents/plugins/agent-native-turn-into-app/skills/turn-into-app/references/fresh-project.md @@ -36,7 +36,7 @@ Inputs and outputs: Judgment and review points: Representative runs: none, or 1-3 selected examples Integrations and permissions: -Unknowns and follow-up: +Unknowns and assumptions: ``` Keep source references bounded. Prefer a file name, URL, resource ID, or short @@ -69,6 +69,13 @@ do not fall back to a local sandbox build. Claude Code and Codex Code are the local-agent exception and may follow the implementation steps in the main skill. +The brief is an implementation authorization, not a questionnaire. After a +repeatable workflow is identifiable, choose any source-recommended option +automatically. For unresolved non-blocking choices, use the most direct +conventional default, record it under assumptions, and continue. Ask only when +there is no identifiable workflow, no authorized target workspace, or a +destructive/credential boundary genuinely prevents the handoff. + In a new Project chat, say: ```text diff --git a/.agents/skills/adding-a-feature/SKILL.md b/.agents/skills/adding-a-feature/SKILL.md index bf8e1e4f68..817c8c9cef 100644 --- a/.agents/skills/adding-a-feature/SKILL.md +++ b/.agents/skills/adding-a-feature/SKILL.md @@ -81,6 +81,13 @@ or other file-like payloads, design the upload path in the same change: provider upload first, then URL/id/blob handle in SQL. Do not add base64/binary columns or stuff files into `application_state`. +If the feature adds a server-side dependency carrying a native binary or heavy +runtime — headless browser, ffmpeg, image/video processing, ML runtime — read +the `performance` skill §9 before adding it. A deployed app has one `/*` page +function that every visitor's cache miss wakes, and it ships whatever the server +bundle depends on. Run that work from a background function or a job so the page +function never carries the weight. + **If the action produces or lists a navigable resource**, add a `link` builder that returns `{ url: buildDeepLink({ app, view, params }), label }`. External coding agents and MCP hosts (Claude / ChatGPT / Claude Code / Cowork / Codex, over MCP/A2A) then surface an "Open in … →" deep link that drops the user back into the running UI focused on the record — for free. If a compatible MCP host should render an inline review/edit surface, also add `mcpApp` with `embedApp()` so the action embeds the real React app route instead of a one-off HTML UI. The `link` builder and `mcpApp` metadata must be pure and synchronous (no I/O). Any external-agent read/ingest action must be `http: { method: "GET" }` + `readOnly: true` + `publicAgent: { expose: true, readOnly: true, requiresAuth: true }`. See the `external-agents` skill. ### 3. Skills / Instructions @@ -195,4 +202,6 @@ TL;DR: declare the vocabulary once with `defineAppRoles({ appId, roles, defaultR - **create-skill** — How to create skills for new patterns (area 3 in detail) - **storing-data** — Where to store the feature's data - **real-time-sync** — How the UI stays in sync when the agent writes data +- **performance** — Query/load cost, and (§9) cold-start artifact size when a + feature adds a server-side dependency - **internationalization** — How to update localized UI copy and catalogs diff --git a/.agents/skills/chat-first-workbench/SKILL.md b/.agents/skills/chat-first-workbench/SKILL.md new file mode 100644 index 0000000000..1a3aa54bfe --- /dev/null +++ b/.agents/skills/chat-first-workbench/SKILL.md @@ -0,0 +1,54 @@ +--- +name: chat-first-workbench +description: >- + Implement or extend the opt-in Codex/T3-like chat-first shell. Use when + changing chat rails, contextual app panes, app opening, or local/mobile shell + preferences across Electron, Dispatch, or mobile. +scope: dev +--- + +# Chat-first workbench + +## Contract + +The chat remains the primary surface. Workspace apps are a bounded discovery +rail, not a second navigation system. An app pane is opened from an explicit +app selection or a completed open_app action and carries only an app id plus a +validated app-relative path or view. + +## Client behavior + +- The preference is opt-in and local to the client. Keep the existing app-first + shell unchanged when it is disabled. +- Electron uses the shared Code Agents rail and AppWebview. +- Dispatch uses list-workspace-apps and create_embed_session; never iframe a + raw workspace URL. Resolve an absolute open_app URL against the registered + app first, then pass the resulting app-relative path. +- Mobile uses the existing app registry and secure AppWebView routes instead of + accepting arbitrary URLs or adding a second navigator. +- Show no nested app sidebar or browser chrome in a contextual pane. +- The app shelf is capped at six visible entries. Pinning and drag-reordering + are device-local presentation preferences; the agent-visible target remains + the registered app id and route. + +## Agent and state boundaries + +- Keep app discovery, embed-session minting, and thread operations on the + existing action surfaces. +- Emit the shared chat-first open-app event only for the completed first-party + `open_app` action with a real app, path, or view. A missing or unreadable + result is not success, and namespaced third-party tools cannot steer the + pane. +- Both web and desktop resolve the event through the same core origin/base-path + allow-list. An unresolved target produces a visible notice and no pane. +- If the agent needs to know which app is visible, expose a compact + application-state selection instead of scraping the iframe DOM. +- Cloud handoff is a future execution-residence seam. Do not claim that a local + run has moved to a worker until a durable worker snapshot and readback exist. + +## Verification + +Test the disabled path and the enabled path separately. For the enabled path, +verify rail ordering, chat history, app selection, embed-session errors, pane +close, and completed open_app behavior in a running client. Treat configuration +alone as insufficient proof. diff --git a/.agents/skills/turn-into-app/SKILL.md b/.agents/skills/turn-into-app/SKILL.md index 6ff17ee236..9c3652c6f6 100644 --- a/.agents/skills/turn-into-app/SKILL.md +++ b/.agents/skills/turn-into-app/SKILL.md @@ -3,8 +3,9 @@ name: turn-into-app description: >- Turn visible project context, a proven thread, skill, or workflow into a runnable Agent-Native app with simple buttons, visible agent steps, preview, - and deployment handoff. Use when a user invokes `/turn-into-app` or asks to - make a workflow into an app, including from Claude or ChatGPT on the web. + and deployment handoff. Use when a user invokes `/turn-into-app`, + `/make-into-app`, or asks to make a workflow into an app, including from + Claude or ChatGPT on the web. user-invocable: true scope: both metadata: @@ -38,6 +39,15 @@ The build location depends on the host. Treat this as a hard routing rule: workspace, follow the local implementation steps below. The local agent may scaffold, edit, run, and verify the app there. +Once the source brief is sufficient to identify a repeatable workflow, the +handoff is non-interactive. Do not ask the user for visual, product, copy, +layout, template, integration, or implementation choices that can be resolved +from the source. Select the source's recommended option; otherwise choose the +most direct conventional default and record the assumption for later review. +Only stop for a genuine hard blocker such as missing authorization, a +destructive external action, an ambiguous target workspace, or no identifiable +workflow at all. + ## Default behavior For a local code-agent runtime this is an end-to-end build skill, not a request @@ -104,14 +114,16 @@ before creating the app. Read the host-provided context in this order: Record the brief with these headings before handoff: source and provenance, project goal, configuration and constraints, knowledge sources, repeatable workflow, inputs and outputs, judgment and review points, representative runs, -integrations and permissions, and unknowns. This is the compact contract for +integrations and permissions, and unknowns and assumptions. This is the compact contract for the app. It keeps the new app useful without pretending that hidden Project history was imported. See [the fresh Project reference](references/fresh-project.md) for the host setup and brief template. -If the visible Project context has no concrete repeatable job, ask for one -focused clarification or a representative artifact. Do not fall back to a -generic “what app do you want to make?” builder. +If the visible Project context has no concrete repeatable job and no primary +goal can be inferred, ask for one focused clarification or a representative +artifact. Otherwise use the project's primary goal and source conventions; do +not ask a questionnaire and do not fall back to a generic “what app do you +want to make?” builder. ## Source selection guard diff --git a/.agents/skills/turn-into-app/references/fresh-project.md b/.agents/skills/turn-into-app/references/fresh-project.md index f88beb1702..53fb46c3b5 100644 --- a/.agents/skills/turn-into-app/references/fresh-project.md +++ b/.agents/skills/turn-into-app/references/fresh-project.md @@ -36,7 +36,7 @@ Inputs and outputs: Judgment and review points: Representative runs: none, or 1-3 selected examples Integrations and permissions: -Unknowns and follow-up: +Unknowns and assumptions: ``` Keep source references bounded. Prefer a file name, URL, resource ID, or short @@ -69,6 +69,13 @@ do not fall back to a local sandbox build. Claude Code and Codex Code are the local-agent exception and may follow the implementation steps in the main skill. +The brief is an implementation authorization, not a questionnaire. After a +repeatable workflow is identifiable, choose any source-recommended option +automatically. For unresolved non-blocking choices, use the most direct +conventional default, record it under assumptions, and continue. Ask only when +there is no identifiable workflow, no authorized target workspace, or a +destructive/credential boundary genuinely prevents the handoff. + In a new Project chat, say: ```text diff --git a/.agents/skills/workspace-conventions/SKILL.md b/.agents/skills/workspace-conventions/SKILL.md index ccdc0e2daa..e93c3f41f9 100644 --- a/.agents/skills/workspace-conventions/SKILL.md +++ b/.agents/skills/workspace-conventions/SKILL.md @@ -69,6 +69,12 @@ Preview before `--apply`, commit `agent-native.ejections.json`, and never edit data, or credential-looking literals in source, docs, prompts, fixtures, application state, action responses, or generated app content. Use secrets/OAuth/runtime configuration and obvious placeholders in examples. +- Each app deploys its own `/*` page function, and that function ships whatever + the app's server bundle depends on — including a dependency only a background + job uses. Before an app takes on a heavy runtime (headless browser, ffmpeg, + media processing, ML), read `.agents/skills/performance/SKILL.md` §9 and keep + that work in a background function or job. A dependency belonging to one app + does not belong in `packages/shared`. - Prefer framework defaults until the workspace has a real custom rule, component, plugin, action, or skill to share. - Keep the Workspace files view for user-authored or user-requested resources. @@ -91,5 +97,6 @@ Preview before `--apply`, commit `agent-native.ejections.json`, and never edit - **agent-native-toolkit** — Inventory of shared workspace and agent UI. - **customizing-agent-native** — The configure → compose → eject ladder. - **delegate-to-agent** — Routing every AI feature through the agent chat. +- **performance** — Load cost, and (§9) cold-start artifact size per app. - **secrets** — Registering API keys and service credentials. - **storing-data** — Where structured data and large payloads belong. diff --git a/.changeset/agent-plugin-agent-visible.md b/.changeset/agent-plugin-agent-visible.md new file mode 100644 index 0000000000..ed09e725d9 --- /dev/null +++ b/.changeset/agent-plugin-agent-visible.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Keep imported Agent Plugin skills in the agent-visible workspace skills tree, including namespaced plugin skills. diff --git a/.changeset/autonomous-workspace-app-builds.md b/.changeset/autonomous-workspace-app-builds.md new file mode 100644 index 0000000000..500bedce35 --- /dev/null +++ b/.changeset/autonomous-workspace-app-builds.md @@ -0,0 +1,5 @@ +--- +"@agent-native/dispatch": patch +--- + +Make turn-into-app Builder handoffs autonomous by choosing recommended defaults and recording non-blocking assumptions instead of stopping for questions. diff --git a/.changeset/chat-first-workbench.md b/.changeset/chat-first-workbench.md new file mode 100644 index 0000000000..67117df6bc --- /dev/null +++ b/.changeset/chat-first-workbench.md @@ -0,0 +1,7 @@ +--- +"@agent-native/core": minor +"@agent-native/dispatch": minor +"@agent-native/toolkit": patch +--- + +Add an opt-in chat-first workbench with contextual app surfaces for desktop, Dispatch, and mobile clients. diff --git a/.changeset/fence-background-automation-workers.md b/.changeset/fence-background-automation-workers.md new file mode 100644 index 0000000000..8e78ddb1f0 --- /dev/null +++ b/.changeset/fence-background-automation-workers.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Prevent duplicate recurring automation dispatch across multiple app deployments and preserve verified Builder credentials for detached background runs. diff --git a/.changeset/prerender-public-template-pages.md b/.changeset/prerender-public-template-pages.md new file mode 100644 index 0000000000..3ad96d4a58 --- /dev/null +++ b/.changeset/prerender-public-template-pages.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Document prerendering in the scaffolded app's `react-router.config.ts`: a commented, correct example with the three conditions a path must meet (public, build-time constant, never redirecting), left off by default because every route the scaffold ships is signed-in and a prerendered file is served without auth middleware running. diff --git a/.changeset/repair-structured-document-titles.md b/.changeset/repair-structured-document-titles.md new file mode 100644 index 0000000000..d2ebfd3f87 --- /dev/null +++ b/.changeset/repair-structured-document-titles.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Repair structured route metadata before it can appear as a serialized payload in browser tab titles. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8298f21527..6027468b41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -618,18 +618,22 @@ jobs: - name: Build Content template (Cloudflare Pages preset) run: NITRO_PRESET=cloudflare_pages pnpm --filter content build - # Plan covers diagram-heavy SSR and Clips covers public-share SSR. Build - # both real Netlify function artifacts so a core runtime dependency cannot - # be missing from either deployment's server bundle. - - name: Build Plan and Clips templates (netlify preset) - run: NITRO_PRESET=netlify pnpm --filter plan --filter clips build + # Three templates, one per size class, because building all 17 per PR is + # too slow: Plan covers diagram-heavy SSR, Clips covers public-share SSR, + # and Assets is the one that legitimately depends on creative-context — so + # it is the only build that exercises the browser-runtime size budget. + - name: Build Plan, Clips, and Assets templates (netlify preset) + run: NITRO_PRESET=netlify pnpm --filter plan --filter clips --filter assets build - name: Import SSR handler — must not crash at cold-start # External timeout is a backstop against a pathological synchronous hang; # the script itself races module eval against a 30s window and force-exits. # Match the deployed Netlify environment so failed best-effort local # SQLite setup cannot call process.exit during this import-only probe. - run: NETLIFY=true timeout 120 node scripts/ssr-boot-smoke.mjs plan clips + # This lane intentionally probes the three built representatives above. + # The smoke script's optional `--report-uncovered` mode is fail-closed; + # use it only when the caller intends to gate on full template coverage. + run: NETLIFY=true timeout 180 node scripts/ssr-boot-smoke.mjs plan clips assets # Exercise the two other Node serverless presets as well. These import # their generated handlers, so an unresolved bare runtime dependency diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 4dcc13eb4f..6bce0cc482 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -74,6 +74,7 @@ values from `.env` files, and use placeholders such as `` in examples. | `WORKSPACE_SECRETS_ENCRYPTION_KEY` | Stable encryption key for a workspace-shared secrets vault. | | `WORKSPACE_SECRETS_ENCRYPTION_KEY_PREVIOUS` | Previous workspace vault key during a rotation window. | | `AUTH_MODE` | Local authentication mode selection. | +| `AGENT_NATIVE_ALLOW_BUILDER_PREVIEW_LOCAL_DEV` | Explicitly enables the local-dev sign-in button on approved Builder preview hosts during development; keep unset on public or production deployments. | | `AUTH_DISABLED` | Local/preview-only auth bypass; unset already means false, and it must never be used for a public production app. | | `AUTH_MAGIC_LINK` | Set to `0` to force the email/password fallback; otherwise a ready email transport enables magic links by default. | | `ACCESS_TOKEN` / `ACCESS_TOKENS` | Static bearer fallback for MCP/connect clients, not browser authentication. | @@ -220,18 +221,19 @@ authority for defaults and accepted values. These are useful for maintainers and automation, not required for a normal production deployment: -| Pattern | Scope | -| ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | -| `E2E_*` / `PLAYWRIGHT_*` / `*_SMOKE_*` / `SIGN_IN_MATRIX_*` | Browser, end-to-end, and smoke-test configuration. | -| `HEADLESS_ONRAMP_*` / `STANDALONE_CHAT_DEV_*` | CLI and standalone-chat QA harnesses. | -| `DEV_*` / `DEBUG_*` / `*_DEBUG` / `CHOKIDAR_*` | Local development and diagnostics. | -| `AUTH_SKIP_EMAIL_VERIFICATION` | Local/test auth convenience; never use to weaken production auth. | -| `VITEST_*` / `VITEST` | Test-runner behavior and worker configuration. | -| `UPDATE_I18N_*` | Maintainer-only i18n baseline updates. | -| `CODESPACES` / `GITPOD_*` / `CODEX_HOME` / `XDG_CONFIG_HOME` / `CLAUDE_CONFIG_DIR` / `COREPACK_HOME` | Tool-host or development-environment detection. | -| `DEV` / `MODE` / `SSR` | Vite-provided build-mode flags exposed through `import.meta.env`. | -| `ALLOW_DRIZZLE_PUSH_ON_NEON` / `AN_*` / `AUTO_CREATE_DEFAULT_ORG` / `DO_NOT_TRACK` / `ENABLE_*` / `PI_*` | Explicitly opt-in local or maintainer controls. Read the owning source before setting them. | -| `CLAUDE_PROJECT_DIR` / `CODE_AGENTS_PROJECT_ROOT` / `LANES` / `FORCE_COLOR` / `LC_ALL` / `LC_CTYPE` / `NO_COLOR` / `PNPM_HOME` | Local coding-agent, shell, package-manager, and test-lane tooling. | +| Pattern | Scope | +| ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | +| `E2E_*` / `PLAYWRIGHT_*` / `*_SMOKE_*` / `SIGN_IN_MATRIX_*` | Browser, end-to-end, and smoke-test configuration. | +| `CHAT_FIRST_SCREENSHOT_DIR` / `CHAT_FIRST_ELECTRON` / `CHAT_FIRST_ELECTRON_EXECUTABLE` / `CHAT_FIRST_COLOR_SCHEME` | Chat-first workbench QA output, optional Electron lane toggle, binary override, and light/dark capture scheme. | +| `HEADLESS_ONRAMP_*` / `STANDALONE_CHAT_DEV_*` | CLI and standalone-chat QA harnesses. | +| `DEV_*` / `DEBUG_*` / `*_DEBUG` / `CHOKIDAR_*` | Local development and diagnostics. | +| `AUTH_SKIP_EMAIL_VERIFICATION` | Local/test auth convenience; never use to weaken production auth. | +| `VITEST_*` / `VITEST` | Test-runner behavior and worker configuration. | +| `UPDATE_I18N_*` | Maintainer-only i18n baseline updates. | +| `CODESPACES` / `GITPOD_*` / `CODEX_HOME` / `XDG_CONFIG_HOME` / `CLAUDE_CONFIG_DIR` / `COREPACK_HOME` | Tool-host or development-environment detection. | +| `DEV` / `MODE` / `SSR` | Vite-provided build-mode flags exposed through `import.meta.env`. | +| `ALLOW_DRIZZLE_PUSH_ON_NEON` / `AN_*` / `AUTO_CREATE_DEFAULT_ORG` / `DO_NOT_TRACK` / `ENABLE_*` / `PI_*` | Explicitly opt-in local or maintainer controls. Read the owning source before setting them. | +| `CLAUDE_PROJECT_DIR` / `CODE_AGENTS_PROJECT_ROOT` / `LANES` / `FORCE_COLOR` / `LC_ALL` / `LC_CTYPE` / `NO_COLOR` / `PNPM_HOME` | Local coding-agent, shell, package-manager, and test-lane tooling. | ## CI-only variables diff --git a/package.json b/package.json index ab866181be..c38892c0d4 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "qa:composer-geometry": "tsx scripts/qa-composer-geometry-smoke.ts", "qa:dispatch-workspace-resources": "tsx scripts/qa-dispatch-workspace-resources-smoke.ts", "qa:dispatch-automations": "tsx scripts/qa-dispatch-automations-smoke.ts", + "qa:chat-first-workbench": "tsx scripts/qa-chat-first-workbench-smoke.ts", "qa:template-routes": "tsx scripts/qa-template-route-matrix.ts", "qa:sign-in": "tsx scripts/qa-sign-in-matrix-smoke.ts", "actions:audit": "node scripts/audit-template-actions.mjs", @@ -53,6 +54,7 @@ "dev:cli": "tsx packages/core/src/cli/index.ts", "guard:no-drizzle-push": "node scripts/guard-no-drizzle-push.mjs", "guard:no-pnpm-patches": "tsx scripts/guard-no-pnpm-patches.ts", + "guard:chat-first-shared-ui": "tsx scripts/guard-chat-first-shared-ui.ts", "guard:no-empty-migrations": "tsx scripts/guard-no-empty-migrations.ts", "guard:no-unscoped-queries": "node scripts/guard-no-unscoped-queries.mjs", "guard:no-env-credentials": "node scripts/guard-no-env-credentials.mjs", @@ -102,6 +104,7 @@ "guard:help-icon-scale": "node scripts/guard-help-icon-scale.mjs", "guard:no-boot-data-work": "node scripts/guard-no-boot-data-work.mjs", "guard:dead-settings-keys": "node scripts/guard-dead-settings-keys.mjs", + "guard:serverless-function-payload": "node scripts/guard-serverless-function-payload.mjs", "guards": "tsx scripts/run-guards.ts", "chat-health": "node scripts/chat-health.mjs", "clear:deploy-previews": "tsx scripts/clear-deploy-previews.ts", diff --git a/packages/code-agents-ui/src/CodeAgentsApp.spec.ts b/packages/code-agents-ui/src/CodeAgentsApp.spec.ts index 95db4403df..4a2c1bffaf 100644 --- a/packages/code-agents-ui/src/CodeAgentsApp.spec.ts +++ b/packages/code-agents-ui/src/CodeAgentsApp.spec.ts @@ -2,8 +2,14 @@ import { describe, expect, it } from "vitest"; import { resolveNewSessionExtensionComposerState, + shouldCloseWatchedChatFirstSession, type CodeAgentsNewSessionExtension, } from "./CodeAgentsApp.js"; +import { + mergeSessionWatchTranscriptEvents, + SESSION_WATCH_TRANSCRIPT_EVENT_LIMIT, +} from "./SessionWatchPanel.js"; +import type { CodeAgentTranscriptEvent } from "./types.js"; const extension: CodeAgentsNewSessionExtension = { active: true, @@ -29,3 +35,96 @@ describe("CodeAgentsApp new-session extension seam", () => { }); }); }); + +describe("session watch transcript reconciliation", () => { + it("keeps a live event that arrives before an older snapshot resolves", () => { + const liveEvent: CodeAgentTranscriptEvent = { + id: "live", + runId: "run-1", + type: "status", + createdAt: "2026-08-09T20:00:02.000Z", + text: "live update", + metadata: { seq: 2 }, + }; + const snapshotEvent: CodeAgentTranscriptEvent = { + id: "snapshot", + runId: "run-1", + type: "status", + createdAt: "2026-08-09T20:00:01.000Z", + text: "initial snapshot", + metadata: { seq: 1 }, + }; + + expect( + mergeSessionWatchTranscriptEvents([liveEvent], [snapshotEvent]).map( + (event) => event.id, + ), + ).toEqual(["snapshot", "live"]); + }); + + it("bounds long-running watched transcripts", () => { + const events = Array.from( + { length: SESSION_WATCH_TRANSCRIPT_EVENT_LIMIT + 1 }, + (_, index) => ({ + id: `event-${index}`, + runId: "run-1", + type: "status" as const, + createdAt: "2026-08-09T20:00:00.000Z", + text: `event ${index}`, + metadata: { seq: index }, + }), + ); + + const merged = mergeSessionWatchTranscriptEvents([], events); + expect(merged).toHaveLength(SESSION_WATCH_TRANSCRIPT_EVENT_LIMIT); + expect(merged[0]?.id).toBe("event-1"); + expect(merged.at(-1)?.id).toBe( + `event-${SESSION_WATCH_TRANSCRIPT_EVENT_LIMIT}`, + ); + }); +}); + +describe("chat-first session watch bounds", () => { + it("only closes when runs have loaded and a code-agent watch target is still missing", () => { + expect( + shouldCloseWatchedChatFirstSession({ + runsLoaded: false, + targetSessionId: "run-1", + targetKind: "code-agent", + watchedRunPresent: false, + }), + ).toBe(false); + expect( + shouldCloseWatchedChatFirstSession({ + runsLoaded: true, + targetSessionId: "run-1", + targetKind: "agent-chat", + watchedRunPresent: false, + }), + ).toBe(false); + expect( + shouldCloseWatchedChatFirstSession({ + runsLoaded: true, + targetSessionId: "run-1", + targetKind: "external", + watchedRunPresent: false, + }), + ).toBe(false); + expect( + shouldCloseWatchedChatFirstSession({ + runsLoaded: true, + targetSessionId: "run-1", + targetKind: "code-agent", + watchedRunPresent: false, + }), + ).toBe(true); + expect( + shouldCloseWatchedChatFirstSession({ + runsLoaded: true, + targetSessionId: "run-1", + targetKind: "code-agent", + watchedRunPresent: true, + }), + ).toBe(false); + }); +}); diff --git a/packages/code-agents-ui/src/CodeAgentsApp.tsx b/packages/code-agents-ui/src/CodeAgentsApp.tsx index ef843c227a..6802043b17 100644 --- a/packages/code-agents-ui/src/CodeAgentsApp.tsx +++ b/packages/code-agents-ui/src/CodeAgentsApp.tsx @@ -3,13 +3,23 @@ import { ChatHistoryList, buildRepositoryFromCodeAgentTranscript, codeAgentTranscriptHasPendingApproval, + closeChatFirstSessionWatch, createCodeAgentChatAdapter, + emitChatFirstSessionWatch, isCodeAgentRunActive, isCredentialGapCodeAgentEvent, mergeCodeAgentTranscriptEvents, + useChatFirstSessionWatch, + type ChatFirstSurfaceKind, type ChatHistoryItem, type CodeAgentChatController, } from "@agent-native/core/client/agent-chat"; +import { + ChatFirstChatHistory, + ChatFirstPrimaryNavigation, + type ChatFirstOpenAppDetail, +} from "@agent-native/core/client/chat-first"; +import { writeClipboardText } from "@agent-native/core/client/clipboard"; import { PromptComposer, readAgentPromptAttachment, @@ -30,6 +40,7 @@ import { IconCopy, IconDeviceMobile, IconDeviceDesktop, + IconEye, IconFolder, IconFolderPlus, IconLink, @@ -54,6 +65,7 @@ import { useRef, useState, type CSSProperties, + type ReactNode, } from "react"; import { toast } from "sonner"; @@ -68,6 +80,7 @@ import { type CodeAgentGoalId, type CodeAgentPermissionMode, } from "./code-agents.js"; +import { SessionWatchPanel } from "./SessionWatchPanel.js"; import type { CodeAgentCodePack, CodeAgentCodePackResult, @@ -234,6 +247,19 @@ export function resolveNewSessionExtensionComposerState( }; } +export function shouldCloseWatchedChatFirstSession(input: { + runsLoaded: boolean; + targetSessionId: string | null; + targetKind: string | null; + watchedRunPresent: boolean; +}): boolean { + if (!input.runsLoaded || !input.targetSessionId) return false; + if (input.targetKind === "agent-chat" || input.targetKind === "external") { + return false; + } + return !input.watchedRunPresent; +} + export interface CodeAgentsAppProps { apps: AppConfig[]; host: CodeAgentsHost; @@ -243,9 +269,97 @@ export interface CodeAgentsAppProps { refreshKey?: number; brandIconUrl?: string; onOpenSettings?: () => void; + /** Compact actions rendered above the primary surface. */ + mainToolbarSlot?: ReactNode; + /** Extra first-party navigation items rendered below New chat. */ + railNavigationSlot?: ReactNode; + /** App shortcuts rendered between navigation and the chat history. */ + railWorkspaceSlot?: ReactNode; + /** Optional actions pinned to the bottom of the rail. */ + railFooterSlot?: ReactNode; renderAppSurface?: CodeAgentsRenderAppSurface; newSessionExtension?: CodeAgentsNewSessionExtension; openDetailRequest?: { detailId: string; nonce: number }; + /** Active chat-first side surface; watch is rendered only when selected. */ + activeChatFirstSurfaceKind?: ChatFirstSurfaceKind; + /** Keep session-watch affordances opt-in with the chat-first shell. */ + chatFirstMode?: boolean; + /** Selected primary chat kind in the opt-in chat-first shell. */ + chatFirstMainKind?: "agent" | "code"; + /** Select the primary chat kind in the opt-in chat-first shell. */ + onChatFirstMainKindChange?: (kind: "agent" | "code") => void; + /** Host-rendered shared Agent-Native chat surface for chat-first mode. */ + renderChatFirstMainSurface?: ReactNode; + /** Navigation callbacks for the shared chat-first rail. */ + chatFirstNavigation?: { + onOpenIntegrations: () => void; + onOpenScheduled: () => void; + }; + /** Route first-party MCP open_app results through the shared app pane. */ + onChatFirstOpenApp?: (detail: ChatFirstOpenAppDetail) => void; + /** Lets a host place the shared watch renderer in its side-surface slot. */ + onWatchedRunChange?: ( + run: CodeAgentRun | null, + sourceRunId?: string | null, + ) => void; + /** Exposes the already-loaded run list to a host-owned side surface. */ + onRunsChange?: (runs: CodeAgentRun[]) => void; +} + +function recordFromUnknown(value: unknown): Record | null { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + if (typeof value !== "string" || !value.trim()) return null; + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + console.warn("[code-agents] Could not parse transcript metadata as JSON."); + return null; + } +} + +function stringFromRecord( + record: Record, + ...keys: string[] +): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return undefined; +} + +function chatFirstOpenAppDetailFromTranscriptEvent( + event: CodeAgentTranscriptEvent, + registeredServerIds: Set, +): ChatFirstOpenAppDetail | null { + if (event.type !== "status" || event.metadata?.type !== "tool_done") { + return null; + } + const tool = stringFromRecord(event.metadata, "tool"); + if (!tool) return null; + if (tool !== "open_app") { + const serverMatch = /^mcp__(.+)__open_app$/.exec(tool); + if (!serverMatch || !registeredServerIds.has(serverMatch[1]!)) return null; + } + + let result = recordFromUnknown(event.metadata.result); + if (result?.__agentNativeMcpToolResult === true) { + result = + recordFromUnknown(result.text) ?? recordFromUnknown(result.raw) ?? null; + } + if (!result) return null; + const detail: ChatFirstOpenAppDetail = { + app: stringFromRecord(result, "app", "appId", "application"), + path: stringFromRecord(result, "path", "targetPath"), + url: stringFromRecord(result, "url", "href"), + view: stringFromRecord(result, "view"), + }; + return detail.app || detail.path || detail.url || detail.view ? detail : null; } type RunListStatus = CodeAgentRunListResult["status"]; @@ -404,15 +518,34 @@ export default function CodeAgentsApp({ refreshKey = 0, brandIconUrl, onOpenSettings, + mainToolbarSlot, + railNavigationSlot, + railWorkspaceSlot, + railFooterSlot, renderAppSurface, newSessionExtension, openDetailRequest, + activeChatFirstSurfaceKind, + chatFirstMode = false, + chatFirstMainKind = "code", + onChatFirstMainKindChange, + renderChatFirstMainSurface, + chatFirstNavigation, + onChatFirstOpenApp, + onWatchedRunChange, + onRunsChange, }: CodeAgentsAppProps) { const [selectedGoalId, setSelectedGoalId] = useState("task"); const selectedGoal = getCodeAgentGoal(selectedGoalId) ?? getDefaultCodeAgentGoal(); const [runs, setRuns] = useState([]); + const [runsLoaded, setRunsLoaded] = useState(false); const [selectedRunId, setSelectedRunId] = useState(null); + const watchedSession = useChatFirstSessionWatch(); + const watchedSessionTarget = watchedSession.target; + const watchedSessionTargetSessionId = watchedSessionTarget?.sessionId ?? null; + const watchedSessionTargetKind = watchedSessionTarget?.kind ?? null; + const watchedSessionTargetGoalId = watchedSessionTarget?.goalId ?? null; const [selectedExtensionDetailId, setSelectedExtensionDetailId] = useState< string | null >(null); @@ -425,12 +558,57 @@ export default function CodeAgentsApp({ () => runs.find((run) => run.id === selectedRunId) ?? null, [runs, selectedRunId], ); + const watchedRun = useMemo( + () => runs.find((run) => run.id === watchedSessionTargetSessionId) ?? null, + [runs, watchedSessionTargetSessionId], + ); useEffect(() => { if (activeNewSessionExtension) return; setSelectedExtensionDetailId(null); }, [activeNewSessionExtension]); + useEffect(() => { + if ( + !shouldCloseWatchedChatFirstSession({ + runsLoaded, + targetSessionId: watchedSessionTargetSessionId, + targetKind: watchedSessionTargetKind, + watchedRunPresent: watchedRun !== null, + }) + ) { + return; + } + closeChatFirstSessionWatch(); + }, [ + runsLoaded, + watchedRun, + watchedSessionTargetKind, + watchedSessionTargetSessionId, + ]); + + useEffect(() => { + const goal = getCodeAgentGoal(watchedSessionTargetGoalId); + if (watchedSessionTargetKind !== "code-agent" || !goal) return; + if (goal.id !== selectedGoalId) setSelectedGoalId(goal.id); + }, [selectedGoalId, watchedSessionTargetGoalId, watchedSessionTargetKind]); + + useEffect(() => { + onWatchedRunChange?.( + watchedRun, + watchedSessionTarget?.sourceSessionId ?? selectedRunId, + ); + }, [ + onWatchedRunChange, + selectedRunId, + watchedRun, + watchedSessionTarget?.sourceSessionId, + ]); + + useEffect(() => { + onRunsChange?.(runs); + }, [onRunsChange, runs]); + useEffect(() => { if (!openDetailRequest || !activeNewSessionExtension?.renderDetail) return; setSelectedRunId(null); @@ -458,6 +636,16 @@ export default function CodeAgentsApp({ >([]); const [transcriptLoading, setTranscriptLoading] = useState(false); const [transcriptError, setTranscriptError] = useState(null); + const seenChatFirstOpenAppEvents = useRef(new Set()); + const registeredChatFirstMcpServerIds = useMemo( + () => + new Set( + apps + .filter((app) => app.enabled !== false) + .map((app) => `desktop_app_${app.id.replace(/[^A-Za-z0-9_]/g, "_")}`), + ), + [apps], + ); const [newRunPermissionMode, setNewRunPermissionMode] = useState(DEFAULT_CODE_AGENT_PERMISSION_MODE); const [selectedPermissionMode, setSelectedPermissionMode] = @@ -576,6 +764,7 @@ export default function CodeAgentsApp({ const loadRuns = useCallback( async (_busy = false) => { + setRunsLoaded(false); try { const result = await withHostCallTimeout( host.listRuns(selectedGoal.id), @@ -584,6 +773,7 @@ export default function CodeAgentsApp({ setStatus(result.status); setError(result.error ?? null); setRuns(result.runs); + if (result.status === "ok") setRunsLoaded(true); if (result.status === "ok" && !viewedRunIdsInitializedRef.current) { const initialIds = result.runs.map((run) => run.id); viewedRunIdsInitializedRef.current = true; @@ -633,6 +823,22 @@ export default function CodeAgentsApp({ } }, [host]); + const routeChatFirstOpenAppEvents = useCallback( + (events: CodeAgentTranscriptEvent[]) => { + if (!onChatFirstOpenApp) return; + for (const event of events) { + if (seenChatFirstOpenAppEvents.current.has(event.id)) continue; + seenChatFirstOpenAppEvents.current.add(event.id); + const detail = chatFirstOpenAppDetailFromTranscriptEvent( + event, + registeredChatFirstMcpServerIds, + ); + if (detail) onChatFirstOpenApp(detail); + } + }, + [onChatFirstOpenApp, registeredChatFirstMcpServerIds], + ); + const loadTranscript = useCallback( async (runId: string | null = selectedRunId, busy = false) => { if (!runId) { @@ -650,6 +856,7 @@ export default function CodeAgentsApp({ }), getHostCallTimeoutMs(1_000), ); + routeChatFirstOpenAppEvents(result.events); setTranscriptEvents(result.events); setTranscriptError(result.error ?? null); } catch (err) { @@ -659,7 +866,7 @@ export default function CodeAgentsApp({ setTranscriptLoading(false); } }, - [host, selectedGoal.id, selectedRunId], + [host, routeChatFirstOpenAppEvents, selectedGoal.id, selectedRunId], ); const loadProjects = useCallback(async () => { @@ -911,15 +1118,16 @@ export default function CodeAgentsApp({ useEffect(() => { if (!openRequest) return; + onChatFirstMainKindChange?.("code"); const nextGoal = getCodeAgentGoal(openRequest.goalId); if (nextGoal) setSelectedGoalId(nextGoal.id); setSelectedExtensionDetailId(null); setSelectedRunId(openRequest.runId ?? null); - setWorkbenchOpen(true); + setWorkbenchOpen(!chatFirstMode); setSearchPanelOpen(false); setMobilePanelOpen(false); void loadRuns(true); - }, [loadRuns, openRequest]); + }, [chatFirstMode, loadRuns, onChatFirstMainKindChange, openRequest]); const hasActiveRuns = useMemo(() => runs.some(isRunActive), [runs]); const selectedRunIsActive = selectedRun ? isRunActive(selectedRun) : false; @@ -931,7 +1139,7 @@ export default function CodeAgentsApp({ () => buildCodeAgentSlashCommands(codePack), [codePack], ); - const canOpenTerminal = Boolean(host.openTerminal); + const canOpenTerminal = !chatFirstMode && Boolean(host.openTerminal); const canChooseProjectFolder = Boolean(host.chooseProject); const providerGate = useMemo( () => getProviderGate(hostMetadata), @@ -1081,6 +1289,7 @@ export default function CodeAgentsApp({ if (batch.error) setTranscriptError(batch.error); if (batch.status === "ok" && batch.events.length > 0) { setTranscriptError(null); + routeChatFirstOpenAppEvents(batch.events); setTranscriptEvents((current) => mergeTranscriptEvents(current, batch.events), ); @@ -1110,6 +1319,7 @@ export default function CodeAgentsApp({ host, isActive, loadTranscript, + routeChatFirstOpenAppEvents, selectedGoal.id, selectedRunId, selectedRunIsActive, @@ -1242,12 +1452,14 @@ export default function CodeAgentsApp({ } function openSearchPanel() { + onChatFirstMainKindChange?.("code"); setSearchPanelOpen(true); setMobilePanelOpen(false); setWorkbenchOpen(false); } function openSearchResult(run: CodeAgentRun) { + onChatFirstMainKindChange?.("code"); const goal = getCodeAgentGoal(run.goalId) ?? getDefaultCodeAgentGoal(); setSelectedGoalId(goal.id); setRuns((current) => @@ -1261,6 +1473,7 @@ export default function CodeAgentsApp({ } function openMobilePanel() { + onChatFirstMainKindChange?.("code"); setSearchPanelOpen(false); setMobilePanelOpen(true); setWorkbenchOpen(false); @@ -1351,6 +1564,7 @@ export default function CodeAgentsApp({ } function openSelectedGoal() { + onChatFirstMainKindChange?.("code"); setSelectedGoalId("task"); setSelectedExtensionDetailId(null); setSelectedRunId(null); @@ -1671,332 +1885,438 @@ export default function CodeAgentsApp({ className="code-agents-rail" aria-label="Agent chats and navigation" > -
-
- {brandIconUrl && ( - - )} -

Agent

-
-
-
- - - {host.getRemoteConnectorStatus && ( - - )} - {hostMetadata?.computerControl && ( - setComputerSetupOpen(true)} + {chatFirstMode ? ( + + chatFirstNavigation?.onOpenIntegrations() + } + onOpenScheduled={() => chatFirstNavigation?.onOpenScheduled()} + onSearch={openSearchPanel} /> - )} -
- -
-

Chats

- {loading ? ( - - ) : runs.length === 0 ? ( -
- -

No chats yet.

-
) : ( - { - markRunsViewed([id]); - setSelectedExtensionDetailId(null); - setSelectedRunId(id); - setSearchPanelOpen(false); - setMobilePanelOpen(false); - }} - onOpen={(id) => { - markRunsViewed([id]); - setSelectedExtensionDetailId(null); - setSelectedRunId(id); - setWorkbenchOpen(true); - setSearchPanelOpen(false); - setMobilePanelOpen(false); - }} - onTogglePin={(id) => { - const run = runs.find((item) => item.id === id); - if (run) toggleRunPinned(run); - }} - onRename={(id, nextTitle) => { - const run = runs.find((item) => item.id === id); - if (run) renameRun(run, nextTitle); - }} - variant="rail" - /> + <> + + {railNavigationSlot} + + {host.getRemoteConnectorStatus && ( + + )} + {hostMetadata?.computerControl && ( + setComputerSetupOpen(true)} + /> + )} + )}
- -
- {workbenchOpen ? ( -
-
-
-

Chat

-

- {getRunTitle(selectedRun) ?? - (selectedRunId - ? `Chat ${selectedRunId}` - : selectedGoal.primaryActionLabel)} -

- setComputerSetupOpen(true)} - /> -
-
- {canOpenTerminal && ( - - )} + {railWorkspaceSlot} + + } + emptyLabel="No chats yet." + onSelect={(id) => { + onChatFirstMainKindChange?.("code"); + markRunsViewed([id]); + setSelectedExtensionDetailId(null); + setSelectedRunId(id); + setSearchPanelOpen(false); + setMobilePanelOpen(false); + }} + onOpen={(id) => { + onChatFirstMainKindChange?.("code"); + markRunsViewed([id]); + setSelectedExtensionDetailId(null); + setSelectedRunId(id); + setWorkbenchOpen(true); + setSearchPanelOpen(false); + setMobilePanelOpen(false); + }} + onTogglePin={(id) => { + const run = runs.find((item) => item.id === id); + if (run) toggleRunPinned(run); + }} + onRename={(id, nextTitle) => { + const run = runs.find((item) => item.id === id); + if (run) renameRun(run, nextTitle); + }} + renderAdditionalRowActions={(item, closeMenu) => ( + <> + + {chatFirstMode ? ( -
-
-
- {selectedGoalApp && renderAppSurface ? ( - renderAppSurface({ - goal: selectedGoal, - app: selectedGoalApp, - urlParams: workbenchUrlParams, - refreshKey, - }) - ) : ( - - )} -
-
+ ) : null} + + )} + className="code-agents-run-list" + /> + {railFooterSlot ? ( +
{railFooterSlot}
+ ) : null} + + +
+ {mainToolbarSlot ? ( +
{mainToolbarSlot}
+ ) : null} + {chatFirstMode && + chatFirstMainKind === "agent" && + renderChatFirstMainSurface ? ( + renderChatFirstMainSurface ) : ( -
- {mobilePanelOpen ? ( - - ) : searchPanelOpen ? ( - + {chatFirstMode && + !onWatchedRunChange && + watchedRun && + (!activeChatFirstSurfaceKind || + activeChatFirstSurfaceKind === "side-chat") ? ( + + ) : null} + {workbenchOpen ? ( +
+
+
+

Chat

+

+ {getRunTitle(selectedRun) ?? + (selectedRunId + ? `Chat ${selectedRunId}` + : selectedGoal.primaryActionLabel)} +

+ setComputerSetupOpen(true)} + /> +
+
+ {canOpenTerminal && ( + + )} + +
+
+
+ {selectedGoalApp && renderAppSurface ? ( + renderAppSurface({ + goal: selectedGoal, + app: selectedGoalApp, + urlParams: workbenchUrlParams, + refreshKey, + }) + ) : ( + + )} +
+
) : ( - <> - {loading ? ( - +
+ {mobilePanelOpen ? ( + + ) : searchPanelOpen ? ( + ) : ( <> - {status !== "ok" && ( -
- - - {status === "unauthorized" - ? `Open ${selectedGoal.surfaceLabel} and sign in to see chats.` - : (error ?? - `${selectedGoal.surfaceLabel} is not reporting chats yet.`)} - -
- )} - - {activeNewSessionExtension && - selectedExtensionDetailId && - activeNewSessionExtension.renderDetail ? ( - activeNewSessionExtension.renderDetail({ - detailId: selectedExtensionDetailId, - onClose: openSelectedGoal, - }) - ) : selectedRun ? ( - controlRun("stop")} - onApprove={() => controlRun("approve")} - onApproveAlways={() => controlRun("approve-always")} - onDeny={() => controlRun("deny")} - providerBlocked={providerGate.blocked} - builderConnecting={builderConnecting} - builderConnectMessage={builderConnectMessage} - onConnectBuilder={connectBuilderProvider} - onOpenSettings={onOpenSettings} - onConnectProvider={connectBuilderProvider} - onConnectLocalRuntime={ - codexCliAvailable ? connectLocalRuntime : undefined - } - /> + {loading ? ( + ) : ( -
-

What outcome do you want?

- {!activeNewSessionExtension && providerGate.blocked && ( - + {status !== "ok" && ( +
+ + + {status === "unauthorized" + ? `Open ${selectedGoal.surfaceLabel} and sign in to see chats.` + : (error ?? + `${selectedGoal.surfaceLabel} is not reporting chats yet.`)} + +
+ )} + + {activeNewSessionExtension && + selectedExtensionDetailId && + activeNewSessionExtension.renderDetail ? ( + activeNewSessionExtension.renderDetail({ + detailId: selectedExtensionDetailId, + onClose: openSelectedGoal, + }) + ) : selectedRun ? ( + controlRun("stop")} + onApprove={() => controlRun("approve")} + onApproveAlways={() => controlRun("approve-always")} + onDeny={() => controlRun("deny")} + providerBlocked={providerGate.blocked} + builderConnecting={builderConnecting} + builderConnectMessage={builderConnectMessage} onConnectBuilder={connectBuilderProvider} onOpenSettings={onOpenSettings} + onConnectProvider={connectBuilderProvider} onConnectLocalRuntime={ - codexCliAvailable - ? () => void connectLocalRuntime("codex-cli") + !chatFirstMode && codexCliAvailable + ? connectLocalRuntime : undefined } /> + ) : ( +
+

What outcome do you want?

+ {!activeNewSessionExtension && + providerGate.blocked && ( + + void connectLocalRuntime("codex-cli") + : undefined + } + /> + )} + + {(projects.length > 0 || + canChooseProjectFolder) && ( + + )} +
)} - - {(projects.length > 0 || canChooseProjectFolder) && ( - - )} -
+ )} )} - +
)} -
+ )}
) : ( + // Local coding runs keep their own controller and transcript adapter, + // but they intentionally enter the shared AssistantChat renderer so + // message parts, tool activity, and integration suggestions stay in + // parity with server-backed agent chats. void; +}) { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sending, setSending] = useState(false); + const [prompt, setPrompt] = useState(""); + const transcriptGenerationRef = useRef(0); + + const loadTranscript = useCallback( + async (generation?: number) => { + const requestGeneration = generation ?? transcriptGenerationRef.current; + try { + const result = await host.readTranscript({ + goalId: run.goalId, + runId: run.id, + }); + if (requestGeneration !== transcriptGenerationRef.current) return; + if (result.status !== "ok") { + setError( + result.error ?? "This session is not reporting a transcript.", + ); + return; + } + setError(null); + setEvents((current) => + mergeSessionWatchTranscriptEvents(current, result.events), + ); + } catch (caught) { + if (requestGeneration !== transcriptGenerationRef.current) return; + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + if (requestGeneration === transcriptGenerationRef.current) { + setLoading(false); + } + } + }, + [host, run.goalId, run.id], + ); + + useEffect(() => { + const generation = transcriptGenerationRef.current + 1; + transcriptGenerationRef.current = generation; + setEvents([]); + setError(null); + setLoading(true); + + // Subscribe before reading the snapshot. The merge below keeps events that + // arrive in the gap between those two operations. + const unsubscribe = host.subscribeTranscript?.( + { goalId: run.goalId, runId: run.id }, + (batch) => { + if (generation !== transcriptGenerationRef.current) return; + if (batch.status !== "ok") { + setError(batch.error ?? "The watched session could not be updated."); + return; + } + setError(null); + if (batch.events.length > 0) { + setEvents((current) => + mergeSessionWatchTranscriptEvents(current, batch.events), + ); + } + }, + ); + void loadTranscript(generation); + const interval = window.setInterval( + () => void loadTranscript(generation), + 10_000, + ); + return () => { + unsubscribe?.(); + window.clearInterval(interval); + }; + }, [host, loadTranscript, run.goalId, run.id]); + + async function sendFollowUp( + text: string, + _files: File[], + _references: unknown[], + options: { intent?: "immediate" | "queued" }, + ) { + const trimmed = text.trim(); + if (!trimmed || sending) return; + setSending(true); + try { + const result = await host.appendFollowUp({ + goalId: run.goalId, + runId: run.id, + prompt: trimmed, + followUpMode: options.intent === "queued" ? "queued" : "immediate", + metadata: { + sessionBridge: "watch-panel", + ...(sourceRunId ? { sourceRunId } : {}), + }, + }); + if (!result.ok) { + toast("Could not message this session", { + description: result.error ?? result.message, + duration: 3200, + }); + return; + } + setPrompt(""); + toast("Message sent to session", { duration: 1600 }); + await loadTranscript(); + } catch (caught) { + toast("Could not message this session", { + description: caught instanceof Error ? caught.message : String(caught), + duration: 3200, + }); + } finally { + setSending(false); + } + } + + return ( +
+
+
+
+ +
+ +
+
+ {loading ? ( +
+ + Reading session… +
+ ) : error ? ( +
+ {error} +
+ ) : events.length === 0 ? ( +
+ + No transcript events yet. +
+ ) : ( + events.slice(-8).map((event) => ( +
+ {event.type} +

{event.text}

+
+ )) + )} +
+ +
+
+ ); +} diff --git a/packages/code-agents-ui/src/code-agents.ts b/packages/code-agents-ui/src/code-agents.ts index 511030d8bb..4754fbd829 100644 --- a/packages/code-agents-ui/src/code-agents.ts +++ b/packages/code-agents-ui/src/code-agents.ts @@ -39,14 +39,14 @@ export interface CodeAgentGoalDefinition { export const CODE_AGENT_GOALS: CodeAgentGoalDefinition[] = [ { id: "task", - label: "New task", + label: "New chat", slashCommand: "/task", description: - "Start a general task from a prompt, then keep progress, outcomes, and follow-ups attached to the same run.", + "Start a coding chat, then keep progress, outcomes, and follow-ups attached to the same conversation.", cliCommand: "task", runNoun: "agent task", surfaceLabel: "Task workspace", - primaryActionLabel: "New Task", + primaryActionLabel: "New chat", surfaceKind: "native", }, { diff --git a/packages/code-agents-ui/src/index.ts b/packages/code-agents-ui/src/index.ts index 46bed38ec5..52ef8437c8 100644 --- a/packages/code-agents-ui/src/index.ts +++ b/packages/code-agents-ui/src/index.ts @@ -11,6 +11,7 @@ export type { CodeAgentsRenderAppSurface, } from "./CodeAgentsApp.js"; export { resolveNewSessionExtensionComposerState } from "./CodeAgentsApp.js"; +export { SessionWatchPanel } from "./SessionWatchPanel.js"; export * from "./composer-primitives.js"; export * from "./code-agents.js"; export * from "./types.js"; diff --git a/packages/code-agents-ui/src/styles.css b/packages/code-agents-ui/src/styles.css index d37e5d46a3..4a5ee3e157 100644 --- a/packages/code-agents-ui/src/styles.css +++ b/packages/code-agents-ui/src/styles.css @@ -10,37 +10,36 @@ root has no explicit `.light`/`.dark` class, mirroring the pattern used in agent-conversation.css. */ .code-agents-surface { - --background: 0 0% 100%; + --background: 0 0% 98%; --foreground: 0 0% 10%; - --card: 0 0% 100%; + --card: 0 0% 99%; --card-foreground: 0 0% 10%; - --popover: 0 0% 100%; + --popover: 0 0% 99%; --popover-foreground: 0 0% 10%; --primary: 0 0% 15%; --primary-foreground: 0 0% 100%; --secondary: 0 0% 95%; --secondary-foreground: 0 0% 15%; - --muted: 0 0% 95%; + --muted: 0 0% 94%; --muted-foreground: 0 0% 45%; - --accent: 0 0% 95%; + --accent: 0 0% 94%; --accent-foreground: 0 0% 15%; --destructive: 0 84% 60%; --destructive-foreground: 0 0% 98%; - --border: 0 0% 90%; - --input: 0 0% 90%; + --border: 0 0% 93%; + --input: 0 0% 92%; --ring: 0 0% 40%; --radius: 0.5rem; - --sidebar-background: 0 0% 97%; + --sidebar-background: 0 0% 96%; --sidebar-foreground: 0 0% 45%; - --sidebar-accent: 0 0% 95%; + --sidebar-accent: 0 0% 93%; --sidebar-accent-foreground: 0 0% 15%; - --sidebar-border: 0 0% 90%; + --sidebar-border: 0 0% 93%; --ease: 120ms cubic-bezier(0.16, 1, 0.3, 1); --code-agents-chat-max: 700px; --code-agents-rail-gutter: 8px; --code-agents-rail-row-inset: 8px; --code-agents-rail-icon-size: 15px; - --code-agents-title-icon-size: 18px; --color-background: hsl(var(--background)); --color-foreground: hsl(var(--foreground)); --color-muted-foreground: hsl(var(--muted-foreground)); @@ -65,25 +64,25 @@ } .dark .code-agents-surface { - --background: 0 0% 13%; + --background: 0 0% 15%; --foreground: 0 0% 90%; - --card: 0 0% 15%; + --card: 0 0% 17%; --card-foreground: 0 0% 90%; - --popover: 0 0% 15%; + --popover: 0 0% 17%; --popover-foreground: 0 0% 90%; --primary: 0 0% 75%; --primary-foreground: 0 0% 10%; --secondary: 0 0% 18%; --secondary-foreground: 0 0% 90%; - --muted: 0 0% 16%; + --muted: 0 0% 18%; --muted-foreground: 0 0% 60%; - --accent: 0 0% 18%; + --accent: 0 0% 20%; --accent-foreground: 0 0% 90%; --destructive: 0 91% 71%; - --border: 0 0% 24%; - --input: 0 0% 24%; + --border: 0 0% 26%; + --input: 0 0% 26%; --ring: 0 0% 60%; - --sidebar-background: 0 0% 10%; + --sidebar-background: 0 0% 12%; --sidebar-foreground: 0 0% 60%; --sidebar-accent: 0 0% 16%; --sidebar-accent-foreground: 0 0% 90%; @@ -92,25 +91,25 @@ @media (prefers-color-scheme: dark) { :root:not(.light) .code-agents-surface { - --background: 0 0% 13%; + --background: 0 0% 15%; --foreground: 0 0% 90%; - --card: 0 0% 15%; + --card: 0 0% 17%; --card-foreground: 0 0% 90%; - --popover: 0 0% 15%; + --popover: 0 0% 17%; --popover-foreground: 0 0% 90%; --primary: 0 0% 75%; --primary-foreground: 0 0% 10%; --secondary: 0 0% 18%; --secondary-foreground: 0 0% 90%; - --muted: 0 0% 16%; + --muted: 0 0% 18%; --muted-foreground: 0 0% 60%; - --accent: 0 0% 18%; + --accent: 0 0% 20%; --accent-foreground: 0 0% 90%; --destructive: 0 91% 71%; - --border: 0 0% 24%; - --input: 0 0% 24%; + --border: 0 0% 26%; + --input: 0 0% 26%; --ring: 0 0% 60%; - --sidebar-background: 0 0% 10%; + --sidebar-background: 0 0% 12%; --sidebar-foreground: 0 0% 60%; --sidebar-accent: 0 0% 16%; --sidebar-accent-foreground: 0 0% 90%; @@ -129,42 +128,6 @@ border-right: 1px solid hsl(var(--sidebar-border)); } -.code-agents-rail__header { - display: flex; - align-items: center; - justify-content: flex-start; - min-height: 48px; - padding: 0 12px; - border-bottom: 1px solid hsl(var(--sidebar-border)); -} - -.code-agents-title-block { - min-width: 0; - display: flex; - align-items: center; - gap: 8px; -} - -.code-agents-title-icon { - width: var(--code-agents-title-icon-size); - height: var(--code-agents-title-icon-size); - flex: 0 0 auto; - border-radius: calc(var(--radius) - 4px); -} - -.code-agents-title-block h1 { - margin: 0; - color: hsl(var(--foreground)); - font-size: 13px; - font-weight: 600; -} - -.code-agents-title-block p { - margin-top: 1px; - color: hsl(var(--muted-foreground) / 0.72); - font-size: 11px; -} - .code-agents-nav-list { display: grid; gap: 1px; @@ -368,6 +331,17 @@ color: hsl(var(--foreground)); } +/* The chat-first rail uses one neutral treatment for New chat, Search, and + the shared Integrations/Scheduled actions. The legacy Agent rail keeps its + active-state styling above. */ +.desktop-chat-first-hub .code-agents-nav-link { + color: hsl(var(--sidebar-foreground) / 0.8); +} + +.desktop-chat-first-hub .code-agents-nav-link svg { + color: inherit; +} + .code-agents-run-list { min-height: 0; overflow-y: auto; @@ -447,6 +421,173 @@ background: hsl(var(--background)); } +.code-agents-main { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.code-agents-main-toolbar { + display: flex; + min-height: 44px; + flex: 0 0 44px; + align-items: center; + justify-content: flex-end; + padding: 0 12px; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--background)); +} + +.code-agents-session-watch { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 8px; + max-height: min(340px, 38vh); + padding: 12px 16px 10px; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--card) / 0.72); +} + +.code-agents-session-watch__header, +.code-agents-session-watch__title { + display: flex; + align-items: center; + min-width: 0; +} + +.code-agents-session-watch__header { + justify-content: space-between; + gap: 12px; +} + +.code-agents-session-watch__title { + gap: 8px; + color: hsl(var(--foreground)); +} + +.code-agents-session-watch__title > svg { + flex: 0 0 auto; + color: hsl(var(--primary)); +} + +.code-agents-session-watch__title div { + display: grid; + min-width: 0; + gap: 2px; +} + +.code-agents-session-watch__title strong, +.code-agents-session-watch__title span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.code-agents-session-watch__title strong { + font-size: 12px; + font-weight: 600; +} + +.code-agents-session-watch__title span { + color: hsl(var(--muted-foreground)); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 10px; +} + +.code-agents-session-watch__close { + display: inline-grid; + flex: 0 0 auto; + place-items: center; + width: 26px; + height: 26px; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + color: hsl(var(--muted-foreground)); + cursor: pointer; +} + +.code-agents-session-watch__close:hover { + background: hsl(var(--accent)); + color: hsl(var(--foreground)); +} + +.code-agents-session-watch__body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 360px); + gap: 12px; + min-height: 0; +} + +.code-agents-session-watch__events { + min-width: 0; + max-height: 226px; + overflow-y: auto; + padding: 2px; +} + +.code-agents-session-watch__event { + display: grid; + gap: 2px; + padding: 5px 7px; + border-bottom: 1px solid hsl(var(--border) / 0.7); +} + +.code-agents-session-watch__event span { + color: hsl(var(--muted-foreground)); + font-size: 10px; + text-transform: uppercase; +} + +.code-agents-session-watch__event p { + margin: 0; + overflow: hidden; + color: hsl(var(--foreground) / 0.82); + font-size: 11px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.code-agents-session-watch__state { + display: flex; + align-items: center; + gap: 7px; + min-height: 60px; + padding: 10px; + color: hsl(var(--muted-foreground)); + font-size: 11px; +} + +.code-agents-session-watch__state--error { + color: hsl(var(--destructive)); +} + +.code-agents-session-watch__composer { + align-self: end; + width: 100% !important; +} + +.code-agents-main > .code-agents-overview, +.code-agents-main > .code-agents-workbench { + flex: 1 1 auto; + height: auto; +} + +@media (max-width: 760px) { + .code-agents-session-watch__body { + grid-template-columns: minmax(0, 1fr); + } + + .code-agents-session-watch { + max-height: min(480px, 58vh); + } + + .code-agents-session-watch__events { + max-height: 150px; + } +} + .code-agents-overview { height: 100%; overflow-y: auto; diff --git a/packages/code-agents-ui/src/types.ts b/packages/code-agents-ui/src/types.ts index f4535c6f28..453cb607f8 100644 --- a/packages/code-agents-ui/src/types.ts +++ b/packages/code-agents-ui/src/types.ts @@ -232,6 +232,8 @@ export interface CodeAgentFollowUpRequest { model?: string; effort?: CodeAgentReasoningEffort; attachments?: CodeAgentPromptAttachment[]; + /** Bounded provenance for host-side coordination such as session watch. */ + metadata?: Record; } export interface CodeAgentFollowUpResult { @@ -349,6 +351,7 @@ export interface CodeAgentRetryRunRequest { engine?: string; model?: string; effort?: CodeAgentReasoningEffort; + metadata?: Record; } export interface CodeAgentRetryRunResult { diff --git a/packages/core/export-snapshot.json b/packages/core/export-snapshot.json index 81592118f5..72665c6f28 100644 --- a/packages/core/export-snapshot.json +++ b/packages/core/export-snapshot.json @@ -41,6 +41,7 @@ "./cli/skills": ["dist/cli/skills.d.ts", "dist/cli/skills.js"], "./client": ["dist/client/index.js"], "./client/agent-chat": ["dist/client/agent-chat/index.js"], + "./client/chat-first": ["dist/client/chat-first/index.js"], "./client/AgentPanel": ["dist/client/AgentPanel.js"], "./client/analytics": ["dist/client/analytics/index.js"], "./client/api-path": ["dist/client/api-path.js"], diff --git a/packages/core/package.json b/packages/core/package.json index fc6f4d06f1..a51e12e6b1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -111,6 +111,7 @@ "./db/drizzle-config": "./dist/db/drizzle-config.js", "./client": "./dist/client/index.js", "./client/agent-chat": "./dist/client/agent-chat/index.js", + "./client/chat-first": "./dist/client/chat-first/index.js", "./client/analytics": "./dist/client/analytics/index.js", "./client/automation": "./dist/client/automation/index.js", "./client/chat": "./dist/client/chat/index.js", diff --git a/packages/core/src/agent/engine/builder-engine.ts b/packages/core/src/agent/engine/builder-engine.ts index 51bc6470ca..54d648a8e2 100644 --- a/packages/core/src/agent/engine/builder-engine.ts +++ b/packages/core/src/agent/engine/builder-engine.ts @@ -143,6 +143,17 @@ interface GatewayErrorBody { }; } +/** + * Credentials captured by a durable caller that cannot rely on ambient + * request context staying attached to a later run-manager callback. + */ +export interface BuilderEngineCredentials { + privateKey: string | null; + publicKey: string | null; + userId?: string | null; + orgName?: string | null; +} + class BuilderEngine implements AgentEngine { readonly name = "builder"; readonly label = "Builder.io Gateway"; @@ -150,8 +161,13 @@ class BuilderEngine implements AgentEngine { readonly supportedModels = BUILDER_SUPPORTED_MODELS; readonly capabilities = BUILDER_CAPABILITIES; + constructor( + private readonly configuredCredentials?: BuilderEngineCredentials, + ) {} + async *stream(opts: EngineStreamOptions): AsyncIterable { - const creds = await resolveBuilderCredentials(); + const creds = + this.configuredCredentials ?? (await resolveBuilderCredentials()); const authHeader = creds.privateKey ? `Bearer ${creds.privateKey}` : null; const spaceId = creds.publicKey; const builderUserId = creds.userId; @@ -343,7 +359,8 @@ class BuilderEngine implements AgentEngine { // self-healing path for workspace/env-managed credentials, which never // flow through writeBuilderCredentials. try { - const creds = await resolveBuilderCredentials(); + const creds = + this.configuredCredentials ?? (await resolveBuilderCredentials()); await clearBuilderCredentialAuthFailure({ privateKey: creds.privateKey, publicKey: creds.publicKey, @@ -911,9 +928,13 @@ function htmlToText(html: string): string { } export function createBuilderEngine( - _config: Record = {}, + config: Record = {}, ): AgentEngine { - return new BuilderEngine(); + const configuredCredentials = + config.credentials && typeof config.credentials === "object" + ? (config.credentials as BuilderEngineCredentials) + : undefined; + return new BuilderEngine(configuredCredentials); } function resolveMaxBuilderGatewayTimeoutMs(): number { diff --git a/packages/core/src/chat-threads/store.ts b/packages/core/src/chat-threads/store.ts index ded7db282d..7322f5e165 100644 --- a/packages/core/src/chat-threads/store.ts +++ b/packages/core/src/chat-threads/store.ts @@ -485,6 +485,8 @@ export async function createThread( title?: string; scope?: ChatThreadScope | null; source?: ChatThreadSource | null; + /** Explicit owner organization for durable/background callers. */ + orgId?: string | null; }, ): Promise { await ensureTable(); @@ -494,7 +496,7 @@ export async function createThread( const title = opts?.title ?? ""; const scope = opts?.scope ?? null; const source = opts?.source ?? null; - const orgId = getRequestOrgId() ?? null; + const orgId = opts?.orgId ?? getRequestOrgId() ?? null; await client.execute({ sql: `INSERT INTO chat_threads (id, owner_email, title, preview, thread_data, message_count, created_at, updated_at, scope_type, scope_id, scope_label, source_platform, source_app_id, source_url, org_id, visibility) VALUES (?, ?, ?, '', '{}', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'private')`, diff --git a/packages/core/src/cli/agent-plugin.spec.ts b/packages/core/src/cli/agent-plugin.spec.ts index cba8255533..4faec7d0cf 100644 --- a/packages/core/src/cli/agent-plugin.spec.ts +++ b/packages/core/src/cli/agent-plugin.spec.ts @@ -4,11 +4,13 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { readAgentsBundleFromFs } from "../server/agents-bundle.js"; import { importAgentPlugin, loadAgentPlugin, parseAgentPluginArgs, } from "./agent-plugin.js"; +import { mergeCodeAgentMcpConfig } from "./code-agent-mcp-config.js"; const tmpRoots: string[] = []; @@ -183,6 +185,7 @@ describe("Agent Plugin import", () => { const result = importAgentPlugin(pluginRoot, { targetDir: workspace }); const skillPath = path.join( workspace, + ".agents", "skills", "calendar-tools", "meeting-helper", @@ -209,6 +212,42 @@ describe("Agent Plugin import", () => { expect(metadata.pluginHash).toBe(result.plugin.hash); expect(metadata.mcpServers[0].headersIgnored).toBe(1); expect(JSON.stringify(metadata)).not.toContain("package-placeholder"); + + const agentBundle = readAgentsBundleFromFs(workspace); + expect(agentBundle.skills["meeting-helper"]?.content).toContain( + "Read the relevant calendar context", + ); + expect( + mergeCodeAgentMcpConfig(null, { + MCP_SERVERS: JSON.stringify(config), + } as NodeJS.ProcessEnv), + ).toMatchObject({ + servers: { + [server.id]: { + url: "https://calendar.example.com/mcp", + }, + }, + }); + }); + + it("can place namespaced skills in the agent-visible skills root", () => { + const root = tmpDir(); + const pluginRoot = writePlugin(path.join(root, "plugin")); + const workspace = path.join(root, "workspace"); + const skillsRoot = path.join(workspace, ".agents", "skills"); + + const result = importAgentPlugin(pluginRoot, { + targetDir: workspace, + skillsTargetDir: skillsRoot, + }); + + expect(result.targetDir).toBe(fs.realpathSync(workspace)); + expect( + fs.existsSync( + path.join(skillsRoot, "calendar-tools", "meeting-helper", "SKILL.md"), + ), + ).toBe(true); + expect(fs.existsSync(path.join(workspace, "mcp.config.json"))).toBe(true); }); it("is idempotent and refuses a conflicting Skill without --force", () => { @@ -228,6 +267,7 @@ describe("Agent Plugin import", () => { const skillPath = path.join( workspace, + ".agents", "skills", "calendar-tools", "meeting-helper", diff --git a/packages/core/src/cli/agent-plugin.ts b/packages/core/src/cli/agent-plugin.ts index 23f703aa57..56651b7821 100644 --- a/packages/core/src/cli/agent-plugin.ts +++ b/packages/core/src/cli/agent-plugin.ts @@ -2,8 +2,9 @@ * Import and validate the portable Agent Plugin format. * * The importer intentionally handles only local plugin directories. Skills are - * copied as inert files, and remote Streamable HTTP MCP entries are translated - * to the Agent-Native `mcp.config.json` shape without copying package headers. + * copied as inert files into the workspace's agent-visible `.agents/skills` + * tree, and remote Streamable HTTP MCP entries are translated to the + * Agent-Native `mcp.config.json` shape without copying package headers. * A plugin package is not a credential store and this command never executes * a plugin's scripts or stdio servers. */ @@ -107,6 +108,8 @@ export interface LoadedAgentPlugin { export interface AgentPluginImportOptions { targetDir?: string; + /** Optional skills root used by hosts whose agent cwd is a child workspace. */ + skillsTargetDir?: string; force?: boolean; dryRun?: boolean; } @@ -169,7 +172,7 @@ Usage: Commands: import Import a standard Agent Plugin's Skills and remote MCP servers into an Agent-Native workspace. -The importer copies Skills into a namespaced skills// path and +The importer copies Skills into a namespaced .agents/skills// path and maps Streamable HTTP MCP servers into mcp.config.json. Package headers are never imported as credentials; configure authentication through the app's normal Connections flow. Stdio and SSE servers are reported and skipped.`; @@ -968,18 +971,12 @@ function planMcpServers( function planSkills( plugin: LoadedAgentPlugin, - targetDir: string, - targetPluginSlug: string, + skillsRoot: string, force: boolean, ): PlannedSkill[] { return plugin.skills.map((skill) => { - const destination = path.join( - targetDir, - "skills", - targetPluginSlug, - skill.name, - ); - assertPathInside(targetDir, destination, "Imported skill destination"); + const destination = path.join(skillsRoot, skill.name); + assertPathInside(skillsRoot, destination, "Imported skill destination"); assertPathOutside( plugin.rootDir, destination, @@ -1231,7 +1228,20 @@ export function importAgentPlugin( assertNoSymlinkInExistingPath(targetDir, "Agent-Native import target"); const slug = pluginSlug(plugin.manifest.name); - const skillsRoot = path.join(targetDir, "skills", slug); + const skillsTargetDir = canonicalPathForComparison( + options.skillsTargetDir ?? path.join(targetDir, ".agents", "skills"), + ); + if ( + pathExists(skillsTargetDir) && + !fs.statSync(skillsTargetDir).isDirectory() + ) { + throw new Error( + `Agent-Native skills target must be a directory: ${skillsTargetDir}`, + ); + } + assertPathOutside(plugin.rootDir, skillsTargetDir, "Imported skills target"); + assertNoSymlinkInExistingPath(skillsTargetDir, "Imported skills target"); + const skillsRoot = path.join(skillsTargetDir, slug); const metadataFile = path.join( targetDir, ".agent-native", @@ -1239,9 +1249,9 @@ export function importAgentPlugin( `${slug}.json`, ); const mcpFile = path.join(targetDir, "mcp.config.json"); - assertPathInside(targetDir, skillsRoot, "Imported skills root"); assertPathInside(targetDir, metadataFile, "Imported metadata path"); assertPathInside(targetDir, mcpFile, "Imported MCP config path"); + assertPathInside(skillsTargetDir, skillsRoot, "Imported skills root"); assertPathOutside(plugin.rootDir, skillsRoot, "Imported skills root"); assertPathOutside(plugin.rootDir, metadataFile, "Imported metadata path"); assertPathOutside(plugin.rootDir, mcpFile, "Imported MCP config path"); @@ -1254,7 +1264,7 @@ export function importAgentPlugin( const force = options.force ?? false; const dryRun = options.dryRun ?? false; const existingMcp = readExistingMcpConfig(mcpFile); - const skillPlans = planSkills(plugin, targetDir, slug, force); + const skillPlans = planSkills(plugin, skillsRoot, force); const mcpPlans = planMcpServers(plugin, existingMcp, force); const existingMetadata = readExistingMetadata(metadataFile); const metadataIsSame = metadataMatches(existingMetadata, plugin); diff --git a/packages/core/src/cli/code-agent-executor.spec.ts b/packages/core/src/cli/code-agent-executor.spec.ts index fb65439a00..127cb02f91 100644 --- a/packages/core/src/cli/code-agent-executor.spec.ts +++ b/packages/core/src/cli/code-agent-executor.spec.ts @@ -143,6 +143,13 @@ describe("executeCodeAgentRun", () => { it("runs a Codex CLI-backed session without provider API keys", async () => { const root = useTempCodeAgentsHome(); for (const key of providerEnvKeys) delete process.env[key]; + const originalMcpServers = process.env.MCP_SERVERS; + process.env.MCP_SERVERS = JSON.stringify({ + workspaceHttp: { + type: "http", + url: "https://workspace.example/mcp", + }, + }); const binDir = path.join(root, "bin"); const promptPath = path.join(root, "codex-prompt.txt"); const argsPath = path.join(root, "codex-args.json"); @@ -189,29 +196,43 @@ describe("executeCodeAgentRun", () => { stdout: output.stream, }); - expect(getCodeAgentRunRecord(run.id)).toMatchObject({ - status: "completed", - phase: "complete", - metadata: { - engine: "codex-cli", - model: "codex-default", - }, - }); - expect(output.read()).toContain("Codex streamed output"); - expect(fs.readFileSync(promptPath, "utf-8")).toContain("fix auth tests"); - const args = JSON.parse(fs.readFileSync(argsPath, "utf-8")) as string[]; - const execIndex = args.indexOf("exec"); - expect(args.slice(0, 3)).toEqual(["--ask-for-approval", "never", "exec"]); - expect(args.slice(execIndex + 1)).not.toContain("--ask-for-approval"); - expect(listCodeAgentTranscriptEvents(run.id)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "system", - message: "Codex final answer", - metadata: expect.objectContaining({ engine: "codex-cli" }), - }), - ]), - ); + try { + expect(getCodeAgentRunRecord(run.id)).toMatchObject({ + status: "completed", + phase: "complete", + metadata: { + engine: "codex-cli", + model: "codex-default", + }, + }); + expect(output.read()).toContain("Codex streamed output"); + expect(fs.readFileSync(promptPath, "utf-8")).toContain("fix auth tests"); + const args = JSON.parse(fs.readFileSync(argsPath, "utf-8")) as string[]; + const execIndex = args.indexOf("exec"); + expect(execIndex).toBeGreaterThan(-1); + expect(args.indexOf("--ask-for-approval")).toBeGreaterThan(-1); + expect(args.indexOf("--ask-for-approval")).toBeLessThan(execIndex); + expect(args.indexOf("--sandbox")).toBeGreaterThan(-1); + expect(args.indexOf("--sandbox")).toBeLessThan(execIndex); + expect(args.indexOf("--cd")).toBeGreaterThan(-1); + expect(args.indexOf("--cd")).toBeLessThan(execIndex); + expect(args.indexOf("-c")).toBeGreaterThan(-1); + expect(args.indexOf("-c")).toBeLessThan(execIndex); + expect(args.indexOf("--ignore-user-config")).toBeGreaterThan(execIndex); + expect(args.slice(execIndex + 1)).not.toContain("--ask-for-approval"); + expect(listCodeAgentTranscriptEvents(run.id)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "system", + message: "Codex final answer", + metadata: expect.objectContaining({ engine: "codex-cli" }), + }), + ]), + ); + } finally { + if (originalMcpServers === undefined) delete process.env.MCP_SERVERS; + else process.env.MCP_SERVERS = originalMcpServers; + } }); it("routes AGENT_ENGINE=codex-cli to the Codex CLI runner without engine metadata", async () => { diff --git a/packages/core/src/cli/code-agent-executor.ts b/packages/core/src/cli/code-agent-executor.ts index 6b2bde7085..dc4146ce54 100644 --- a/packages/core/src/cli/code-agent-executor.ts +++ b/packages/core/src/cli/code-agent-executor.ts @@ -63,6 +63,11 @@ import { createAgentNativeConfigContext, loadResolvedAgentNativeConfig, } from "../vite/agent-native-config-loader.js"; +import { + codexMcpConfigArgs, + mergeCodeAgentMcpConfig, + restrictCodeAgentMcpConfig, +} from "./code-agent-mcp-config.js"; import { createCodeAgentOutputSmoother } from "./code-agent-output-smoother.js"; import { addCodeAgentCommandToAllowlist, @@ -609,20 +614,32 @@ async function executeCodexCliRun(options: { const streamToolOutputToStdout = options.streamToolOutputToStdout ?? process.env.AGENT_NATIVE_CODE_AGENT_STRUCTURED_STDOUT !== "1"; + const additionalSkillsRoot = + process.env.AGENT_NATIVE_CODE_AGENT_SKILLS_ROOT?.trim(); const args = [ + ...codexMcpConfigArgs(), "--ask-for-approval", "never", - "exec", + "--sandbox", + codexSandboxForPermissionMode(options.permissionMode), "--cd", cwd, + "exec", "--color", "never", "--skip-git-repo-check", - "--sandbox", - codexSandboxForPermissionMode(options.permissionMode), + "--ignore-user-config", "--output-last-message", outputPath, ]; + if (additionalSkillsRoot && fs.existsSync(additionalSkillsRoot)) { + args.splice( + args.indexOf("--output-last-message"), + 0, + "--add-dir", + additionalSkillsRoot, + ); + } if (model) args.push("--model", model); args.push("-"); @@ -851,9 +868,16 @@ function buildCodexCliPrompt(run: CodeAgentRunRecord, prompt: string): string { mode === "Plan" ? "Inspect and explain only. Do not edit files or run mutating commands." : "Edit and verify as needed. Do not create, switch, reset, rebase, or stash git branches."; + const additionalSkillsRoot = + process.env.AGENT_NATIVE_CODE_AGENT_SKILLS_ROOT?.trim(); return [ `You are running from Agent-Native Code in ${run.cwd || process.cwd()}.`, "Follow the repository AGENTS.md and any relevant skill instructions.", + ...(additionalSkillsRoot + ? [ + `Additional installed Agent Plugin skills are available under ${additionalSkillsRoot}. Read any relevant SKILL.md files before acting.`, + ] + : []), `Run mode: ${mode} (${permissionMode}). ${modeInstruction}`, "", "# User request", @@ -1175,9 +1199,17 @@ async function startCodeAgentMcpManager( }); return null; }); - if (!config || Object.keys(config.servers ?? {}).length === 0) return null; + const effectiveConfig = restrictCodeAgentMcpConfig( + mergeCodeAgentMcpConfig(config), + ); + if ( + !effectiveConfig || + Object.keys(effectiveConfig.servers ?? {}).length === 0 + ) { + return null; + } - const manager = new McpClientManager(config); + const manager = new McpClientManager(effectiveConfig); await manager.start().catch((err) => { const message = err instanceof Error ? err.message : String(err); appendCodeAgentTranscriptEvent({ @@ -1632,6 +1664,10 @@ export async function buildCodeAgentSystemPrompt( ); const bundle = readAgentsBundleFromFs(cwd, null, { instructions: appConfig.instructions, + additionalSkillDirs: (process.env.AGENT_NATIVE_CODE_AGENT_SKILLS_ROOT ?? "") + .split(path.delimiter) + .map((value) => value.trim()) + .filter(Boolean), }); // If the bundle has no AGENTS.md, try CLAUDE.md as a fallback — many repos diff --git a/packages/core/src/cli/code-agent-mcp-config.spec.ts b/packages/core/src/cli/code-agent-mcp-config.spec.ts new file mode 100644 index 0000000000..63fddd2f41 --- /dev/null +++ b/packages/core/src/cli/code-agent-mcp-config.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import type { McpConfig } from "../mcp-client/config.js"; +import { + codexMcpConfigArgs, + mergeCodeAgentMcpConfig, + restrictCodeAgentMcpConfig, +} from "./code-agent-mcp-config.js"; + +describe("code-agent MCP config", () => { + it("accepts the full MCP_SERVERS shape", () => { + const environment = { + MCP_SERVERS: JSON.stringify({ + servers: { + fullHttp: { + type: "http", + url: "https://full.example/mcp", + }, + fullStdio: { + command: "full-bin", + args: ["--serve"], + }, + }, + }), + } as NodeJS.ProcessEnv; + + expect(mergeCodeAgentMcpConfig({ servers: {} }, environment)).toMatchObject( + { + source: "env:MCP_SERVERS", + servers: { + fullHttp: { + type: "http", + url: "https://full.example/mcp", + }, + fullStdio: { + command: "full-bin", + args: ["--serve"], + }, + }, + }, + ); + expect(codexMcpConfigArgs(environment)).toEqual([ + "-c", + 'mcp_servers."fullHttp".url="https://full.example/mcp"', + ]); + }); + + it("accepts the inner-map MCP_SERVERS shape", () => { + const environment = { + MCP_SERVERS: JSON.stringify({ + innerHttp: { + type: "http", + url: "https://inner.example/mcp", + }, + innerStdio: { + command: "inner-bin", + }, + }), + } as NodeJS.ProcessEnv; + + expect(mergeCodeAgentMcpConfig({ servers: {} }, environment)).toMatchObject( + { + source: "env:MCP_SERVERS", + servers: { + innerHttp: { + type: "http", + url: "https://inner.example/mcp", + }, + innerStdio: { + command: "inner-bin", + }, + }, + }, + ); + expect(codexMcpConfigArgs(environment)).toEqual([ + "-c", + 'mcp_servers."innerHttp".url="https://inner.example/mcp"', + ]); + }); + + it("keeps only the exact allowlisted desktop server ids", () => { + const config: McpConfig = { + source: "desktop", + servers: { + allowed: { + command: "allowed-bin", + }, + "org_org-1_zapier": { + type: "http", + url: "https://zapier.example/mcp", + }, + }, + }; + + expect( + restrictCodeAgentMcpConfig(config, { + AGENT_NATIVE_CODE_AGENT_MCP_SERVER_ALLOWLIST: "allowed", + }), + ).toEqual({ + source: "desktop", + servers: { + allowed: { + command: "allowed-bin", + }, + }, + }); + }); +}); diff --git a/packages/core/src/cli/code-agent-mcp-config.ts b/packages/core/src/cli/code-agent-mcp-config.ts new file mode 100644 index 0000000000..7b7a09ebf8 --- /dev/null +++ b/packages/core/src/cli/code-agent-mcp-config.ts @@ -0,0 +1,133 @@ +import type { McpConfig, McpServerConfig } from "../mcp-client/config.js"; + +const DESKTOP_MCP_ALLOWLIST_ENV = + "AGENT_NATIVE_CODE_AGENT_MCP_SERVER_ALLOWLIST"; + +function parseServerAllowlist(raw: string | undefined): Set | null { + if (raw === undefined) return null; + return new Set( + raw + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + ); +} + +/** + * Keep a desktop coding run on the app registry's MCP servers. The normal + * server-side manager merges persisted MCP settings for web requests, but a + * local shell must not inherit arbitrary user MCP servers when the desktop + * host has supplied an explicit workspace allowlist. + */ +export function restrictCodeAgentMcpConfig( + config: McpConfig | null, + environment: NodeJS.ProcessEnv = process.env, +): McpConfig | null { + const allowlist = parseServerAllowlist( + environment[DESKTOP_MCP_ALLOWLIST_ENV], + ); + if (!allowlist || !config) return config; + + const servers = Object.fromEntries( + Object.entries(config.servers).filter(([id]) => allowlist.has(id)), + ); + return { ...config, servers }; +} + +function codexConfigKey(serverId: string): string { + const normalized = serverId.replace(/[^A-Za-z0-9_-]/g, "_"); + return normalized || "workspace"; +} + +function tomlString(value: string): string { + return JSON.stringify(value); +} + +function tomlInlineTable(headers: Record): string { + return `{${Object.entries(headers) + .map(([key, value]) => `${tomlString(key)}=${tomlString(value)}`) + .join(",")}}`; +} + +function parseMcpServers( + environment: NodeJS.ProcessEnv, +): Record { + const raw = environment.MCP_SERVERS?.trim(); + if (!raw) return {}; + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + throw new Error("MCP_SERVERS is not valid JSON."); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("MCP_SERVERS must contain a servers object."); + } + const serverMap = + "servers" in parsed && + parsed.servers && + typeof parsed.servers === "object" && + !Array.isArray(parsed.servers) + ? (parsed.servers as Record) + : (parsed as Record); + return Object.fromEntries( + Object.entries(serverMap).map(([id, value]) => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`MCP_SERVERS entry ${id} is invalid.`); + } + return [id, value]; + }), + ) as Record; +} + +/** + * Add host-provided app/plugin servers to the normal merged config. The + * filesystem and environment layers have different precedence rules, so the + * child process combines them explicitly instead of silently dropping one. + */ +export function mergeCodeAgentMcpConfig( + config: McpConfig | null, + environment: NodeJS.ProcessEnv = process.env, +): McpConfig | null { + const environmentServers = parseMcpServers(environment); + if (Object.keys(environmentServers).length === 0) return config; + return { + ...(config ?? { servers: {} }), + servers: { + ...(config?.servers ?? {}), + ...environmentServers, + }, + source: config?.source + ? `${config.source}+desktop-environment` + : "env:MCP_SERVERS", + }; +} + +/** + * Convert the host-scoped HTTP MCP config into Codex CLI's `-c` overrides. + * The caller adds `--ignore-user-config` in the `exec` segment so a local + * coding session receives the workspace apps, not the user's global MCP + * catalog. + */ +export function codexMcpConfigArgs( + environment: NodeJS.ProcessEnv = process.env, +): string[] { + const servers = parseMcpServers(environment); + if (Object.keys(servers).length === 0) return []; + const args: string[] = []; + for (const [serverId, server] of Object.entries(servers)) { + if (server.type !== "http" || !server.url) continue; + const key = codexConfigKey(serverId); + args.push( + "-c", + `mcp_servers.${tomlString(key)}.url=${tomlString(server.url)}`, + ); + if (server.headers && Object.keys(server.headers).length > 0) { + args.push( + "-c", + `mcp_servers.${tomlString(key)}.http_headers=${tomlInlineTable(server.headers)}`, + ); + } + } + return args; +} diff --git a/packages/core/src/client/NewWorkspaceAppFlow.tsx b/packages/core/src/client/NewWorkspaceAppFlow.tsx index b2359ab9b9..77b2b6fe20 100644 --- a/packages/core/src/client/NewWorkspaceAppFlow.tsx +++ b/packages/core/src/client/NewWorkspaceAppFlow.tsx @@ -79,6 +79,8 @@ const ERROR_FAILURE_REASONS = new Set([ "builder-not-connected", "credential-store-unavailable", ]); +const LOCAL_APP_DOCS_URL = + "https://agent-native.com/docs/multi-app-workspace#adding-a-new-app"; function isErrorFailureReason(reason: string | null): boolean { return !!reason && ERROR_FAILURE_REASONS.has(reason); @@ -396,14 +398,27 @@ export function NewWorkspaceAppFlow({ ) : null} {failureReason === "builder-not-connected" ? ( - +
+ + + Create locally + +
) : null} {failureReason === "credential-store-unavailable" || failureReason === "builder-error" ? ( diff --git a/packages/core/src/client/agent-chat/index.ts b/packages/core/src/client/agent-chat/index.ts index 793ba7d6f5..b8e25c43e5 100644 --- a/packages/core/src/client/agent-chat/index.ts +++ b/packages/core/src/client/agent-chat/index.ts @@ -88,6 +88,11 @@ export { CodeRequiredDialog, type CodeRequiredDialogProps, } from "../components/CodeRequiredDialog.js"; +export { + ChatFirstAgentActivityPanel, + type ChatFirstAgentActivityPanelProps, +} from "../chat-first-agent-activity.js"; +export { ChatFirstSurfacePanelToggle } from "../chat-first-surface-panel-toggle.js"; export { useAgentEngineConfigured, type AgentEngineConfiguredState, @@ -224,6 +229,7 @@ export { } from "../AgentPanel.js"; export { AgentTabsPage, + ConnectionsTab, type AgentPageExtraTabContext, type AgentPageExtraTabFactory, type AgentTabsPageProps, @@ -283,3 +289,67 @@ export { } from "../chat/tool-render-registry.js"; export * from "../chat/connectors.js"; export * from "../chat/runtime.js"; +export { + CHAT_FIRST_APP_LAYOUT_STORAGE_KEY, + CHAT_FIRST_DEFAULT_APP_IDS, + CHAT_FIRST_SURFACE_TABS_STORAGE_KEY, + CHAT_FIRST_SURFACE_PANEL_STORAGE_KEY, + CHAT_FIRST_SURFACE_WIDTH_DEFAULT, + CHAT_FIRST_SURFACE_WIDTH_MIN, + clampChatFirstSurfaceWidth, + CHAT_FIRST_MODE_STORAGE_KEY, + CHAT_FIRST_MODE_CHANGED_EVENT, + CHAT_FIRST_OPEN_APP_EVENT, + CHAT_FIRST_OPEN_BROWSER_EVENT, + CHAT_FIRST_WATCH_SESSION_EVENT, + CHAT_FIRST_SURFACE_CATALOG, + chatFirstSurfaceTabId, + closeChatFirstSessionWatch, + emitChatFirstOpenApp, + emitChatFirstOpenBrowser, + emitChatFirstSessionWatch, + getChatFirstSessionWatchStore, + getChatFirstSurfaceTabsStore, + getChatFirstSurfacePanelStore, + orderChatFirstAppIds, + readChatFirstAppLayout, + resolveChatFirstAppTarget, + resolveChatFirstBrowserTarget, + resolveChatFirstSessionId, + normalizeChatFirstSessionReference, + readChatFirstMode, + readChatFirstModeState, + readChatFirstSurfaceWidth, + subscribeChatFirstOpenApp, + subscribeChatFirstOpenBrowser, + subscribeChatFirstSessionWatch, + useChatFirstSessionWatch, + useChatFirstSurfaceResize, + useChatFirstSurfacePanel, + useChatFirstSurfaceTabs, + writeChatFirstAppLayout, + writeChatFirstMode, + writeChatFirstSurfaceWidth, + type ChatFirstAppLayoutPreference, + type ChatFirstModeReadResult, + type ChatFirstAppRegistration, + type ChatFirstAppResolution, + type ChatFirstAppTarget, + type ChatFirstAgentActivity, + type ChatFirstAgentActivityStatus, + type ChatFirstOpenAppDelivery, + type ChatFirstOpenAppDetail, + type ChatFirstOpenBrowserDetail, + type ChatFirstBrowserResolution, + type ChatFirstSessionKind, + type ChatFirstSessionReference, + type ChatFirstSessionWatchDelivery, + type ChatFirstSessionWatchState, + type ChatFirstSessionWatchStore, + type ChatFirstSurfaceKind, + type ChatFirstSurfaceTab, + type ChatFirstSurfacePanelState, + type ChatFirstSurfacePanelStore, + type ChatFirstSurfaceTabsState, + type ChatFirstSurfaceTabsStore, +} from "../chat-first.js"; diff --git a/packages/core/src/client/agent-page/AgentTabsPage.tsx b/packages/core/src/client/agent-page/AgentTabsPage.tsx index 7d5e57b928..4edf7fc506 100644 --- a/packages/core/src/client/agent-page/AgentTabsPage.tsx +++ b/packages/core/src/client/agent-page/AgentTabsPage.tsx @@ -232,7 +232,9 @@ function ServerStatus({ server }: { server: McpServer }) { ); } -function ConnectionsTab({ canManageOrg = false }: AgentPageTabProps) { +export function ConnectionsTab({ + canManageOrg, +}: Partial = {}) { const t = useT(); const serversQuery = useMcpServers(); const createServer = useCreateMcpServer(); @@ -242,8 +244,10 @@ function ConnectionsTab({ canManageOrg = false }: AgentPageTabProps) { const [deleteTarget, setDeleteTarget] = useState(null); const [error, setError] = useState(null); const data = serversQuery.data; + const resolvedCanManageOrg = + canManageOrg ?? (data?.role === "owner" || data?.role === "admin"); const hasOrg = Boolean(data?.orgId); - const canCreateOrgMcp = hasOrg && canManageOrg; + const canCreateOrgMcp = hasOrg && resolvedCanManageOrg; const onCreateMcpServer = useCallback( async (args: { @@ -286,7 +290,7 @@ function ConnectionsTab({ canManageOrg = false }: AgentPageTabProps) { const renderServer = (server: McpServer) => { const key = `${server.scope}:${server.id}`; - const canDelete = server.scope === "user" || canManageOrg; + const canDelete = server.scope === "user" || resolvedCanManageOrg; const selected = selectedServer?.id === server.id && selectedServer.scope === server.scope; return ( diff --git a/packages/core/src/client/app-providers.spec.tsx b/packages/core/src/client/app-providers.spec.tsx index 3f7252cb8e..a310183b5d 100644 --- a/packages/core/src/client/app-providers.spec.tsx +++ b/packages/core/src/client/app-providers.spec.tsx @@ -26,12 +26,15 @@ let container: HTMLDivElement; let root: Root; let originalLocation: Location; let originalFetch: typeof window.fetch; +let originalDocumentTitle: string; let replaceMock: ReturnType; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); + originalDocumentTitle = document.title; + document.title = ""; replaceMock = vi.fn(); originalFetch = window.fetch; Object.defineProperty(window, "fetch", { @@ -65,6 +68,7 @@ afterEach(() => { configurable: true, value: originalFetch, }); + document.title = originalDocumentTitle; vi.clearAllMocks(); }); @@ -147,4 +151,17 @@ describe("AppProviders session gate", () => { expect(useSessionMock).not.toHaveBeenCalled(); expect(replaceMock).not.toHaveBeenCalled(); }); + + it("repairs structured route titles before they reach the browser tab", async () => { + useSessionMock.mockReturnValue(SIGNED_OUT_SESSION); + document.title = "Manage agent"; + + renderProviders({ isPublicPath: true }); + + act(() => { + document.title = '[{"id":"automation-1","status":"success"}]'; + }); + + await vi.waitFor(() => expect(document.title).toBe("Manage agent")); + }); }); diff --git a/packages/core/src/client/app-providers.tsx b/packages/core/src/client/app-providers.tsx index ca720025d7..24337b52d0 100644 --- a/packages/core/src/client/app-providers.tsx +++ b/packages/core/src/client/app-providers.tsx @@ -52,9 +52,13 @@ import { Toaster } from "@agent-native/toolkit/ui/sonner"; import { TooltipProvider } from "@radix-ui/react-tooltip"; import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; import { ThemeProvider, type Attribute } from "next-themes"; -import React from "react"; +import React, { useEffect, useRef } from "react"; import { useInRouterContext } from "react-router"; +import { + isHumanReadableDocumentTitle, + normalizeDocumentTitle, +} from "../shared/document-title.js"; import { ClientOnly } from "./ClientOnly.js"; import { DefaultSpinner } from "./DefaultSpinner.js"; import { @@ -133,6 +137,9 @@ export interface AppProvidersProps { */ sessionBypass?: boolean; + /** Fallback used if route metadata leaves the browser title empty or structured. */ + documentTitleFallback?: string; + children: React.ReactNode; } @@ -150,6 +157,60 @@ function RoutedAppEnhancements() { ); } +function readDocumentTitleFallback(): string { + const selectors = [ + 'meta[name="application-name"]', + 'meta[name="apple-mobile-web-app-title"]', + 'meta[property="og:site_name"]', + ]; + const metadataTitle = selectors + .map( + (selector) => + document.querySelector(selector)?.content ?? "", + ) + .find((title) => isHumanReadableDocumentTitle(title)); + return normalizeDocumentTitle(metadataTitle, "Agent Native"); +} + +/** Repairs route metadata that would otherwise expose a structured payload in the tab. */ +function DocumentTitleGuard({ fallbackTitle }: { fallbackTitle?: string }) { + const initialTitleRef = useRef(null); + if (initialTitleRef.current === null && typeof document !== "undefined") { + const initialTitle = document.title.trim(); + if (isHumanReadableDocumentTitle(initialTitle)) { + initialTitleRef.current = initialTitle; + } + } + + useEffect(() => { + let lastKnownTitle = normalizeDocumentTitle( + initialTitleRef.current ?? fallbackTitle ?? readDocumentTitleFallback(), + fallbackTitle ?? "Agent Native", + ); + + const repairTitle = () => { + const currentTitle = document.title.trim(); + if (isHumanReadableDocumentTitle(currentTitle)) { + lastKnownTitle = currentTitle; + return; + } + const nextTitle = normalizeDocumentTitle(lastKnownTitle, "Agent Native"); + if (currentTitle !== nextTitle) document.title = nextTitle; + }; + + repairTitle(); + const observer = new MutationObserver(repairTitle); + observer.observe(document.head, { + characterData: true, + childList: true, + subtree: true, + }); + return () => observer.disconnect(); + }, [fallbackTitle]); + + return null; +} + function ProvidersInner({ queryClient, defaultTheme = "system", @@ -158,6 +219,7 @@ function ProvidersInner({ toaster = DEFAULT_TOASTER, disableThemeTransitions = true, i18n, + documentTitleFallback, children, }: { queryClient: QueryClient; @@ -167,6 +229,7 @@ function ProvidersInner({ toaster?: React.ReactNode | null; disableThemeTransitions?: boolean; i18n?: Omit | false; + documentTitleFallback?: string; children: React.ReactNode; }) { const localizedChildren = @@ -188,6 +251,7 @@ function ProvidersInner({ > {localizedChildren} + {toaster} @@ -208,6 +272,7 @@ export function AppProviders({ toaster, disableThemeTransitions, i18n, + documentTitleFallback, children, }: AppProvidersProps) { const fallback = clientOnlyFallback ?? ; @@ -222,6 +287,7 @@ export function AppProviders({ toaster={toaster} disableThemeTransitions={disableThemeTransitions} i18n={i18n} + documentTitleFallback={documentTitleFallback} > {children} @@ -238,6 +304,7 @@ export function AppProviders({ toaster={toaster} disableThemeTransitions={disableThemeTransitions} i18n={i18n} + documentTitleFallback={documentTitleFallback} > {children} diff --git a/packages/core/src/client/chat-first-agent-activity.tsx b/packages/core/src/client/chat-first-agent-activity.tsx new file mode 100644 index 0000000000..714110d759 --- /dev/null +++ b/packages/core/src/client/chat-first-agent-activity.tsx @@ -0,0 +1,200 @@ +import { + IconAlertCircle, + IconAsteriskSimple, + IconCopy, + IconDiamond, + IconEye, + IconFlower, + IconHexagon, + IconRosette, + IconRefresh, + IconSparkles, + type TablerIcon, +} from "@tabler/icons-react"; +import { useState } from "react"; + +import type { + ChatFirstAgentActivity, + ChatFirstAgentActivityStatus, +} from "./chat-first.js"; +import { defaultChatFirstCopy } from "./chat-first/copy.js"; +import type { ChatFirstCopy } from "./chat-first/types.js"; +import { writeClipboardText } from "./clipboard.js"; +import { cn } from "./utils.js"; + +export interface ChatFirstAgentActivityPanelProps { + activities: readonly ChatFirstAgentActivity[]; + loading?: boolean; + error?: string | null; + onRefresh?: () => void; + onWatch?: (activity: ChatFirstAgentActivity) => void; + copy?: ChatFirstCopy; +} + +const STATUS_KEYS: Record = { + queued: "agentActivityStatusQueued", + running: "agentActivityStatusRunning", + paused: "agentActivityStatusPaused", + "needs-approval": "agentActivityStatusNeedsApproval", + completed: "agentActivityStatusCompleted", + errored: "agentActivityStatusErrored", + recent: "agentActivityStatusRecent", + unknown: "agentActivityStatusUnknown", +}; + +const AGENT_GLYPHS: Array<{ icon: TablerIcon; color: string }> = [ + { icon: IconSparkles, color: "text-primary" }, + { icon: IconFlower, color: "text-destructive" }, + { icon: IconRosette, color: "text-accent-foreground" }, + { icon: IconHexagon, color: "text-ring" }, + { icon: IconDiamond, color: "text-primary/70" }, + { icon: IconAsteriskSimple, color: "text-muted-foreground" }, +]; + +function glyphForSession(sessionId: string) { + let hash = 0; + for (const character of sessionId) { + hash = (hash * 31 + character.codePointAt(0)!) | 0; + } + return AGENT_GLYPHS[Math.abs(hash) % AGENT_GLYPHS.length]; +} + +export function ChatFirstAgentActivityPanel({ + activities, + loading = false, + error = null, + onRefresh, + onWatch, + copy = defaultChatFirstCopy, +}: ChatFirstAgentActivityPanelProps) { + const [copiedSessionId, setCopiedSessionId] = useState(null); + + async function copySessionId(sessionId: string) { + const copied = await writeClipboardText(sessionId); + if (!copied) return; + setCopiedSessionId(sessionId); + window.setTimeout(() => { + setCopiedSessionId((current) => (current === sessionId ? null : current)); + }, 1600); + } + + const statusLabel = (status: ChatFirstAgentActivityStatus) => + copy(STATUS_KEYS[status]); + + return ( +
+
+

+ {copy("agentsTitle")} +

+ {onRefresh ? ( + + ) : null} +
+ + {error ? ( +
+
+ ) : null} + + {loading && activities.length === 0 ? ( +
+ {Array.from({ length: 4 }, (_, index) => ( +
+ + +
+ ))} +
+ ) : activities.length === 0 ? ( +

+ {copy("noAgentSessions")} +

+ ) : ( +
+ {activities.map((activity) => { + const { icon: AgentGlyph, color } = glyphForSession( + activity.sessionId, + ); + const copied = copiedSessionId === activity.sessionId; + return ( +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/packages/core/src/client/chat-first-surface-panel-toggle.tsx b/packages/core/src/client/chat-first-surface-panel-toggle.tsx new file mode 100644 index 0000000000..1f1f6a6e65 --- /dev/null +++ b/packages/core/src/client/chat-first-surface-panel-toggle.tsx @@ -0,0 +1,30 @@ +import { IconLayoutSidebarRightCollapse } from "@tabler/icons-react"; + +import { cn } from "./utils.js"; + +export function ChatFirstSurfacePanelToggle({ + open, + onToggle, + className, +}: { + open: boolean; + onToggle: () => void; + className?: string; +}) { + return ( + + ); +} diff --git a/packages/core/src/client/chat-first.spec.ts b/packages/core/src/client/chat-first.spec.ts new file mode 100644 index 0000000000..d1bbbce060 --- /dev/null +++ b/packages/core/src/client/chat-first.spec.ts @@ -0,0 +1,311 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + CHAT_FIRST_DEFAULT_APP_IDS, + orderChatFirstAppIds, + readChatFirstAppLayout, + writeChatFirstAppLayout, + CHAT_FIRST_MODE_STORAGE_KEY, + getChatFirstSessionWatchStore, + getChatFirstSurfaceTabsStore, + getChatFirstSurfacePanelStore, + clampChatFirstSurfaceWidth, + normalizeChatFirstSessionReference, + readChatFirstMode, + readChatFirstModeState, + resolveChatFirstAppTarget, + resolveChatFirstBrowserTarget, + resolveChatFirstSessionId, + chatFirstSurfaceTabId, + readChatFirstSurfaceWidth, + writeChatFirstSurfaceWidth, + writeChatFirstMode, +} from "./chat-first.js"; + +function createStorage(): Storage { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + values.set(key, value); + }, + removeItem: (key) => { + values.delete(key); + }, + clear: () => values.clear(), + key: (index) => [...values.keys()][index] ?? null, + get length() { + return values.size; + }, + }; +} + +describe("chat-first preference", () => { + it("uses the shared first-run app order before the rest of the catalog", () => { + expect(CHAT_FIRST_DEFAULT_APP_IDS).toEqual([ + "content", + "design", + "mail", + "calendar", + "clips", + ]); + expect( + orderChatFirstAppIds( + ["brain", "mail", "calendar", "design", "clips", "content"], + { pinnedIds: [], orderedIds: [] }, + ), + ).toEqual(["content", "design", "mail", "calendar", "clips", "brain"]); + }); + + it("defaults to disabled and persists the opt-in value", () => { + const storage = createStorage(); + + expect(storage.getItem(CHAT_FIRST_MODE_STORAGE_KEY)).toBeNull(); + expect(readChatFirstMode(storage)).toBe(false); + expect(readChatFirstModeState(storage)).toEqual({ + enabled: false, + availability: "available", + }); + + writeChatFirstMode(true, storage); + + expect(readChatFirstMode(storage)).toBe(true); + }); + + it("reports storage failures instead of claiming the preference persisted", () => { + const storage = { + getItem: () => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + }, + }; + + expect(readChatFirstMode(storage)).toBe(false); + expect(readChatFirstModeState(storage)).toEqual({ + enabled: false, + availability: "unavailable", + }); + expect(writeChatFirstMode(true, storage)).toEqual({ + ok: false, + reason: "write-failed", + }); + }); + + it("persists a bounded app shelf layout and keeps pinned apps first", () => { + const storage = createStorage(); + expect( + writeChatFirstAppLayout( + { + pinnedIds: ["calendar", "calendar"], + orderedIds: ["mail", "calendar"], + }, + storage, + ), + ).toEqual({ ok: true }); + const layout = readChatFirstAppLayout(storage); + + expect(layout).toEqual({ + pinnedIds: ["calendar"], + orderedIds: ["mail", "calendar"], + }); + expect( + orderChatFirstAppIds(["mail", "calendar", "design"], layout), + ).toEqual(["calendar", "mail", "design"]); + expect( + orderChatFirstAppIds(["mail", "calendar", "design"], { + pinnedIds: ["calendar"], + orderedIds: ["design", "mail", "calendar"], + }), + ).toEqual(["calendar", "design", "mail"]); + }); +}); + +describe("chat-first app target resolution", () => { + const apps = [ + { + id: "mail", + name: "Mail", + url: "https://mail.example.com", + path: "/mail", + }, + { + id: "calendar", + name: "Calendar", + url: "https://workspace.example.com/calendar", + path: "/calendar", + }, + ]; + + it("turns a registered absolute target into an app-relative path", () => { + expect( + resolveChatFirstAppTarget( + { + app: "mail", + url: "https://mail.example.com/inbox?filter=unread", + }, + apps, + ), + ).toEqual({ + status: "ready", + target: { + appId: "mail", + path: "/inbox?filter=unread", + }, + }); + }); + + it("rejects a URL that does not belong to the named app", () => { + expect( + resolveChatFirstAppTarget( + { app: "mail", url: "https://evil.example.com/login" }, + apps, + ), + ).toEqual({ status: "unresolved", reason: "invalid-url" }); + }); + + it("rejects an app-named target that is not registered", () => { + expect( + resolveChatFirstAppTarget({ app: "unknown", path: "/inbox" }, apps), + ).toEqual({ status: "unresolved", reason: "unknown-app" }); + }); + + it("builds a deep-link path for view-only targets", () => { + expect( + resolveChatFirstAppTarget({ app: "calendar", view: "month" }, apps), + ).toEqual({ + status: "ready", + target: { + appId: "calendar", + path: "/_agent-native/open?app=calendar&view=month", + view: "month", + }, + }); + }); + + it("keeps non-agent-native browser targets in a visible browser surface", () => { + expect( + resolveChatFirstBrowserTarget({ + url: "https://example.com/docs?q=chat-first", + title: "Docs", + }), + ).toEqual({ + status: "ready", + target: { + url: "https://example.com/docs?q=chat-first", + title: "Docs", + }, + }); + expect( + resolveChatFirstBrowserTarget({ + url: "javascript:alert(1)", + }), + ).toEqual({ status: "unresolved", reason: "invalid-url" }); + }); +}); + +describe("chat-first surface panel preference", () => { + it("starts closed and supports an explicit toggle", () => { + const store = getChatFirstSurfacePanelStore( + `panel-test-${Math.random().toString(36).slice(2)}`, + ); + expect(store.getSnapshot().open).toBe(false); + store.setOpen(true); + expect(store.getSnapshot().open).toBe(true); + store.toggle(); + expect(store.getSnapshot().open).toBe(false); + }); +}); + +describe("chat-first session watch contract", () => { + it("resolves stable ids from run and thread-shaped payloads", () => { + expect(resolveChatFirstSessionId({ runId: " run-42 " })).toBe("run-42"); + expect(resolveChatFirstSessionId({ threadId: "thread-7" })).toBe( + "thread-7", + ); + expect(resolveChatFirstSessionId({ id: "" })).toBeNull(); + expect( + normalizeChatFirstSessionReference({ + id: "thread-7", + title: " Review ", + kind: "agent-chat", + sourceSessionId: "source-1", + }), + ).toEqual({ + sessionId: "thread-7", + title: "Review", + kind: "agent-chat", + sourceSessionId: "source-1", + }); + }); + + it("keeps watch state and subscriptions shared by both renderers", () => { + const store = getChatFirstSessionWatchStore(); + store.close(); + const listener = vi.fn(); + const unsubscribe = store.subscribe(listener); + + store.open({ sessionId: "session-1", title: "Build" }); + expect(store.getSnapshot()).toEqual({ + target: { sessionId: "session-1", title: "Build" }, + }); + expect(listener).toHaveBeenCalledTimes(1); + + store.close(); + expect(store.getSnapshot()).toEqual({ target: null }); + expect(listener).toHaveBeenCalledTimes(2); + unsubscribe(); + }); + + it("provides tab lifecycle operations for shared side surfaces", () => { + const store = getChatFirstSurfaceTabsStore(); + store.closeAll(); + store.open({ + id: chatFirstSurfaceTabId("browser", "docs"), + kind: "browser", + title: "Docs", + url: "https://example.com/docs", + }); + store.open({ + id: chatFirstSurfaceTabId("app", "mail"), + kind: "app", + title: "Mail", + appId: "mail", + }); + store.open({ + id: chatFirstSurfaceTabId("files", "workspace"), + kind: "files", + title: "Files", + }); + + expect(store.getSnapshot().activeTabId).toBe("files:workspace"); + store.closeToRight("browser:docs"); + expect(store.getSnapshot().tabs.map((tab) => tab.id)).toEqual([ + "browser:docs", + ]); + store.open({ + id: "app:mail", + kind: "app", + title: "Mail", + appId: "mail", + }); + store.open({ id: "diff", kind: "diff", title: "Diff" }); + store.closeOthers("app:mail"); + expect(store.getSnapshot().tabs.map((tab) => tab.id)).toEqual(["app:mail"]); + store.closeAll(); + }); + + it("clamps and persists side-surface width without accepting invalid values", () => { + const storage = createStorage(); + expect(clampChatFirstSurfaceWidth(100, 1200)).toBe(320); + expect(clampChatFirstSurfaceWidth(2000, 1200)).toBe(840); + expect(writeChatFirstSurfaceWidth(620, "thread-1", storage)).toEqual({ + ok: true, + }); + expect(readChatFirstSurfaceWidth("thread-1", storage)).toBe(620); + expect(readChatFirstSurfaceWidth("missing", storage)).toBe( + clampChatFirstSurfaceWidth(380), + ); + }); +}); diff --git a/packages/core/src/client/chat-first.ts b/packages/core/src/client/chat-first.ts new file mode 100644 index 0000000000..5df64bbe07 --- /dev/null +++ b/packages/core/src/client/chat-first.ts @@ -0,0 +1,1151 @@ +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; + +export const CHAT_FIRST_MODE_STORAGE_KEY = "agent-native:chat-first-mode:v1"; +export const CHAT_FIRST_APP_LAYOUT_STORAGE_KEY = + "agent-native:chat-first-app-layout:v1"; +export const CHAT_FIRST_SURFACE_TABS_STORAGE_KEY = + "agent-native:chat-first-surface-tabs:v1"; +export const CHAT_FIRST_SURFACE_WIDTH_STORAGE_KEY = + "agent-native:chat-first-surface-width:v2"; +export const CHAT_FIRST_SURFACE_PANEL_STORAGE_KEY = + "agent-native:chat-first-surface-panel:v1"; +export const CHAT_FIRST_MODE_CHANGED_EVENT = "agentNative:chatFirstModeChanged"; +export const CHAT_FIRST_OPEN_APP_EVENT = "agentNative:openApp"; +export const CHAT_FIRST_OPEN_BROWSER_EVENT = "agentNative:openBrowser"; +export const CHAT_FIRST_WATCH_SESSION_EVENT = "agentNative:watchSession"; + +export type ChatFirstOpenAppDetail = { + app?: string; + path?: string; + url?: string; + view?: string; +}; + +export type ChatFirstOpenBrowserDetail = { + url?: string; + title?: string; +}; + +export type ChatFirstSessionKind = "code-agent" | "agent-chat" | "external"; + +export interface ChatFirstSessionReference { + sessionId: string; + title?: string; + kind?: ChatFirstSessionKind; + goalId?: string; + sourceSessionId?: string; +} + +export type ChatFirstSessionWatchState = { + target: ChatFirstSessionReference | null; +}; + +export interface ChatFirstSessionWatchStore { + getSnapshot: () => ChatFirstSessionWatchState; + subscribe: (listener: () => void) => () => void; + hasSubscribers: () => boolean; + open: (target: ChatFirstSessionReference) => void; + close: () => void; +} + +export type ChatFirstSurfaceKind = + | "app" + | "browser" + | "terminal" + | "diff" + | "files" + | "side-chat" + | "agents"; + +export interface ChatFirstSurfaceTab { + id: string; + kind: ChatFirstSurfaceKind; + title: string; + appId?: string; + path?: string; + view?: string; + url?: string; + session?: ChatFirstSessionReference; + disabledReason?: string; +} + +export type ChatFirstAgentActivityStatus = + | "queued" + | "running" + | "paused" + | "needs-approval" + | "completed" + | "errored" + | "recent" + | "unknown"; + +/** The renderer-neutral subset used by the shared agent activity surface. */ +export interface ChatFirstAgentActivity { + sessionId: string; + title: string; + subtitle?: string; + status: ChatFirstAgentActivityStatus; + updatedAt?: string | number; + progressPercent?: number; + goalId?: string; +} + +export interface ChatFirstSurfaceTabsState { + tabs: ChatFirstSurfaceTab[]; + activeTabId: string | null; +} + +export interface ChatFirstSurfaceTabsStore { + getSnapshot: () => ChatFirstSurfaceTabsState; + subscribe: (listener: () => void) => () => void; + open: (tab: ChatFirstSurfaceTab) => void; + activate: (tabId: string) => void; + close: (tabId: string) => void; + closeOthers: (tabId: string) => void; + closeToRight: (tabId: string) => void; + closeAll: () => void; +} + +export interface ChatFirstSurfacePanelState { + open: boolean; +} + +export interface ChatFirstSurfacePanelStore { + getSnapshot: () => ChatFirstSurfacePanelState; + subscribe: (listener: () => void) => () => void; + setOpen: (open: boolean) => void; + toggle: () => void; +} + +export const CHAT_FIRST_SURFACE_CATALOG: readonly { + kind: ChatFirstSurfaceKind; + label: string; + description: string; + availability: "both" | "desktop" | "deferred"; + disabledReason?: string; +}[] = [ + { + kind: "browser", + label: "Browser", + description: "Open a regular web page with address-bar controls.", + availability: "both", + }, + { + kind: "terminal", + label: "Terminal", + description: "Run commands beside the conversation.", + availability: "deferred", + disabledReason: + "Deferred in this release: the existing terminal is a standalone host surface; a shared PTY lifecycle is needed for an embedded pane.", + }, + { + kind: "files", + label: "Files", + description: "Browse the active workspace tree.", + availability: "deferred", + disabledReason: + "Deferred in this release: neither client exposes a bounded workspace tree to this shared pane contract yet.", + }, + { + kind: "diff", + label: "Diff", + description: "Review the current changeset.", + availability: "deferred", + disabledReason: + "Deferred in this release: diff blocks remain available in chat while a shared changed-file source is defined.", + }, + { + kind: "side-chat", + label: "Side chat", + description: "Watch and message another session beside the main chat.", + availability: "both", + disabledReason: "Choose Watch and message from any chat row.", + }, + { + kind: "agents", + label: "Agents", + description: "Review agent sessions and watch one beside the conversation.", + availability: "both", + disabledReason: "Review recent runs or watch one beside this conversation.", + }, +]; + +const CHAT_FIRST_SURFACE_KINDS: readonly ChatFirstSurfaceKind[] = [ + "app", + "browser", + "terminal", + "diff", + "files", + "side-chat", + "agents", +]; + +export function chatFirstSurfaceTabId( + kind: ChatFirstSurfaceKind, + value: string, +): string { + return `${kind}:${value}`; +} + +export const CHAT_FIRST_SURFACE_WIDTH_DEFAULT = 380; +export const CHAT_FIRST_SURFACE_WIDTH_MIN = 320; + +/** + * The first-run rail is intentionally opinionated. These are the five + * everyday workspace apps shown before the user expands the rest of the + * catalog; an explicit ordered layout still wins after the first change. + */ +export const CHAT_FIRST_DEFAULT_APP_IDS = [ + "content", + "design", + "mail", + "calendar", + "clips", +] as const; + +export function clampChatFirstSurfaceWidth( + value: number, + viewportWidth = typeof window === "undefined" + ? Number.POSITIVE_INFINITY + : window.innerWidth, +): number { + const max = Math.max( + CHAT_FIRST_SURFACE_WIDTH_MIN, + Math.floor(viewportWidth * 0.7), + ); + return Math.min( + max, + Math.max(CHAT_FIRST_SURFACE_WIDTH_MIN, Math.round(value)), + ); +} + +function surfaceWidthStorageKey(scope: string): string { + return `${CHAT_FIRST_SURFACE_WIDTH_STORAGE_KEY}:${normalizedSurfaceScope(scope)}`; +} + +export function readChatFirstSurfaceWidth( + scope = "default", + storage: StringStorage | null = browserStorage(), +): number { + if (!storage) return CHAT_FIRST_SURFACE_WIDTH_DEFAULT; + try { + const raw = storage.getItem(surfaceWidthStorageKey(scope)); + if (!raw) return CHAT_FIRST_SURFACE_WIDTH_DEFAULT; + const value = Number(raw); + return Number.isFinite(value) + ? clampChatFirstSurfaceWidth(value) + : CHAT_FIRST_SURFACE_WIDTH_DEFAULT; + } catch { + return CHAT_FIRST_SURFACE_WIDTH_DEFAULT; + } +} + +export function writeChatFirstSurfaceWidth( + width: number, + scope = "default", + storage: StringStorage | null = browserStorage(), +): { ok: true } | { ok: false; reason: "unavailable" | "write-failed" } { + if (!storage) return { ok: false, reason: "unavailable" }; + try { + storage.setItem( + surfaceWidthStorageKey(scope), + String(clampChatFirstSurfaceWidth(width)), + ); + return { ok: true }; + } catch { + return { ok: false, reason: "write-failed" }; + } +} + +export function useChatFirstSurfaceResize(scope = "default") { + const [width, setWidth] = useState(() => readChatFirstSurfaceWidth(scope)); + const [isResizing, setIsResizing] = useState(false); + const widthRef = useRef(width); + const cleanupRef = useRef<(() => void) | null>(null); + widthRef.current = width; + + useEffect(() => { + cleanupRef.current?.(); + cleanupRef.current = null; + const next = readChatFirstSurfaceWidth(scope); + widthRef.current = next; + setWidth(next); + setIsResizing(false); + return () => cleanupRef.current?.(); + }, [scope]); + + const onPointerDown = useCallback( + (event: { clientX: number; preventDefault: () => void }) => { + if (typeof window === "undefined") return; + event.preventDefault(); + cleanupRef.current?.(); + const startX = event.clientX; + const startWidth = widthRef.current; + setIsResizing(true); + const handleMove = (moveEvent: PointerEvent) => { + const next = clampChatFirstSurfaceWidth( + startWidth - (moveEvent.clientX - startX), + ); + widthRef.current = next; + setWidth(next); + }; + const handleUp = () => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + cleanupRef.current = null; + setIsResizing(false); + writeChatFirstSurfaceWidth(widthRef.current, scope); + }; + cleanupRef.current = () => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + }; + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleUp, { once: true }); + }, + [scope], + ); + + return { width, isResizing, onPointerDown }; +} + +function surfacePanelStorageKey(scope: string): string { + return `${CHAT_FIRST_SURFACE_PANEL_STORAGE_KEY}:${normalizedSurfaceScope(scope)}`; +} + +function createChatFirstSurfacePanelStore( + scope: string, +): ChatFirstSurfacePanelStore { + let state: ChatFirstSurfacePanelState = { open: false }; + const listeners = new Set<() => void>(); + try { + state = { + open: browserStorage()?.getItem(surfacePanelStorageKey(scope)) === "true", + }; + // coercion-ok: device-local panel preference failure falls back to the closed in-memory state. + } catch { + // The panel is an optional device preference; the live default is closed. + } + const publish = (open: boolean) => { + if (state.open === open) return; + state = { open }; + try { + browserStorage()?.setItem(surfacePanelStorageKey(scope), String(open)); + // coercion-ok: device-local persistence failure leaves the in-memory toggle usable. + } catch { + // Keep the in-memory toggle usable when device storage is unavailable. + } + listeners.forEach((listener) => listener()); + }; + return { + getSnapshot: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + setOpen: publish, + toggle: () => publish(!state.open), + }; +} + +const chatFirstSurfacePanelStores = new Map< + string, + ChatFirstSurfacePanelStore +>(); + +export function getChatFirstSurfacePanelStore( + scope = "default", +): ChatFirstSurfacePanelStore { + const key = normalizedSurfaceScope(scope); + const existing = chatFirstSurfacePanelStores.get(key); + if (existing) return existing; + const store = createChatFirstSurfacePanelStore(key); + chatFirstSurfacePanelStores.set(key, store); + return store; +} + +export function useChatFirstSurfacePanel(scope = "default") { + const store = getChatFirstSurfacePanelStore(scope); + const state = useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + (event.metaKey || event.ctrlKey) && + event.altKey && + event.key.toLowerCase() === "b" + ) { + event.preventDefault(); + store.toggle(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [store]); + + return { ...state, setOpen: store.setOpen, toggle: store.toggle }; +} + +export interface ChatFirstSessionWatchDelivery { + delivered: boolean; + reason?: "empty-detail" | "no-listener"; +} + +export type ChatFirstBrowserResolution = + | { status: "ready"; target: { url: string; title?: string } } + | { status: "unresolved"; reason: "empty-detail" | "invalid-url" }; + +export interface ChatFirstAppRegistration { + id: string; + name?: string; + enabled?: boolean; + /** The app's current production or mounted URL. */ + url?: string | null; + /** Optional local development URL used by the desktop shell. */ + devUrl?: string | null; + /** Mounted path used when an app has no absolute URL. */ + path?: string | null; +} + +export interface ChatFirstAppTarget { + appId: string; + path?: string; + view?: string; +} + +export type ChatFirstAppResolution = + | { status: "ready"; target: ChatFirstAppTarget } + | { + status: "unresolved"; + reason: "empty-detail" | "unknown-app" | "invalid-url"; + }; + +export interface ChatFirstOpenAppDelivery { + delivered: boolean; + reason?: "empty-detail" | "no-listener"; +} + +export interface ChatFirstAppLayoutPreference { + pinnedIds: string[]; + orderedIds: string[]; +} + +export interface ChatFirstModeReadResult { + enabled: boolean; + availability: "available" | "unavailable"; +} + +type StringStorage = Pick; + +function browserStorage(): StringStorage | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + // coercion-ok: callers receive null and expose unavailable device storage explicitly. + } catch { + return null; + } +} + +export function readChatFirstModeState( + storage: StringStorage | null = browserStorage(), +): ChatFirstModeReadResult { + if (!storage) { + return { enabled: false, availability: "unavailable" }; + } + try { + return { + enabled: storage.getItem(CHAT_FIRST_MODE_STORAGE_KEY) === "true", + availability: "available", + }; + } catch { + return { enabled: false, availability: "unavailable" }; + } +} + +export function readChatFirstMode( + storage: StringStorage | null = browserStorage(), +): boolean { + return readChatFirstModeState(storage).enabled; +} + +export function writeChatFirstMode( + enabled: boolean, + storage: StringStorage | null = browserStorage(), +): { ok: true } | { ok: false; reason: "unavailable" | "write-failed" } { + if (!storage) return { ok: false, reason: "unavailable" }; + try { + storage.setItem(CHAT_FIRST_MODE_STORAGE_KEY, String(enabled)); + return { ok: true }; + } catch { + return { ok: false, reason: "write-failed" }; + } +} + +const EMPTY_CHAT_FIRST_APP_LAYOUT: ChatFirstAppLayoutPreference = { + pinnedIds: [], + orderedIds: [], +}; + +export function readChatFirstAppLayout( + storage: StringStorage | null = browserStorage(), +): ChatFirstAppLayoutPreference { + if (!storage) return { ...EMPTY_CHAT_FIRST_APP_LAYOUT }; + try { + const raw = storage.getItem(CHAT_FIRST_APP_LAYOUT_STORAGE_KEY); + if (!raw) return { ...EMPTY_CHAT_FIRST_APP_LAYOUT }; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") { + return { ...EMPTY_CHAT_FIRST_APP_LAYOUT }; + } + const value = parsed as Record; + const ids = (candidate: unknown): string[] => + Array.isArray(candidate) + ? candidate.filter( + (item): item is string => + typeof item === "string" && item.trim().length > 0, + ) + : []; + return { + pinnedIds: [...new Set(ids(value.pinnedIds))], + orderedIds: [...new Set(ids(value.orderedIds))], + }; + } catch { + return { ...EMPTY_CHAT_FIRST_APP_LAYOUT }; + } +} + +export function writeChatFirstAppLayout( + layout: ChatFirstAppLayoutPreference, + storage: StringStorage | null = browserStorage(), +): { ok: true } | { ok: false; reason: "unavailable" | "write-failed" } { + if (!storage) return { ok: false, reason: "unavailable" }; + try { + storage.setItem( + CHAT_FIRST_APP_LAYOUT_STORAGE_KEY, + JSON.stringify({ + pinnedIds: [...new Set(layout.pinnedIds.filter(Boolean))], + orderedIds: [...new Set(layout.orderedIds.filter(Boolean))], + }), + ); + return { ok: true }; + } catch { + return { ok: false, reason: "write-failed" }; + } +} + +export function orderChatFirstAppIds( + appIds: readonly string[], + layout: ChatFirstAppLayoutPreference, +): string[] { + const available = new Set(appIds); + const preferredDefaults = CHAT_FIRST_DEFAULT_APP_IDS.filter((id) => + available.has(id), + ); + const preferredDefaultSet = new Set(preferredDefaults); + const fallbackOrder = [ + ...preferredDefaults, + ...appIds.filter((id) => !preferredDefaultSet.has(id)), + ]; + const ordered = [ + ...layout.orderedIds.filter((id) => available.has(id)), + ...fallbackOrder.filter((id) => !layout.orderedIds.includes(id)), + ]; + const pinned = new Set(layout.pinnedIds.filter((id) => available.has(id))); + // `orderedIds` is the single source of positional truth. Pinning changes + // presentation only; it must not rewrite the user's drag order or make an + // unpinned app jump to the fallback order. + return [ + ...ordered.filter((id) => pinned.has(id)), + ...ordered.filter((id) => !pinned.has(id)), + ]; +} + +export function resolveChatFirstBrowserTarget( + detail: ChatFirstOpenBrowserDetail, +): ChatFirstBrowserResolution { + const rawUrl = detail.url?.trim(); + if (!rawUrl) return { status: "unresolved", reason: "empty-detail" }; + const url = parseAbsoluteHttpUrl(rawUrl); + if (!url || url.username || url.password) { + return { status: "unresolved", reason: "invalid-url" }; + } + return { + status: "ready", + target: { + url: url.href, + ...(detail.title?.trim() ? { title: detail.title.trim() } : {}), + }, + }; +} + +type ChatFirstOpenAppListener = (detail: ChatFirstOpenAppDetail) => void; +const chatFirstOpenAppListeners = new Set(); +type ChatFirstOpenBrowserListener = ( + detail: ChatFirstOpenBrowserDetail, +) => void; +const chatFirstOpenBrowserListeners = new Set(); + +function normalizeSessionId(value: unknown): string | null { + if (typeof value !== "string") return null; + const id = value.trim(); + return id || null; +} + +/** Resolve ids from run/thread payloads without treating an absent id as a session. */ +export function resolveChatFirstSessionId(value: unknown): string | null { + if (typeof value === "string") return normalizeSessionId(value); + if (!value || typeof value !== "object") return null; + const record = value as Record; + for (const key of ["sessionId", "runId", "threadId", "id"]) { + const id = normalizeSessionId(record[key]); + if (id) return id; + } + return null; +} + +export function normalizeChatFirstSessionReference( + value: unknown, +): ChatFirstSessionReference | null { + const sessionId = resolveChatFirstSessionId(value); + if (!sessionId) return null; + if (!value || typeof value !== "object") return { sessionId }; + const record = value as Record; + const title = normalizeSessionId(record.title); + const goalId = normalizeSessionId(record.goalId); + const sourceSessionId = normalizeSessionId(record.sourceSessionId); + const kind = + record.kind === "code-agent" || + record.kind === "agent-chat" || + record.kind === "external" + ? record.kind + : undefined; + return { + sessionId, + ...(title ? { title } : {}), + ...(kind ? { kind } : {}), + ...(goalId ? { goalId } : {}), + ...(sourceSessionId ? { sourceSessionId } : {}), + }; +} + +function createChatFirstSessionWatchStore(): ChatFirstSessionWatchStore { + let state: ChatFirstSessionWatchState = { target: null }; + const listeners = new Set<() => void>(); + return { + getSnapshot: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + hasSubscribers: () => listeners.size > 0, + open: (target) => { + state = { target }; + listeners.forEach((listener) => listener()); + }, + close: () => { + if (!state.target) return; + state = { target: null }; + listeners.forEach((listener) => listener()); + }, + }; +} + +const chatFirstSessionWatchStore = createChatFirstSessionWatchStore(); + +export function getChatFirstSessionWatchStore(): ChatFirstSessionWatchStore { + return chatFirstSessionWatchStore; +} + +export function useChatFirstSessionWatch(): ChatFirstSessionWatchState { + return useSyncExternalStore( + chatFirstSessionWatchStore.subscribe, + chatFirstSessionWatchStore.getSnapshot, + chatFirstSessionWatchStore.getSnapshot, + ); +} + +export function subscribeChatFirstSessionWatch( + listener: (target: ChatFirstSessionReference) => void, +): () => void { + if (typeof window === "undefined") return () => undefined; + listenersForWatch += 1; + const handleEvent = (event: Event) => { + const target = normalizeChatFirstSessionReference( + (event as CustomEvent).detail, + ); + if (target) listener(target); + }; + window.addEventListener(CHAT_FIRST_WATCH_SESSION_EVENT, handleEvent); + return () => { + listenersForWatch = Math.max(0, listenersForWatch - 1); + window.removeEventListener(CHAT_FIRST_WATCH_SESSION_EVENT, handleEvent); + }; +} + +export function emitChatFirstSessionWatch( + detail: unknown, +): ChatFirstSessionWatchDelivery { + const target = normalizeChatFirstSessionReference(detail); + if (!target) return { delivered: false, reason: "empty-detail" }; + const delivered = + chatFirstSessionWatchStore.hasSubscribers() || listenersForWatch > 0; + chatFirstSessionWatchStore.open(target); + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent( + CHAT_FIRST_WATCH_SESSION_EVENT, + { detail: target }, + ), + ); + } + return delivered + ? { delivered: true } + : { delivered: false, reason: "no-listener" }; +} + +let listenersForWatch = 0; + +export function closeChatFirstSessionWatch(): void { + chatFirstSessionWatchStore.close(); +} + +function readChatFirstSurfaceTabsState( + scope: string, +): ChatFirstSurfaceTabsState { + const storage = browserStorage(); + if (!storage) return { tabs: [], activeTabId: null }; + try { + const raw = storage.getItem(CHAT_FIRST_SURFACE_TABS_STORAGE_KEY); + if (!raw) return { tabs: [], activeTabId: null }; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") { + return { tabs: [], activeTabId: null }; + } + const scoped = (parsed as Record)[scope]; + if (!scoped || typeof scoped !== "object") { + return { tabs: [], activeTabId: null }; + } + const value = scoped as Record; + const tabs = Array.isArray(value.tabs) + ? value.tabs.flatMap((candidate) => { + const tab = normalizePersistedChatFirstSurfaceTab(candidate); + return tab ? [tab] : []; + }) + : []; + const activeTabId = + typeof value.activeTabId === "string" && + tabs.some((tab) => tab.id === value.activeTabId) + ? value.activeTabId + : (tabs[0]?.id ?? null); + return { tabs, activeTabId }; + } catch { + return { tabs: [], activeTabId: null }; + } +} + +function normalizePersistedChatFirstSurfaceTab( + candidate: unknown, +): ChatFirstSurfaceTab | null { + if (!candidate || typeof candidate !== "object") return null; + const value = candidate as Record; + const id = typeof value.id === "string" ? value.id.trim() : ""; + const title = typeof value.title === "string" ? value.title.trim() : ""; + const kind = value.kind; + if ( + !id || + !title || + typeof kind !== "string" || + !CHAT_FIRST_SURFACE_KINDS.includes(kind as ChatFirstSurfaceKind) + ) { + return null; + } + + const tab = value as unknown as ChatFirstSurfaceTab; + if (kind === "app") { + const appId = typeof value.appId === "string" ? value.appId.trim() : ""; + if (!appId) return null; + if ( + value.path !== undefined && + (typeof value.path !== "string" || normalizedPath(value.path) === null) + ) { + return null; + } + return { ...tab, id, title, appId }; + } + if (kind === "browser") { + const url = typeof value.url === "string" ? value.url.trim() : ""; + const parsed = parseAbsoluteHttpUrl(url); + if (!parsed || parsed.username || parsed.password) return null; + return { ...tab, id, title, url: parsed.href }; + } + if (kind === "side-chat") { + const session = normalizeChatFirstSessionReference(value.session); + return session ? { ...tab, id, title, session } : null; + } + return { ...tab, id, title, kind: kind as ChatFirstSurfaceKind }; +} + +function writeChatFirstSurfaceTabsState( + scope: string, + state: ChatFirstSurfaceTabsState, +): void { + const storage = browserStorage(); + if (!storage) return; + try { + const raw = storage.getItem(CHAT_FIRST_SURFACE_TABS_STORAGE_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : {}; + const all = + parsed && typeof parsed === "object" + ? (parsed as Record) + : {}; + storage.setItem( + CHAT_FIRST_SURFACE_TABS_STORAGE_KEY, + JSON.stringify({ ...all, [scope]: state }), + ); + // coercion-ok: device-local tab restoration failure leaves the live tab store usable. + } catch { + // Device-local tab restoration is optional; the live store remains usable. + } +} + +function createChatFirstSurfaceTabsStore( + scope: string, +): ChatFirstSurfaceTabsStore { + let state = readChatFirstSurfaceTabsState(scope); + const listeners = new Set<() => void>(); + const publish = (next: ChatFirstSurfaceTabsState) => { + state = next; + writeChatFirstSurfaceTabsState(scope, state); + listeners.forEach((listener) => listener()); + }; + const activeIndex = (tabId: string) => + state.tabs.findIndex((tab) => tab.id === tabId); + + return { + getSnapshot: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + open: (tab) => { + const existingIndex = activeIndex(tab.id); + if (existingIndex >= 0) { + const tabs = state.tabs.map((current, index) => + index === existingIndex ? tab : current, + ); + publish({ tabs, activeTabId: tab.id }); + return; + } + publish({ + tabs: [...state.tabs, tab], + activeTabId: tab.id, + }); + }, + activate: (tabId) => { + if (activeIndex(tabId) < 0 || state.activeTabId === tabId) return; + publish({ ...state, activeTabId: tabId }); + }, + close: (tabId) => { + const index = activeIndex(tabId); + if (index < 0) return; + const tabs = state.tabs.filter((tab) => tab.id !== tabId); + const activeTabId = + state.activeTabId !== tabId + ? state.activeTabId + : (tabs[index - 1]?.id ?? tabs[index]?.id ?? null); + publish({ tabs, activeTabId }); + }, + closeOthers: (tabId) => { + if (activeIndex(tabId) < 0) return; + const tab = state.tabs.find((candidate) => candidate.id === tabId); + if (!tab) return; + publish({ tabs: [tab], activeTabId: tab.id }); + }, + closeToRight: (tabId) => { + const index = activeIndex(tabId); + if (index < 0) return; + const tabs = state.tabs.slice(0, index + 1); + const activeTabId = tabs.some((tab) => tab.id === state.activeTabId) + ? state.activeTabId + : (tabs[index]?.id ?? null); + publish({ tabs, activeTabId }); + }, + closeAll: () => publish({ tabs: [], activeTabId: null }), + }; +} + +const chatFirstSurfaceTabsStores = new Map(); + +function normalizedSurfaceScope(scope: string): string { + const value = scope.trim(); + return value || "default"; +} + +export function getChatFirstSurfaceTabsStore( + scope = "default", +): ChatFirstSurfaceTabsStore { + const key = normalizedSurfaceScope(scope); + const existing = chatFirstSurfaceTabsStores.get(key); + if (existing) return existing; + const store = createChatFirstSurfaceTabsStore(key); + chatFirstSurfaceTabsStores.set(key, store); + return store; +} + +export function useChatFirstSurfaceTabs( + scope = "default", +): ChatFirstSurfaceTabsState { + const store = getChatFirstSurfaceTabsStore(scope); + return useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ); +} + +export function subscribeChatFirstOpenApp( + listener: ChatFirstOpenAppListener, +): () => void { + if (typeof window === "undefined") return () => undefined; + const subscription = Symbol("chat-first-open-app"); + const handleEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail && typeof detail === "object") listener(detail); + }; + chatFirstOpenAppListeners.add(subscription); + window.addEventListener(CHAT_FIRST_OPEN_APP_EVENT, handleEvent); + return () => { + chatFirstOpenAppListeners.delete(subscription); + window.removeEventListener(CHAT_FIRST_OPEN_APP_EVENT, handleEvent); + }; +} + +export function emitChatFirstOpenApp( + detail: ChatFirstOpenAppDetail, +): ChatFirstOpenAppDelivery { + if (!detail.app && !detail.url && !detail.path && !detail.view) { + return { delivered: false, reason: "empty-detail" }; + } + if (typeof window === "undefined") { + return { delivered: false, reason: "no-listener" }; + } + const delivered = chatFirstOpenAppListeners.size > 0; + window.dispatchEvent( + new CustomEvent(CHAT_FIRST_OPEN_APP_EVENT, { + detail, + }), + ); + return delivered + ? { delivered: true } + : { delivered: false, reason: "no-listener" }; +} + +export function subscribeChatFirstOpenBrowser( + listener: ChatFirstOpenBrowserListener, +): () => void { + if (typeof window === "undefined") return () => undefined; + const subscription = Symbol("chat-first-open-browser"); + const handleEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail && typeof detail === "object") listener(detail); + }; + chatFirstOpenBrowserListeners.add(subscription); + window.addEventListener(CHAT_FIRST_OPEN_BROWSER_EVENT, handleEvent); + return () => { + chatFirstOpenBrowserListeners.delete(subscription); + window.removeEventListener(CHAT_FIRST_OPEN_BROWSER_EVENT, handleEvent); + }; +} + +export function emitChatFirstOpenBrowser( + detail: ChatFirstOpenBrowserDetail, +): ChatFirstOpenAppDelivery { + if (!detail.url?.trim()) { + return { delivered: false, reason: "empty-detail" }; + } + if (typeof window === "undefined") { + return { delivered: false, reason: "no-listener" }; + } + const delivered = chatFirstOpenBrowserListeners.size > 0; + window.dispatchEvent( + new CustomEvent(CHAT_FIRST_OPEN_BROWSER_EVENT, { + detail, + }), + ); + return delivered + ? { delivered: true } + : { delivered: false, reason: "no-listener" }; +} + +function normalizedPath(value: string | null | undefined): string | null { + const path = value?.trim(); + if (!path || !path.startsWith("/") || path.startsWith("//")) return null; + if (path.startsWith("/\\") || /[\u0000-\u001f\u007f]/.test(path)) { + return null; + } + if (/^\/[a-z][a-z0-9+.-]*:/i.test(path)) return null; + if (path.split(/[?#]/, 1)[0]?.split("/").includes("..")) return null; + return path; +} + +function parseAbsoluteHttpUrl(value: string): URL | null { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:" ? url : null; + // coercion-ok: invalid URL input is returned as null and rejected by the resolver. + } catch { + return null; + } +} + +function registrationUrls( + app: ChatFirstAppRegistration, + currentOrigin?: string, +): URL[] { + const values = [app.url, app.devUrl]; + if (app.path && currentOrigin) values.push(`${currentOrigin}${app.path}`); + return values.flatMap((value) => { + if (!value) return []; + const absolute = parseAbsoluteHttpUrl(value); + if (absolute) return [absolute]; + if (value.startsWith("/") && currentOrigin) { + const relative = parseAbsoluteHttpUrl(`${currentOrigin}${value}`); + return relative ? [relative] : []; + } + return []; + }); +} + +function appMatchesUrl( + app: ChatFirstAppRegistration, + targetUrl: URL, + currentOrigin?: string, +): URL | null { + for (const registeredUrl of registrationUrls(app, currentOrigin)) { + if (registeredUrl.origin !== targetUrl.origin) continue; + const basePath = registeredUrl.pathname.replace(/\/+$/, "") || "/"; + if ( + basePath !== "/" && + targetUrl.pathname !== basePath && + !targetUrl.pathname.startsWith(`${basePath}/`) + ) { + continue; + } + return registeredUrl; + } + return null; +} + +function appRelativeUrlPath(targetUrl: URL, registeredUrl: URL): string { + const basePath = registeredUrl.pathname.replace(/\/+$/, "") || "/"; + const pathname = + basePath === "/" + ? targetUrl.pathname + : targetUrl.pathname === basePath + ? "/" + : targetUrl.pathname.slice(basePath.length) || "/"; + return `${pathname}${targetUrl.search}${targetUrl.hash}`; +} + +function viewPath(appId: string, view: string): string { + const params = new URLSearchParams({ app: appId, view }); + return `/_agent-native/open?${params.toString()}`; +} + +export function resolveChatFirstAppTarget( + detail: ChatFirstOpenAppDetail, + apps: readonly ChatFirstAppRegistration[], + options: { currentOrigin?: string } = {}, +): ChatFirstAppResolution { + if (!detail.app && !detail.url && !detail.path && !detail.view) { + return { status: "unresolved", reason: "empty-detail" }; + } + + const availableApps = apps.filter((app) => app.enabled !== false); + const requestedId = detail.app?.trim().toLowerCase(); + let app = requestedId + ? availableApps.find( + (candidate) => + candidate.id.toLowerCase() === requestedId || + candidate.name?.trim().toLowerCase() === requestedId, + ) + : undefined; + + const rawUrl = detail.url?.trim(); + let targetUrl: URL | null = null; + if (rawUrl) { + targetUrl = parseAbsoluteHttpUrl(rawUrl); + if (!targetUrl) { + const relativePath = normalizedPath(rawUrl); + if (!app || !relativePath) { + return { status: "unresolved", reason: "invalid-url" }; + } + } + } + + if (targetUrl) { + if (app) { + const registeredUrl = appMatchesUrl( + app, + targetUrl, + options.currentOrigin, + ); + if (!registeredUrl) { + return { status: "unresolved", reason: "invalid-url" }; + } + } else { + const matchingApp = availableApps + .map((candidate) => ({ + app: candidate, + registeredUrl: appMatchesUrl( + candidate, + targetUrl as URL, + options.currentOrigin, + ), + })) + .find((candidate) => candidate.registeredUrl); + if (!matchingApp) { + return { status: "unresolved", reason: "unknown-app" }; + } + app = matchingApp.app; + } + } + + if (!app) return { status: "unresolved", reason: "unknown-app" }; + + let path: string | undefined; + if (targetUrl) { + const registeredUrl = appMatchesUrl(app, targetUrl, options.currentOrigin); + if (!registeredUrl) { + return { status: "unresolved", reason: "invalid-url" }; + } + path = appRelativeUrlPath(targetUrl, registeredUrl); + } else if (rawUrl) { + path = normalizedPath(rawUrl) ?? undefined; + } else if (detail.path) { + path = normalizedPath(detail.path) ?? undefined; + if (!path) return { status: "unresolved", reason: "invalid-url" }; + } else if (detail.view?.trim()) { + path = viewPath(app.id, detail.view.trim()); + } + + return { + status: "ready", + target: { + appId: app.id, + ...(path ? { path } : {}), + ...(detail.view?.trim() ? { view: detail.view.trim() } : {}), + }, + }; +} diff --git a/packages/core/src/client/chat-first/agents-pane.tsx b/packages/core/src/client/chat-first/agents-pane.tsx new file mode 100644 index 0000000000..21c7e778e5 --- /dev/null +++ b/packages/core/src/client/chat-first/agents-pane.tsx @@ -0,0 +1,29 @@ +import { ChatFirstAgentActivityPanel } from "../chat-first-agent-activity.js"; +import { defaultChatFirstCopy } from "./copy.js"; +import type { ChatFirstAgentsPaneProps } from "./types.js"; + +export function ChatFirstAgentsPane({ + activities, + loading = false, + error, + onRefresh, + onWatch, + copy = defaultChatFirstCopy, +}: ChatFirstAgentsPaneProps) { + return ( +
+ +
+ ); +} diff --git a/packages/core/src/client/chat-first/app-pane.tsx b/packages/core/src/client/chat-first/app-pane.tsx new file mode 100644 index 0000000000..7b835e7af7 --- /dev/null +++ b/packages/core/src/client/chat-first/app-pane.tsx @@ -0,0 +1,53 @@ +import { Skeleton } from "@agent-native/toolkit/ui"; + +import { defaultChatFirstCopy } from "./copy.js"; +import type { ChatFirstAppPaneProps } from "./types.js"; + +export function ChatFirstAppPane({ + app, + status, + embedUrl, + errorMessage, + onRetry, + renderEmbed, + copy = defaultChatFirstCopy, +}: ChatFirstAppPaneProps) { + return ( +
+ {status === "loading" ? ( +
+ + + +
+ ) : status === "ready" && app && embedUrl ? ( +
+ {renderEmbed({ url: embedUrl, title: app.name })} +
+ ) : status === "error" ? ( +
+

+ {errorMessage || copy("appUnavailable")} +

+ {onRetry ? ( + + ) : null} +
+ ) : ( +
+ {copy("appUnavailable")} +
+ )} +
+ ); +} diff --git a/packages/core/src/client/chat-first/apps-rail.tsx b/packages/core/src/client/chat-first/apps-rail.tsx new file mode 100644 index 0000000000..14da07088e --- /dev/null +++ b/packages/core/src/client/chat-first/apps-rail.tsx @@ -0,0 +1,387 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, + Skeleton, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@agent-native/toolkit/ui"; +import { + IconChevronDown, + IconChevronUp, + IconPin, + IconPlus, +} from "@tabler/icons-react"; +import { useMemo, useState, type ReactNode } from "react"; + +import { + orderChatFirstAppIds, + readChatFirstAppLayout, + writeChatFirstAppLayout, + type ChatFirstAppLayoutPreference, +} from "../chat-first.js"; +import { cn } from "../utils.js"; +import { defaultChatFirstCopy } from "./copy.js"; +import type { + ChatFirstAppItem, + ChatFirstAppRailProps, + ChatFirstCopy, +} from "./types.js"; + +function AppRows({ + apps, + activeAppId, + layout, + onDragStart, + onDrop, + onDragEnd, + onOpenApp, + onTogglePinned, + onMove, + renderIcon, + copy, +}: { + apps: ChatFirstAppItem[]; + activeAppId?: string; + layout: ChatFirstAppLayoutPreference; + onDragStart: (id: string) => void; + onDrop: (id: string) => void; + onDragEnd: () => void; + onOpenApp: (app: ChatFirstAppItem) => void; + onTogglePinned: (id: string) => void; + onMove: (id: string, direction: -1 | 1) => void; + renderIcon: (app: ChatFirstAppItem) => ReactNode; + copy: ChatFirstCopy; +}) { + const orderedIds = orderChatFirstAppIds( + apps.map((app) => app.id), + layout, + ); + const appsById = new Map(apps.map((app) => [app.id, app])); + const orderedApps = orderedIds + .map((id) => appsById.get(id)) + .filter((app): app is ChatFirstAppItem => Boolean(app)); + + return ( +
    + {orderedApps.map((app) => { + const active = activeAppId === app.id; + const pinned = layout.pinnedIds.includes(app.id); + const index = orderedApps.indexOf(app); + return ( + + +
  • { + event.dataTransfer.effectAllowed = "move"; + onDragStart(app.id); + }} + onDragOver={(event) => event.preventDefault()} + onDrop={() => onDrop(app.id)} + onDragEnd={onDragEnd} + > + + + + {`${index + 1} of ${orderedApps.length}`} + +
  • +
    + + onTogglePinned(app.id)}> + + + onMove(app.id, -1)} + > + + onMove(app.id, 1)} + > + + +
    + ); + })} +
+ ); +} + +export function ChatFirstAppsRail({ + apps, + activeAppId, + loading = false, + error, + collapsed = false, + layout: controlledLayout, + onLayoutChange, + onLayoutError, + onRetry, + onOpenApp, + onCreateApp, + createAppTrigger, + renderIcon, + copy = defaultChatFirstCopy, +}: ChatFirstAppRailProps) { + const [localLayout, setLocalLayout] = useState( + () => readChatFirstAppLayout(), + ); + const layout = controlledLayout ?? localLayout; + const [draggedAppId, setDraggedAppId] = useState(null); + const [showAllApps, setShowAllApps] = useState(false); + const orderedApps = useMemo(() => { + const orderedIds = orderChatFirstAppIds( + apps.map((app) => app.id), + layout, + ); + const appsById = new Map(apps.map((app) => [app.id, app])); + return orderedIds + .map((id) => appsById.get(id)) + .filter((app): app is ChatFirstAppItem => Boolean(app)); + }, [apps, layout]); + const visibleApps = showAllApps ? orderedApps : orderedApps.slice(0, 5); + const hasMoreApps = orderedApps.length > visibleApps.length; + const createTrigger = + createAppTrigger ?? + (onCreateApp ? ( + + ) : null); + + function persistLayout(next: ChatFirstAppLayoutPreference) { + setLocalLayout(next); + onLayoutChange?.(next); + const result = writeChatFirstAppLayout(next); + if (!result.ok) onLayoutError?.(result.reason); + } + + function togglePinned(appId: string) { + const pinnedIds = layout.pinnedIds.includes(appId) + ? layout.pinnedIds.filter((id) => id !== appId) + : [appId, ...layout.pinnedIds]; + persistLayout({ ...layout, pinnedIds }); + } + + function reorderApps(targetId: string) { + if (!draggedAppId || draggedAppId === targetId) return; + const currentOrder = orderChatFirstAppIds( + apps.map((app) => app.id), + layout, + ); + const fromIndex = currentOrder.indexOf(draggedAppId); + const toIndex = currentOrder.indexOf(targetId); + if (fromIndex < 0 || toIndex < 0) return; + const nextOrder = [...currentOrder]; + nextOrder.splice(fromIndex, 1); + nextOrder.splice(toIndex, 0, draggedAppId); + persistLayout({ ...layout, orderedIds: nextOrder }); + setDraggedAppId(null); + } + + function moveApp(appId: string, direction: -1 | 1) { + const currentOrder = orderChatFirstAppIds( + apps.map((app) => app.id), + layout, + ); + const index = currentOrder.indexOf(appId); + const nextIndex = index + direction; + if (index < 0 || nextIndex < 0 || nextIndex >= currentOrder.length) return; + const nextOrder = [...currentOrder]; + [nextOrder[index], nextOrder[nextIndex]] = [ + nextOrder[nextIndex], + nextOrder[index], + ]; + persistLayout({ ...layout, orderedIds: nextOrder }); + } + + if (collapsed) { + return ( +
+ {createTrigger} + {loading && apps.length === 0 + ? [0, 1, 2].map((index) => ( + + )) + : visibleApps.map((app) => ( + + ))} + {error ? ( + + ) : null} +
+ ); + } + + return ( +
+
+ {copy("workspaceApps")} + {createTrigger} +
+ {loading && apps.length === 0 ? ( +
+ {[0, 1, 2].map((index) => ( + + ))} +
+ ) : apps.length === 0 ? ( +
+

+ {copy("noWorkspaceApps")} +

+ {createAppTrigger ? ( + createTrigger + ) : onCreateApp ? ( + + ) : ( + createTrigger + )} +
+ ) : ( + setDraggedAppId(null)} + onOpenApp={onOpenApp} + onTogglePinned={togglePinned} + onMove={moveApp} + renderIcon={renderIcon} + copy={copy} + /> + )} + {hasMoreApps || showAllApps ? ( + + ) : null} + {error ? ( + + + + + {error} + + ) : null} +
+ ); +} diff --git a/packages/core/src/client/chat-first/browser-pane.tsx b/packages/core/src/client/chat-first/browser-pane.tsx new file mode 100644 index 0000000000..0bb6008209 --- /dev/null +++ b/packages/core/src/client/chat-first/browser-pane.tsx @@ -0,0 +1,199 @@ +import { + IconArrowLeft, + IconArrowRight, + IconExternalLink, + IconRefresh, + IconWorld, + IconX, +} from "@tabler/icons-react"; +import { useEffect, useState, type FormEvent } from "react"; + +import { resolveChatFirstBrowserTarget } from "../chat-first.js"; +import { defaultChatFirstCopy } from "./copy.js"; +import type { ChatFirstBrowserPaneProps } from "./types.js"; + +export function ChatFirstBrowserPane({ + url, + title, + status, + statusMessage, + onClose, + renderEmbed, + copy = defaultChatFirstCopy, +}: ChatFirstBrowserPaneProps) { + const [currentUrl, setCurrentUrl] = useState(url); + const [draftUrl, setDraftUrl] = useState(url); + const [reloadKey, setReloadKey] = useState(0); + const [history, setHistory] = useState([url]); + const [historyIndex, setHistoryIndex] = useState(0); + const [navigationError, setNavigationError] = useState(null); + + useEffect(() => { + setCurrentUrl(url); + setDraftUrl(url); + setHistory([url]); + setHistoryIndex(0); + setNavigationError(null); + }, [url]); + + function navigate(event: FormEvent) { + event.preventDefault(); + const target = resolveChatFirstBrowserTarget({ url: draftUrl }); + if (target.status !== "ready") { + setNavigationError(copy("browserInvalidUrl")); + return; + } + const nextUrl = target.target.url; + setNavigationError(null); + setCurrentUrl(nextUrl); + setDraftUrl(nextUrl); + setHistory((current) => [...current.slice(0, historyIndex + 1), nextUrl]); + setHistoryIndex((index) => index + 1); + setReloadKey((key) => key + 1); + } + + function goBack() { + if (historyIndex <= 0) return; + const nextIndex = historyIndex - 1; + const nextUrl = history[nextIndex]; + setHistoryIndex(nextIndex); + setCurrentUrl(nextUrl); + setDraftUrl(nextUrl); + setNavigationError(null); + setReloadKey((key) => key + 1); + } + + function goForward() { + if (historyIndex >= history.length - 1) return; + const nextIndex = historyIndex + 1; + const nextUrl = history[nextIndex]; + setHistoryIndex(nextIndex); + setCurrentUrl(nextUrl); + setDraftUrl(nextUrl); + setNavigationError(null); + setReloadKey((key) => key + 1); + } + + return ( +
+
+
+ + + +
+
+
+ {navigationError ? ( +

+ {navigationError} +

+ ) : null} +
+ {status === "starting" || status === "error" ? ( +
+
+

+ {status === "starting" + ? copy("browserPreviewStarting") + : copy("browserPreviewError")} +

+ {statusMessage ? ( +

{statusMessage}

+ ) : null} +
+
+ ) : ( + renderEmbed({ + url: currentUrl, + title: title || copy("browserPage"), + key: `${currentUrl}:${reloadKey}`, + }) + )} +
+
+ ); +} diff --git a/packages/core/src/client/chat-first/chat-history.tsx b/packages/core/src/client/chat-first/chat-history.tsx new file mode 100644 index 0000000000..9ff2f17860 --- /dev/null +++ b/packages/core/src/client/chat-first/chat-history.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from "react"; + +import { + ChatHistoryList, + type ChatHistoryItem, + type ChatHistoryListProps, +} from "../chat/ChatHistoryList.js"; + +export interface ChatFirstChatHistoryProps extends Omit< + ChatHistoryListProps, + "items" | "sections" | "variant" +> { + items: ChatHistoryItem[]; + label?: ReactNode; +} + +/** + * The single chat-list presentation used by the chat-first rail on every + * host. Hosts still own thread fetching and actions, but row rhythm and the + * Chats section treatment stay identical. + */ +export function ChatFirstChatHistory({ + items, + label = "Chats", + className, + ...props +}: ChatFirstChatHistoryProps) { + return ( +
+

+ {label} +

+ +
+ ); +} diff --git a/packages/core/src/client/chat-first/copy.spec.ts b/packages/core/src/client/chat-first/copy.spec.ts new file mode 100644 index 0000000000..1c6c8ded70 --- /dev/null +++ b/packages/core/src/client/chat-first/copy.spec.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +import { defaultChatFirstCopy } from "./copy.js"; + +describe("default chat-first copy", () => { + it("provides labels used by the primary navigation", () => { + expect(defaultChatFirstCopy("newChat")).toBe("New chat"); + expect(defaultChatFirstCopy("search")).toBe("Search"); + }); +}); diff --git a/packages/core/src/client/chat-first/copy.ts b/packages/core/src/client/chat-first/copy.ts new file mode 100644 index 0000000000..8ab290de0c --- /dev/null +++ b/packages/core/src/client/chat-first/copy.ts @@ -0,0 +1,128 @@ +import type { ChatFirstSurfaceKind } from "../chat-first.js"; +import type { ChatFirstCopy } from "./types.js"; + +const DEFAULT_COPY: Record = { + newChat: "New chat", + search: "Search", + workspaceApps: "Apps", + integrations: "Integrations", + scheduled: "Scheduled", + createWorkspaceApp: "Create app", + openApp: "Open {name}", + appsLoadError: "Apps could not be loaded", + noWorkspaceApps: "No apps yet", + createApp: "Create app", + retry: "Retry", + dismiss: "Dismiss", + unpinApp: "Unpin {name}", + pinApp: "Pin {name}", + removePinned: "Remove from pinned apps", + pinTop: "Pin app to the top", + moveUp: "Move up", + moveDown: "Move down", + showMore: "Show more", + showLess: "Show less", + appOrderUnavailable: "App order could not be saved on this device.", + openSideSurfaces: "Open side surfaces", + sideSurfaces: "Side surfaces", + resizeSideSurface: "Resize side surface", + closeTab: "Close {name}", + close: "Close", + closeOthers: "Close others", + closeToRight: "Close to the right", + closeAll: "Close all", + deferred: "Deferred", + openActivity: "Open activity", + unavailable: "This surface is not available here.", + browserBack: "Back", + browserForward: "Forward", + browserReload: "Reload page", + browserAddress: "Browser address", + browserOpenExternal: "Open in external browser", + browserClose: "Close browser", + browserPage: "Browser page", + browserInvalidUrl: "Enter a full http(s) URL to navigate.", + browserPreviewStarting: "Starting preview…", + browserPreviewError: "Preview unavailable", + appUnavailable: "This workspace app is no longer available.", + appLoading: "Loading app", + agentActivityEyebrow: "Workspace", + agentActivityTitle: "Agent activity", + agentActivityDescription: + "Follow runs and open a second session beside this chat.", + refreshAgentActivity: "Refresh agent activity", + noAgentSessions: "No agent sessions yet", + startAgentSession: "Start an agent run and it will appear here.", + watchSession: "Watch", + copySessionId: "Copy session ID", + copySessionIdFor: "Copy session ID for {name}", + sessionIdCopied: "Session ID copied", + sessionIdShort: "ID", + copied: "Copied", + agentActivityStatusQueued: "Queued", + agentActivityStatusRunning: "Running", + agentActivityStatusPaused: "Paused", + agentActivityStatusNeedsApproval: "Needs approval", + agentActivityStatusCompleted: "Completed", + agentActivityStatusErrored: "Needs attention", + agentActivityStatusRecent: "Recent", + agentActivityStatusUnknown: "Unknown", + watchingSession: "Watching {name}", + session: "session", + stopWatchingSession: "Stop watching session", + stopWatching: "Stop watching", + watchedSession: "Watched session", + agentsTitle: "Subagents", +}; + +const SURFACE_COPY: Record< + ChatFirstSurfaceKind, + { label: string; reason: string } +> = { + app: { + label: "App", + reason: "Open a workspace app beside the conversation.", + }, + browser: { + label: "Browser", + reason: "Ask the agent to open a web URL.", + }, + terminal: { + label: "Terminal", + reason: + "Deferred: an embedded PTY lifecycle is not connected in this release.", + }, + files: { + label: "Files", + reason: + "Deferred: a bounded workspace tree is not connected in this release.", + }, + diff: { + label: "Diff", + reason: "Deferred: changed-file data is not connected in this release.", + }, + "side-chat": { + label: "Side chat", + reason: "Choose Watch and message from any chat row.", + }, + agents: { + label: "Agents", + reason: "Review recent runs or watch one beside this conversation.", + }, +}; + +function interpolate(value: string, values?: Record): string { + if (!values) return value; + return value.replace(/\{(\w+)\}/g, (_, key: string) => values[key] ?? ""); +} + +export const defaultChatFirstCopy: ChatFirstCopy = (key, values) => { + if (key.startsWith("surface.")) { + const [, kind, field] = key.split("."); + const surface = SURFACE_COPY[kind as ChatFirstSurfaceKind]; + if (surface && (field === "label" || field === "reason")) { + return surface[field]; + } + } + return interpolate(DEFAULT_COPY[key] ?? key, values); +}; diff --git a/packages/core/src/client/chat-first/index.ts b/packages/core/src/client/chat-first/index.ts new file mode 100644 index 0000000000..dee5381f6e --- /dev/null +++ b/packages/core/src/client/chat-first/index.ts @@ -0,0 +1,44 @@ +export { ChatFirstAgentsPane } from "./agents-pane.js"; +export { ChatFirstAppPane } from "./app-pane.js"; +export { ChatFirstAppsRail } from "./apps-rail.js"; +export { ChatFirstBrowserPane } from "./browser-pane.js"; +export { defaultChatFirstCopy } from "./copy.js"; +export { + ChatFirstChatHistory, + type ChatFirstChatHistoryProps, +} from "./chat-history.js"; +export { ChatFirstSessionWatchPane } from "./session-watch-pane.js"; +export { + ChatFirstSurfacePanel, + type ChatFirstSurfacePanelProps, +} from "./surface-panel.js"; +export { ChatFirstPrimaryNavigation } from "./primary-nav.js"; +export { + emitChatFirstOpenApp, + resolveChatFirstAppTarget, + CHAT_FIRST_DEFAULT_APP_IDS, + type ChatFirstOpenAppDetail, +} from "../chat-first.js"; +export { + buildChatFirstAppCreationPrompt, + titleFromChatFirstAppPrompt, + type ChatFirstAppCreationPromptInput, + type ChatFirstAppCreationResource, + type ChatFirstAppCreationVaultAccessMode, +} from "../../shared/chat-first-app-creation.js"; +export { + ChatFirstSurfaceContent, + ChatFirstSurfaceTabs, +} from "./surface-tabs.js"; +export type { + ChatFirstAgentActivity, + ChatFirstAgentsPaneProps, + ChatFirstAppItem, + ChatFirstAppPaneProps, + ChatFirstAppRailProps, + ChatFirstBrowserPaneProps, + ChatFirstCopy, + ChatFirstEmbedTarget, + ChatFirstSessionWatchPaneProps, + ChatFirstSurfaceTabsProps, +} from "./types.js"; diff --git a/packages/core/src/client/chat-first/primary-nav.tsx b/packages/core/src/client/chat-first/primary-nav.tsx new file mode 100644 index 0000000000..f898c38dce --- /dev/null +++ b/packages/core/src/client/chat-first/primary-nav.tsx @@ -0,0 +1,64 @@ +import { + IconClock, + IconPlus, + IconPlugConnected, + IconSearch, +} from "@tabler/icons-react"; + +import { defaultChatFirstCopy } from "./copy.js"; +import type { ChatFirstCopy } from "./types.js"; + +export function ChatFirstPrimaryNavigation({ + onNewChat, + onOpenIntegrations, + onOpenScheduled, + onSearch, + copy = defaultChatFirstCopy, +}: { + onNewChat?: () => void; + onOpenIntegrations: () => void; + onOpenScheduled: () => void; + onSearch?: () => void; + copy?: ChatFirstCopy; +}) { + return ( + <> + {onNewChat ? ( + + ) : null} + + + {onSearch ? ( + + ) : null} + + ); +} diff --git a/packages/core/src/client/chat-first/session-watch-pane.tsx b/packages/core/src/client/chat-first/session-watch-pane.tsx new file mode 100644 index 0000000000..fd6f7b3e11 --- /dev/null +++ b/packages/core/src/client/chat-first/session-watch-pane.tsx @@ -0,0 +1,48 @@ +import { IconEye, IconX } from "@tabler/icons-react"; + +import { defaultChatFirstCopy } from "./copy.js"; +import type { ChatFirstSessionWatchPaneProps } from "./types.js"; + +export function ChatFirstSessionWatchPane({ + target, + onClose, + renderChat, + copy = defaultChatFirstCopy, +}: ChatFirstSessionWatchPaneProps) { + if (!target) return null; + const sessionName = target.title || copy("session"); + + return ( +
+
+
+
{renderChat(target)}
+
+ ); +} diff --git a/packages/core/src/client/chat-first/surface-panel.tsx b/packages/core/src/client/chat-first/surface-panel.tsx new file mode 100644 index 0000000000..bdd7fadb51 --- /dev/null +++ b/packages/core/src/client/chat-first/surface-panel.tsx @@ -0,0 +1,43 @@ +import type { CSSProperties, PointerEvent, ReactNode } from "react"; + +import { clampChatFirstSurfaceWidth } from "../chat-first.js"; +import { defaultChatFirstCopy } from "./copy.js"; +import type { ChatFirstCopy } from "./types.js"; + +export interface ChatFirstSurfacePanelProps { + width: number; + onResizePointerDown: (event: PointerEvent) => void; + children: ReactNode; + copy?: ChatFirstCopy; +} + +export function ChatFirstSurfacePanel({ + width, + onResizePointerDown, + children, + copy = defaultChatFirstCopy, +}: ChatFirstSurfacePanelProps) { + const panelWidth = clampChatFirstSurfaceWidth(width); + + return ( + + ); +} diff --git a/packages/core/src/client/chat-first/surface-tabs.tsx b/packages/core/src/client/chat-first/surface-tabs.tsx new file mode 100644 index 0000000000..d99b925eb7 --- /dev/null +++ b/packages/core/src/client/chat-first/surface-tabs.tsx @@ -0,0 +1,262 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@agent-native/toolkit/ui"; +import { + IconFiles, + IconGitCompare, + IconMessageCircle, + IconTerminal2, + IconUsersGroup, + IconWorld, + IconX, +} from "@tabler/icons-react"; +import type { ComponentType, ReactNode } from "react"; + +import { + CHAT_FIRST_SURFACE_CATALOG, + type ChatFirstSurfaceKind, + type ChatFirstSurfaceTab, +} from "../chat-first.js"; +import { cn } from "../utils.js"; +import { defaultChatFirstCopy } from "./copy.js"; +import type { ChatFirstSurfaceTabsProps } from "./types.js"; + +const ICONS: Record< + ChatFirstSurfaceKind, + ComponentType<{ size?: number; className?: string }> +> = { + app: IconFiles, + browser: IconWorld, + terminal: IconTerminal2, + diff: IconGitCompare, + files: IconFiles, + "side-chat": IconMessageCircle, + agents: IconUsersGroup, +}; + +function SurfaceIcon({ kind }: { kind: ChatFirstSurfaceKind }) { + const Icon = ICONS[kind]; + return