diff --git a/.gitignore b/.gitignore
index d0c2ee9e4..84ba09520 100644
--- a/.gitignore
+++ b/.gitignore
@@ -213,5 +213,15 @@ agentkit.yaml
agentkit*.yaml
.agentkit/
+# veadk mpa create config (contains PG/model/OpenViking/Feishu secrets).
+# Only the committed *.example.yaml template is allowed.
+mpa-create.config.yaml
+mpa-create.config*.yaml
+!mpa-create.config.example.yaml
+**/mpa-create.config.yaml
+**/mpa-create.config*.yaml
+!**/mpa-create.config.example.yaml
+
# PiAgent release archives are downloaded and verified during image builds.
examples/piagent_with_mcp/vendor/pi-*.tar.gz
+.gstack/
diff --git a/AGENTS.md b/AGENTS.md
index 68b82a6e0..2045be95e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,9 +1,61 @@
# Agent Instructions
-- Do not use `codex/` as a branch name prefix in this repository. Use semantic branch prefixes such as `feat/`, `fix/`, `chore/`, or `docs/`; for example, `feat/pr-748-dev`.
-- Before modifying frontend code, read `frontend/SPEC.md`.
-- When the user describes a requirement, propose an implementation plan before changing code, and wait for the user to be satisfied before making edits.
-- After creating or switching to a new branch, run `git pull` before development. Branch names must use semantic prefixes and reflect the requirement content.
-- When committing, include only changes related to the current feature or fix.
-- Before committing, fetch the latest remote code, rebase the branch onto it, then run pre-commit and unit tests.
-- When execution hits a pitfall such as insufficient permissions or missing dependencies, ask the user whether to document that pitfall.
+VeADK includes a Python SDK, CLI/runtime integrations, and AgentKit Studio with a TypeScript/React frontend and supporting Python services. Base decisions on the relevant implementation, tests, and existing contracts rather than assumptions from another repository.
+
+## Language and document ownership
+
+- Keep this repository-wide development standard in English. Use English for new code identifiers, comments, and docstrings; user-facing text follows the owning area's localization rules.
+- Read [prd-spec/README.md](prd-spec/README.md) before creating or updating a change design. Every PRD/design must have complete English `.md` and Chinese `.zh.md` counterparts, sharing the same directory, date, and basename.
+- Read [specs/README.md](specs/README.md) before defining or changing a component contract. Maintain each component under `specs//README.md` and `README.zh.md`.
+- Keep both languages semantically equivalent and update them in the same change. Preserve identifiers, commands, examples, limits, and acceptance criteria across translations. Missing or conflicting counterparts block approval and delivery.
+- PRDs own change-specific intent, scope, tasks, and acceptance; component specs own maintained responsibilities, interfaces, state, data, and failure semantics. Link them rather than duplicate contracts.
+- Before modifying frontend code, read `frontend/SPEC.md`; also apply it to its supporting backend interfaces as specified there. It remains the Studio development standard and is not replaced or translated by this document.
+- Preserve existing user-documentation conventions: the documentation site uses Chinese `.mdx` and English `.en.mdx`; bilingual module/example READMEs use English `README.md` and Chinese `README.zh.md`.
+
+## Required development workflow
+
+1. **Inspect and agree on scope.** Read the affected implementation, tests, and contracts. When the user describes a requirement, propose an implementation plan and wait for the user to be satisfied before making edits. Distinguish verified facts from assumptions.
+2. **Design before implementation.** Features, refactors, and bugfixes require a bilingual PRD under `prd-spec/features/`, `prd-spec/refactors/`, or `prd-spec/bugfixes/`. Include background/evidence, goals, non-goals, scenarios, requirements, design, affected files, tasks, tests, risks, and acceptance criteria.
+3. **Assess component contracts.** Create or update affected bilingual component specs for changes to ownership, APIs, configuration, state/data, events, permissions, security, compatibility, runtime behavior, or observability. Record a justified no-impact conclusion when no component contract changes. Do not backfill unrelated components.
+4. **Clear design review.** Review PRD/spec consistency, feasibility, boundaries, errors, security, compatibility, testability, and bilingual equivalence. Use the available `review-spec` skill; if unavailable, perform and record the same review directly. Resolve blockers and record user approval before modifying production code or tests. A generic skill's Chinese-only default does not override this repository's bilingual rule.
+5. **Implement against the approved design.** Map changes to requirements/tasks, write a failing regression or contract test first, then implement and refactor within scope. Contract or scope changes return to design review before implementation proceeds.
+6. **Verify and review.** Run affected tests first, then required repository and component gates. Review both implementation correctness and edge cases, resource handling, security, test quality, and contract alignment. Fix blocking findings and rerun affected checks.
+7. **Reconcile and deliver.** Synchronize both document languages, component contracts, affected user docs/examples, and required generated artifacts. Record actual verification commands, results, unverified areas, and remaining risks. All agreed tasks and acceptance criteria must be satisfied before declaring completion; deferred scope requires user agreement.
+
+Pure documentation, translation, or formatting changes that preserve runtime behavior and public contracts do not need a recursive PRD. They still require an agreed approach, applicable bilingual updates, and proportionate checks. This exception includes maintaining these development guidelines; it does not exempt code or behavioral changes.
+
+## Implementation boundaries
+
+- Make the smallest complete change that satisfies the approved requirement. Reuse existing modules and dependencies; avoid unrelated cleanup, speculative abstractions, and partially wired features.
+- Treat public Python imports/signatures, CLI/configuration behavior, generated projects, Studio API types, and runtime/event contracts as consumer-facing boundaries. Breaking changes require explicit approval and a plan for affected callers and data; do not copy another project's blanket no-compatibility policy.
+- Cover asynchronous cancellation, timeouts, late responses/events, terminal states, and resource cleanup where affected. Do not turn errors into silent success or empty results.
+- Keep real credentials, signed URLs, personal data, and unredacted production logs out of source, design documents, reports, test fixtures, and generated artifacts.
+- Isolate tests from real user state and external services by default. Real cloud/provider operations need explicit authorization and an isolated smoke/E2E procedure; report simulated and live verification separately.
+
+## Verification gates
+
+Use the repository-local Python environment and current `pyproject.toml`, `pytest.ini`, package scripts, and CI workflows as command references. Do not invent `make` targets, copy another repository's coverage thresholds, or treat an unavailable check as passing.
+
+| Change area | Required verification |
+| --- | --- |
+| Python SDK, CLI, and backend | Targeted tests via `uv run --extra dev pytest `; broader regression tests when shared contracts or dependencies are affected. |
+| Default parallel Python regression | `uv run --extra dev pytest -n 2 -m "not codex_smoke and not piagent_smoke"`; adjust worker count to available resources and record the selection. Explicit smoke runs are separate. |
+| Codex runtime smoke | When affected and the environment is prepared: `CODEX_RUN_SMOKE=1 uv run --extra dev pytest -m codex_smoke -p no:xdist -rs`. This test starts real processes and binds ports; never run it under parallel pytest. Verify it actually ran rather than skipped. |
+| Studio frontend | `npm --prefix frontend test` and `npm --prefix frontend run build`, plus real browser checks of the normal flow and affected loading, empty, error, cancellation, retry, keyboard/IME, and narrow-window cases. Follow `frontend/SPEC.md` for additional checks and release asset requirements. |
+| Studio backend or generated Python | Targeted Python tests plus Ruff and Pyright for changed Python files, as required by `frontend/SPEC.md`; verify generated code, dependencies, configuration, and deployment payloads together. |
+| Harness/sidecar contracts | The affected checks and existing coverage thresholds in `.github/workflows/harness-sidecar-release-gate.yaml`, including `npm --prefix frontend run test:harness-sidecar-coverage` when frontend sidecar contracts change. |
+| PRD/spec and guideline-only changes | Check paired-language completeness, contract/identifier consistency, relative links, referenced paths/commands, and diff whitespace. No runtime test result may be claimed from documentation checks. |
+| User documentation site | The affected Markdown/MDX checks and build/type checks defined in `docs/package.json`; do not apply site-specific tooling to unrelated plain Markdown without checking its scope. |
+
+- Before committing, run `uv run --extra dev pre-commit run --all-files` and unit tests after synchronizing the branch as described below. Pre-commit includes Ruff and secret scanning; it does not replace browser, integration, or runtime verification.
+- Prefer targeted checks during iteration. Expand to affected integration/E2E and regression gates for shared or high-risk changes and final delivery; explain any omitted required check.
+- Record check outcomes as `pass`, `fail`, `blocked`, `not_run`, or `not_applicable`, with reasons for the last three. Include the tested revision/diff scope and execution date. Skipped tests do not prove the relevant behavior.
+- Keep change-specific review and verification summaries in the bilingual PRD by default. Separate evidence artifacts are optional when useful; OpenSpec and a multi-report hierarchy are not prerequisites.
+
+## Git and execution safety
+
+- Do not use `codex/` as a branch name prefix. Use semantic prefixes such as `feat/`, `fix/`, `chore/`, or `docs/`, with a name reflecting the requirement.
+- After creating or switching to a new branch, run `git pull` before development. If no upstream exists or synchronization fails, resolve the target explicitly rather than guessing or bypassing the requirement.
+- Before committing, fetch the latest remote code, rebase the branch onto the intended remote base, then run pre-commit and unit tests. Preserve unrelated user changes and stop if safe synchronization requires a user decision.
+- Commit only changes related to the current feature or fix. Commit, push, PR creation, publication, and deployment require user authorization; approval of a design does not authorize them.
+- When execution hits a pitfall such as insufficient permissions or missing dependencies, ask the user whether to document that pitfall. Report blocked checks honestly and do not alter machine-global configuration or bypass gates to hide the issue.
diff --git a/frontend/README.md b/frontend/README.md
index b87f3c5c9..93bb29d4c 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -1047,3 +1047,33 @@ Sandbox。修改这些模板只需要更新 Studio,不需要重建镜像,也
模板中的新依赖不会自动安装,运行环境依赖仍由镜像管理。
其他地域需要显式设置 `STUDIO_WORKSPACE_IMAGE`,避免错误使用跨地域镜像。
+
+### MPA Runtime scheduled tasks
+
+Select a connected cloud Runtime and open the Runtime scheduled tasks tab.
+Studio uses gateway authentication to call `/api/v1/esa-cron-tasks`, without TOP
+credential acquisition, enterprise UID configuration, JWT or user identity headers.
+The MPA Runtime must have `DISABLE_JWT_AUTH=true` and support all-user read access
+(commit `833c6bc` or later). Gateway authentication and Studio Runtime authorization
+remain enforced. The operator must enable this mode on the Runtime explicitly;
+Studio never changes the authentication setting.
+
+The list shows all users' tasks in the selected Runtime's configured store, with
+search, pagination, prompt details, execution counts and success rate. Errors are
+not rendered as empty results. No task mutations are performed.
+
+Run `npm run test:mpa-cron-coverage` and
+`python -m pytest tests/frontend/server/test_mpa_cron.py --cov=frontend.server.mpa_cron --cov-branch --cov-fail-under=96`
+for isolated client/server checks.
+
+### Runtime task management (2026-09-15)
+
+Runtime tasks now follow mono's list/calendar workflow: status filtering, creation, editing, copying, deletion, enable/disable, run now, and execution history. Select a cloud Runtime first. The server forwards the authenticated Studio user as `x-user-id`; with MPA JWT disabled, that user's task data remains isolated. No JWT input or TOP credential exchange is used. Enter the Runtime's Agent ID when creating a task. Complex Cron expressions display only the server's next execution in the calendar. Mutations and history use the existing MPA REST interfaces; Runtime upgrades and ADK session authentication are separate concerns.
+
+### A2A 长耗时请求
+
+连接 A2A Runtime 后,对话会显示等待响应、排队或执行中的状态。收到有效状态后会继续等待最终回复,不因任务耗时超过 30 秒而自动中断。等待响应不代表 Runtime 已接受任务;错误或连接中断也不代表后台任务已取消,请先确认任务状态再重试创建等操作。
+
+### Sandbox file downloads
+
+Assistant Markdown links under `/data/output/` or `/data/workspace/` appear as download buttons in conversation history and streaming messages. Studio uses the current Runtime and session through its authenticated proxy; files remain available only while that Sandbox and file exist. Legacy `` and `` payloads are hidden in conversation rendering. A failed download can be retried by clicking the button again. Run `npm run test:sandbox-download-coverage` for the focused regression suite.
diff --git a/frontend/package.json b/frontend/package.json
index 9500923c6..12ae05c85 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,6 +9,8 @@
"build": "node scripts/build.mjs",
"test": "node --test tests/*.test.mjs",
"check:i18n": "node scripts/check-i18n.mjs",
+ "test:sandbox-download-coverage": "vitest run --coverage -c vitest.sandbox-download.config.ts",
+ "test:mpa-cron-coverage": "vitest run --coverage -c vitest.mpa-cron.config.ts",
"test:webui-assets": "node scripts/verifyBuiltAssets.mjs",
"test:visualizations": "node scripts/testVisualizationCompatibility.mjs",
"test:harness-sidecar-coverage": "vitest run --coverage -c vitest.harness-sidecar.config.ts",
diff --git a/frontend/server/mpa_cron.py b/frontend/server/mpa_cron.py
new file mode 100644
index 000000000..720d363aa
--- /dev/null
+++ b/frontend/server/mpa_cron.py
@@ -0,0 +1,182 @@
+"""User-scoped task management through authorized MPA Runtime connections."""
+
+import asyncio
+from collections.abc import Callable
+from typing import Any
+
+import httpx
+from fastapi import FastAPI, HTTPException, Path, Query, Request
+
+
+async def task_request(
+ endpoint: str,
+ authorization: str,
+ user_id: str,
+ method: str,
+ suffix: str = "",
+ params: dict | None = None,
+ payload: dict | None = None,
+) -> dict[str, Any]:
+ if not user_id:
+ raise HTTPException(401, "mpa_identity_required")
+ try:
+ async with httpx.AsyncClient(timeout=30, follow_redirects=False) as client:
+ response = await client.request(
+ method,
+ endpoint.rstrip("/") + "/api/v1/esa-cron-tasks" + suffix,
+ headers={"Authorization": authorization, "x-user-id": user_id},
+ params=params,
+ json=payload,
+ )
+ if not response.is_success:
+ raise HTTPException(
+ response.status_code if response.is_error else 502,
+ "mpa_tasks_failed",
+ )
+ data = response.json()
+ if not isinstance(data, dict):
+ raise TypeError("Invalid task response")
+ return data
+ except (httpx.HTTPError, ValueError, TypeError) as error:
+ raise HTTPException(502, "mpa_upstream_failed") from error
+
+
+def mount_routes(
+ app: FastAPI,
+ *,
+ authorize: Callable,
+ connection: Callable,
+ authorization: Callable,
+ region_for: Callable,
+ user_for: Callable,
+) -> None:
+ async def send(
+ request, runtime_id, region, method, suffix="", params=None, payload=None
+ ):
+ region = region_for(region)
+ runtime = await asyncio.to_thread(authorize, request, runtime_id, region)
+ user_id = user_for(request)
+ endpoint, apikey, auth_type, _ = await asyncio.to_thread(
+ connection, runtime_id, region, runtime
+ )
+ return await task_request(
+ endpoint,
+ authorization(request, apikey, auth_type),
+ user_id,
+ method,
+ suffix,
+ params,
+ payload,
+ )
+
+ async def body(request: Request, allowed: set[str]):
+ raw = await request.body()
+ if len(raw) > 32768:
+ raise HTTPException(413, "mpa_payload_too_large")
+ try:
+ payload = await request.json()
+ except ValueError as error:
+ raise HTTPException(422, "mpa_invalid_payload") from error
+ if not isinstance(payload, dict) or set(payload) - allowed:
+ raise HTTPException(422, "mpa_invalid_payload")
+ return payload
+
+ fields = {
+ "name",
+ "agentId",
+ "prompt",
+ "enabled",
+ "schedule",
+ "delivery",
+ "jitterSeconds",
+ "timeoutSeconds",
+ }
+
+ @app.get("/web/mpa-cron/{runtime_id}")
+ async def get_tasks(
+ request: Request,
+ runtime_id: str,
+ region: str,
+ offset: int = Query(0, ge=0),
+ query: str = Query("", max_length=200),
+ ):
+ return await send(
+ request,
+ runtime_id,
+ region,
+ "GET",
+ params={
+ "includeDisabled": "true",
+ "limit": 20,
+ "offset": offset,
+ "query": query,
+ },
+ )
+
+ @app.post("/web/mpa-cron/{runtime_id}")
+ async def create_task(request: Request, runtime_id: str, region: str):
+ return await send(
+ request,
+ runtime_id,
+ region,
+ "POST",
+ payload=await body(request, fields | {"clientToken"}),
+ )
+
+ @app.post("/web/mpa-cron/{runtime_id}/{task_id}")
+ async def update_task(
+ request: Request,
+ runtime_id: str,
+ region: str,
+ task_id: str = Path(pattern=r"^[A-Za-z0-9_-]+$"),
+ ):
+ return await send(
+ request,
+ runtime_id,
+ region,
+ "POST",
+ "/" + task_id,
+ payload=await body(request, fields | {"expectedVersion"}),
+ )
+
+ @app.delete("/web/mpa-cron/{runtime_id}/{task_id}")
+ async def delete_task(
+ request: Request,
+ runtime_id: str,
+ region: str,
+ task_id: str = Path(pattern=r"^[A-Za-z0-9_-]+$"),
+ ):
+ return await send(request, runtime_id, region, "DELETE", "/" + task_id)
+
+ @app.post("/web/mpa-cron/{runtime_id}/{task_id}/run")
+ async def run_task(
+ request: Request,
+ runtime_id: str,
+ region: str,
+ task_id: str = Path(pattern=r"^[A-Za-z0-9_-]+$"),
+ ):
+ return await send(
+ request,
+ runtime_id,
+ region,
+ "POST",
+ "/" + task_id + "/run",
+ payload=await body(request, {"clientToken", "mode"}),
+ )
+
+ @app.get("/web/mpa-cron/{runtime_id}/{task_id}/runs")
+ async def get_runs(
+ request: Request,
+ runtime_id: str,
+ region: str,
+ task_id: str = Path(pattern=r"^[A-Za-z0-9_-]+$"),
+ offset: int = Query(0, ge=0),
+ ):
+ return await send(
+ request,
+ runtime_id,
+ region,
+ "GET",
+ "/" + task_id + "/runs",
+ params={"offset": offset, "limit": 20},
+ )
diff --git a/frontend/server/runtime_logs.py b/frontend/server/runtime_logs.py
index ec3ce73dd..db53457b7 100644
--- a/frontend/server/runtime_logs.py
+++ b/frontend/server/runtime_logs.py
@@ -277,10 +277,11 @@ async def read_logs(
runtime_types.GetRuntimeInstanceLogsRequest(
RuntimeId=runtime_id,
InstanceName=instance_name,
- Limit=500,
+ Limit=1000,
),
)
- return self._sanitize(str(response.logs or ""))
+ sanitized = self._sanitize(str(response.logs or ""))
+ return "\n".join(sanitized.splitlines()[-1000:])
async def snapshot(
self,
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 875a5d50d..89900c33d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,3 +1,4 @@
+import { SandboxFileContext } from "./ui/SandboxFileLink";
import {
useCallback,
useEffect,
@@ -29,6 +30,7 @@ import {
downloadArtifact,
previewArtifact,
getAgentInfo,
+ getTurnControl,
getAutomaticEvaluationStatuses,
getSessionTrace,
getSession,
@@ -43,6 +45,7 @@ import {
prepareSessionEnvironmentMounts,
runSseIncompleteResponseError,
runSSE,
+ controlTurn,
refreshAgentFeedbackCases,
submitIssueFeedback,
submitMessageFeedback,
@@ -66,10 +69,13 @@ import {
type StudioAccess,
type StudioEnvironment,
type StudioWorkspace,
+ type TurnControlState,
+ TurnControlConflictError,
type UiConfig,
type UiFeatures,
} from "./adk/client";
import type { RuntimeLogTarget } from "./adk/runtimeLogs";
+import type { SelectedSkill } from "./create/skills/types";
import {
addTokenUsage,
aggregateTokenUsage,
@@ -297,6 +303,14 @@ function issueFeedbackModuleForPage(page: string): IssueFeedbackModule {
return "other";
}
+function isOptionalStudioStorageUnavailable(cause: unknown): boolean {
+ const message = cause instanceof Error ? cause.message : String(cause ?? "");
+ return message.includes("HTTP 503") && (
+ message.includes("未配置持久化存储") ||
+ message.includes("storage is not configured")
+ );
+}
+
interface NewChatCapabilitiesState {
agentId?: string;
ready?: boolean;
@@ -400,8 +414,6 @@ const DRAFT_AUTOSAVE_DELAY_MS = 600;
const AUTO_EVALUATION_RUNNING_POLL_MS = 1_000;
const AUTO_EVALUATION_RETRY_POLL_MS = 5_000;
const AUTO_EVALUATION_MIN_PENDING_POLL_MS = 500;
-const EMPTY_STRING_SET: Set = new Set();
-const EMPTY_STRING_ARR: string[] = [];
const ENVIRONMENT_STUDIO_TOOL_IDS = [
"list_envs",
"get_env_manifest",
@@ -409,6 +421,7 @@ const ENVIRONMENT_STUDIO_TOOL_IDS = [
"delegate_to_codex_sandbox",
] as const;
const SESSION_ENVIRONMENT_STORAGE_KEY = "veadk.sessionEnvironmentMounts.v1";
+const SESSION_SKILL_STORAGE_KEY = "veadk.sessionSkillMounts.v1";
interface StoredSessionEnvironmentState {
mounts: Record;
@@ -484,6 +497,43 @@ function persistSessionEnvironmentState(
}
}
+function loadStoredSessionSkills(): Record {
+ if (typeof localStorage === "undefined") return {};
+ try {
+ const raw = JSON.parse(localStorage.getItem(SESSION_SKILL_STORAGE_KEY) ?? "{}");
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
+ return Object.fromEntries(
+ Object.entries(raw as Record).slice(-200).map(([key, value]) => [
+ key,
+ Array.isArray(value)
+ ? value.slice(0, 20).filter((item): item is SelectedSkill => (
+ Boolean(item)
+ && typeof item === "object"
+ && !Array.isArray(item)
+ && (item as SelectedSkill).source === "skillspace"
+ && typeof (item as SelectedSkill).name === "string"
+ && typeof (item as SelectedSkill).folder === "string"
+ && typeof (item as SelectedSkill).skillSpaceId === "string"
+ && typeof (item as SelectedSkill).skillId === "string"
+ && typeof (item as SelectedSkill).version === "string"
+ ))
+ : [],
+ ]),
+ );
+ } catch {
+ return {};
+ }
+}
+
+function persistSessionSkills(skills: Record) {
+ if (typeof localStorage === "undefined") return;
+ try {
+ localStorage.setItem(SESSION_SKILL_STORAGE_KEY, JSON.stringify(skills));
+ } catch {
+ // Storage can be unavailable in private or quota-restricted browsers.
+ }
+}
+
function emptyInvocation(): FrontendInvocation {
return { skills: [] };
}
@@ -1440,17 +1490,20 @@ export default function App() {
const [studioToolCapabilities, setStudioToolCapabilities] =
useState(null);
const [studioToolsLoading, setStudioToolsLoading] = useState(false);
- const [studioToolsError, setStudioToolsError] = useState("");
+ const [selectedCronRuntime, setSelectedCronRuntime] = useState<{
+ runtimeId: string; name: string; region: string;
+ } | undefined>();
+ useEffect(() => setSelectedCronRuntime(undefined), [appName]);
const [draftStudioRuntime, setDraftStudioRuntime] = useState<{
appName: string;
runtimeId: string;
name: string;
region: string;
} | null>(null);
- const [draftStudioToolIds, setDraftStudioToolIds] = useState([]);
- const [studioToolIdsBySession, setStudioToolIdsBySession] = useState<
- Record
- >({});
+ const [sessionSkillsBySession, setSessionSkillsBySession] = useState<
+ Record
+ >(() => loadStoredSessionSkills());
+ useEffect(() => persistSessionSkills(sessionSkillsBySession), [sessionSkillsBySession]);
const [sessionEnvironments, setSessionEnvironments] = useState([]);
const [sessionWorkspaces, setSessionWorkspaces] = useState([]);
const [sessionEnvironmentsLoading, setSessionEnvironmentsLoading] = useState(false);
@@ -1478,6 +1531,12 @@ export default function App() {
Record
>({});
const [agentInfo, setAgentInfo] = useState(null);
+ const [turnControlBySession, setTurnControlBySession] = useState>({});
+ const turnControlBySessionRef = useRef>({});
+ const [turnControlBusy, setTurnControlBusy] = useState(false);
+ const [selectedModelBySession, setSelectedModelBySession] = useState<
+ Record
+ >({});
const [agentInfoRefreshKey, setAgentInfoRefreshKey] = useState(0);
const [capabilitiesLoading, setCapabilitiesLoading] = useState(false);
const removedAttachmentIdsRef = useRef>(new Set());
@@ -1823,20 +1882,6 @@ export default function App() {
const [uiConfigLoaded, setUiConfigLoaded] = useState(false);
const [localMode, setLocalMode] = useState(false);
const [loadingSession, setLoadingSession] = useState(false);
- // The executing sub-agent (ADK event.author) and everyone who emitted this
- // turn — PER SESSION, so each session's topology highlights its own stream.
- const [activeAgentBySession, setActiveAgentBySession] = useState<
- Record
- >({});
- const [seenAgentsBySession, setSeenAgentsBySession] = useState<
- Record>
- >({});
- // The current delegation chain (root → … → executing agent) per session,
- // built from event.actions.transfer_to_agent / end_of_agent.
- const [execPathBySession, setExecPathBySession] = useState<
- Record
- >({});
-
// Everything the view needs for the ACTIVE session, derived from the
// per-session maps above.
const busy = streamingSids.has(sessionId);
@@ -1961,9 +2006,6 @@ export default function App() {
if (timer !== undefined) window.clearTimeout(timer);
};
}, [sandboxBusy, sandboxSession?.id]);
- const activeAgent = activeAgentBySession[sessionId] ?? "";
- const seenAgents = seenAgentsBySession[sessionId] ?? EMPTY_STRING_SET;
- const execPath = execPathBySession[sessionId] ?? EMPTY_STRING_ARR;
const rootCapabilityNode = agentInfo?.graph;
const rootAgentNames = [
agentInfo?.name,
@@ -1986,11 +2028,13 @@ export default function App() {
tools: [
...new Set([
...(rootCapabilityNode?.tools ?? agentInfo.tools),
- ...(sessionId
- ? (studioToolIdsBySession[
- studioToolSelectionKey(appName, userId, sessionId)
- ] ?? [])
- : draftStudioToolIds),
+ ...(sessionId && (
+ environmentMountsBySession[
+ studioToolSelectionKey(appName, userId, sessionId)
+ ] ?? []
+ ).length > 0
+ ? [...ENVIRONMENT_STUDIO_TOOL_IDS]
+ : []),
]),
],
skills: rootCapabilityNode?.skills ?? agentInfo.skills,
@@ -2043,41 +2087,6 @@ export default function App() {
}
}
- // Apply a stream event's control-flow signals to a session's live state:
- // author = who's executing now; transfer_to_agent pushes the delegation
- // chain; end_of_agent / escalate pops it. `author` always wins for highlight.
- const applyStreamSignals = (sid: string, ev: AdkEvent) => {
- const who = ev.author && ev.author !== "user" ? ev.author : undefined;
- if (who) {
- setActiveAgentBySession((m) => ({ ...m, [sid]: who }));
- setSeenAgentsBySession((m) => ({
- ...m,
- [sid]: new Set(m[sid] ?? []).add(who),
- }));
- // Seed the path with the entry (root) agent on the first event.
- setExecPathBySession((m) =>
- m[sid]?.length ? m : { ...m, [sid]: [who] },
- );
- }
- const transferTo =
- ev.actions?.transferToAgent ?? ev.actions?.transfer_to_agent;
- if (transferTo) {
- setExecPathBySession((m) => {
- const cur = m[sid] ?? [];
- return cur[cur.length - 1] === transferTo
- ? m
- : { ...m, [sid]: [...cur, transferTo] };
- });
- }
- const ended =
- ev.actions?.endOfAgent ?? ev.actions?.end_of_agent ?? ev.actions?.escalate;
- if (ended) {
- setExecPathBySession((m) => {
- const cur = m[sid] ?? [];
- return cur.length <= 1 ? m : { ...m, [sid]: cur.slice(0, -1) };
- });
- }
- };
const [createView, setCreateView] = useState(loadView);
const [deploymentTasks, setDeploymentTasks] = useState<
DeploymentTaskUpdate[]
@@ -4740,7 +4749,6 @@ export default function App() {
setInitializingSession(false);
setPendingTurns([]);
setInvocation(emptyInvocation());
- setDraftStudioToolIds([]);
discardDraftAttachments(attachments);
setAttachments([]);
if (abandonedSession) void abandonDraftSession(abandonedSession);
@@ -5095,24 +5103,100 @@ export default function App() {
);
}
+ const activeTurnControl = sessionId ? turnControlBySession[sessionId] ?? null : null;
+ const activeTurnIsControllable = Boolean(
+ activeTurnControl?.allowedActions.length
+ || ["running", "pausing", "paused", "resuming", "interrupting", "cancelling"]
+ .includes(activeTurnControl?.state ?? ""),
+ );
+
+ async function applyTurnControl(action: "pause" | "resume") {
+ if (!sessionId || !appName || !activeTurnControl) return;
+ setTurnControlBusy(true);
+ try {
+ const next = await controlTurn(
+ appName, sessionId, action, activeTurnControl.generation,
+ );
+ if (action === "resume" && next.resumeDisposition === "new_turn_required") {
+ const originalTask = typeof next.checkpoint?.originalTask === "string"
+ ? next.checkpoint.originalTask.trim()
+ : "";
+ streamAbortsRef.current.get(sessionId)?.abort();
+ await send(
+ [next.continuationPrompt, originalTask && `Original task: ${originalTask}`]
+ .filter(Boolean)
+ .join("\n\n"),
+ [],
+ emptyInvocation(),
+ "composer",
+ undefined,
+ true,
+ );
+ return;
+ }
+ setTurnControlBySession((current) => ({ ...current, [sessionId]: next }));
+ turnControlBySessionRef.current = {
+ ...turnControlBySessionRef.current,
+ [sessionId]: next,
+ };
+ } catch (cause) {
+ if (cause instanceof TurnControlConflictError) {
+ const next = cause.authoritativeState;
+ setTurnControlBySession((current) => ({ ...current, [sessionId]: next }));
+ turnControlBySessionRef.current = {
+ ...turnControlBySessionRef.current,
+ [sessionId]: next,
+ };
+ return;
+ }
+ setError(cause instanceof Error ? cause.message : String(cause));
+ } finally {
+ setTurnControlBusy(false);
+ }
+ }
+
async function send(
text: string,
atts: Attachment[] = [],
selectedInvocation: FrontendInvocation = emptyInvocation(),
messageSource: AgentMessageSource = "composer",
selectedPlatformTools?: readonly string[],
+ allowWhileBusy = false,
) {
// `busy` here = the CURRENT session is already streaming (can't double-send
// to it). Other sessions can stream concurrently.
if (
(!text.trim() && atts.length === 0) ||
- conversationBusy ||
+ (!allowWhileBusy && conversationBusy) ||
!appName ||
!userId
) return;
+ const selectableModels = agentInfo?.selectableModels ?? [];
+ const modelSelectionKey = sessionId || `new:${appName}`;
+ const selectedModel = selectedModelBySession[modelSelectionKey] || "";
+ const requestedModel = selectableModels.length > 1
+ ? selectableModels.includes(selectedModel)
+ ? selectedModel
+ : agentInfo?.model || selectableModels[0] || ""
+ : "";
+ const mountedSkillInvocation = {
+ ...selectedInvocation,
+ skills: [
+ ...selectedInvocation.skills,
+ ...selectedSessionSkills.map((skill) => ({
+ name: skill.name,
+ description: skill.description ?? "",
+ skillSpaceId: skill.skillSpaceId,
+ skillId: skill.skillId,
+ version: skill.version,
+ })),
+ ].filter((skill, index, values) => (
+ values.findIndex((candidate) => candidate.name === skill.name) === index
+ )),
+ };
setError("");
const createsSession = !sessionId;
- let platformTools = [...(selectedPlatformTools ?? selectedStudioToolIds)];
+ let platformTools = [...(selectedPlatformTools ?? environmentStudioToolIds)];
const environmentMounts = createsSession
? []
: environmentMountsBySession[
@@ -5134,8 +5218,8 @@ export default function App() {
: null;
const userBlocks: Turn["blocks"] = [];
- if (selectedInvocation.skills.length > 0 || selectedInvocation.targetAgent) {
- userBlocks.push({ kind: "invocation", value: selectedInvocation });
+ if (mountedSkillInvocation.skills.length > 0 || mountedSkillInvocation.targetAgent) {
+ userBlocks.push({ kind: "invocation", value: mountedSkillInvocation });
}
if (atts.length)
userBlocks.push({
@@ -5191,6 +5275,9 @@ export default function App() {
if (selectedTask) {
const requiredTools = NEW_CHAT_TASK_TOOLS[selectedTask];
const agentTools = new Set(agentInfo?.tools ?? []);
+ const availableStudioToolIds = new Set(
+ studioToolCapabilities?.tools.map((tool) => tool.id) ?? [],
+ );
const availableTools = new Set([
...agentTools,
...(studioToolRuntime ? availableStudioToolIds : []),
@@ -5231,15 +5318,15 @@ export default function App() {
createsSession ? optimisticTurns : [...current, ...optimisticTurns],
);
if (createsSession) {
- if (studioToolRuntime) {
- const key = studioToolSelectionKey(appName, userId, sid);
- setStudioToolIdsBySession((current) => ({
+ viewSidRef.current = sid;
+ setSessionId(sid);
+ if (requestedModel && createsSession) {
+ setSelectedModelBySession((current) => ({
...current,
- [key]: [...platformTools],
+ [sid]: requestedModel,
+ [modelSelectionKey]: "",
}));
}
- viewSidRef.current = sid;
- setSessionId(sid);
setPendingTurns([]);
setInitializingSession(false);
}
@@ -5248,13 +5335,20 @@ export default function App() {
const ctrl = new AbortController();
streamAbortsRef.current.set(sid, ctrl);
setStreaming(sid, true);
+ if (agentInfo?.turnLifecycleControl && currentRuntime) {
+ setTurnControlBySession((current) => {
+ if (!current[sid]) return current;
+ const next = { ...current };
+ delete next[sid];
+ return next;
+ });
+ const nextTurnControls = { ...turnControlBySessionRef.current };
+ delete nextTurnControls[sid];
+ turnControlBySessionRef.current = nextTurnControls;
+ }
startStreamPresentation(sid);
viewSidRef.current = sid;
- setActiveAgentBySession((m) => ({ ...m, [sid]: "" }));
- setSeenAgentsBySession((m) => ({ ...m, [sid]: new Set() }));
- setExecPathBySession((m) => ({ ...m, [sid]: [] }));
-
const eventProjector = createAssistantEventProjector(
`${sid}-${crypto.randomUUID()}`,
optimisticAssistantTurn,
@@ -5269,8 +5363,9 @@ export default function App() {
userId,
sessionId: sid,
text,
+ modelId: requestedModel,
attachments: atts,
- invocation: selectedInvocation,
+ invocation: mountedSkillInvocation,
platformTools: studioToolRuntime ? platformTools : undefined,
environmentMounts: studioToolRuntime && environmentMounts.length > 0
? environmentMounts
@@ -5291,8 +5386,6 @@ export default function App() {
if (viewSidRef.current === sid) setError(errMsg);
break;
}
- // Live topology: author + transfer/end signals, keyed by session.
- applyStreamSignals(sid, event);
addTokenUsageFor(appName, sid, event);
const projection = eventProjector.project(event);
if (projection.ignored) continue;
@@ -5368,11 +5461,11 @@ export default function App() {
) {
setInput((current) => current.trim() ? current : text);
}
- if (streamAbortsRef.current.get(sid) === ctrl) streamAbortsRef.current.delete(sid);
- setStreaming(sid, false);
- finishStreamPresentation(sid);
- setActiveAgentBySession((m) => ({ ...m, [sid]: "" }));
- setExecPathBySession((m) => ({ ...m, [sid]: [] }));
+ if (streamAbortsRef.current.get(sid) === ctrl) {
+ streamAbortsRef.current.delete(sid);
+ setStreaming(sid, false);
+ finishStreamPresentation(sid);
+ }
}
}
@@ -5433,8 +5526,8 @@ export default function App() {
studioToolSelectionKey(appName, userId, sid)
] ?? [];
const resumedPlatformTools = environmentMounts.length > 0
- ? [...new Set([...selectedStudioToolIds, ...ENVIRONMENT_STUDIO_TOOL_IDS])]
- : selectedStudioToolIds;
+ ? [...ENVIRONMENT_STUDIO_TOOL_IDS]
+ : [];
const eventProjector = createAssistantEventProjector(
`${sid}-${crypto.randomUUID()}`,
lastTurn?.role === "assistant"
@@ -5471,7 +5564,6 @@ export default function App() {
if (viewSidRef.current === sid) setError(errMsg);
break;
}
- applyStreamSignals(sid, event);
addTokenUsageFor(appName, sid, event);
const projection = eventProjector.project(event);
if (projection.ignored) continue;
@@ -5514,8 +5606,6 @@ export default function App() {
if (streamAbortsRef.current.get(sid) === ctrl) streamAbortsRef.current.delete(sid);
setStreaming(sid, false);
finishStreamPresentation(sid);
- setActiveAgentBySession((m) => ({ ...m, [sid]: "" }));
- setExecPathBySession((m) => ({ ...m, [sid]: [] }));
}
}
@@ -5536,6 +5626,54 @@ export default function App() {
region: currentConn.region,
}
: undefined;
+ useEffect(() => {
+ if (
+ !sessionId ||
+ !appName ||
+ !currentRuntime ||
+ !agentInfo?.turnLifecycleControl
+ ) return;
+ let cancelled = false;
+ const refresh = () => {
+ void getTurnControl(appName, sessionId)
+ .then((state) => {
+ if (!cancelled) {
+ setTurnControlBySession((current) => (
+ current[sessionId]?.taskId === state.taskId
+ && current[sessionId]?.state === state.state
+ && current[sessionId]?.generation === state.generation
+ && current[sessionId]?.allowedActions.join("\0")
+ === state.allowedActions.join("\0")
+ ? current
+ : { ...current, [sessionId]: state }
+ ));
+ turnControlBySessionRef.current = {
+ ...turnControlBySessionRef.current,
+ [sessionId]: state,
+ };
+ }
+ })
+ .catch(() => {
+ if (!cancelled && !streamingSids.has(sessionId)) {
+ setTurnControlBySession((current) => {
+ const next = { ...current };
+ delete next[sessionId];
+ return next;
+ });
+ const next = { ...turnControlBySessionRef.current };
+ delete next[sessionId];
+ turnControlBySessionRef.current = next;
+ }
+ });
+ };
+ refresh();
+ const timer = window.setInterval(() => {
+ const current = turnControlBySessionRef.current[sessionId];
+ if (current && ["completed", "failed", "rejected", "cancelled", "canceled", "interrupted", "orphaned"].includes(current.state)) return;
+ refresh();
+ }, 1000);
+ return () => { cancelled = true; window.clearInterval(timer); };
+ }, [agentInfo?.turnLifecycleControl, appName, currentRuntime, sessionId]);
const selectedDraftStudioRuntime =
draftStudioRuntime?.appName === appName ? draftStudioRuntime : undefined;
// Local Agents execute through this Studio process, so use the synthetic
@@ -5554,7 +5692,7 @@ export default function App() {
useEffect(() => {
let cancelled = false;
setStudioToolCapabilities(null);
- setStudioToolsError("");
+ setSessionEnvironmentsError("");
if (
authStatus !== "authenticated" ||
!access ||
@@ -5575,7 +5713,7 @@ export default function App() {
})
.catch((cause) => {
if (cancelled) return;
- setStudioToolsError(
+ setSessionEnvironmentsError(
cause instanceof Error ? cause.message : appText("errors.localToolsLoadFailed"),
);
})
@@ -5601,11 +5739,23 @@ export default function App() {
setSessionEnvironmentsLoading(true);
setSessionEnvironmentsError("");
try {
- const [items, workspaces] = await Promise.all([
+ const environmentResults = await Promise.allSettled([
listEnvironments(controller.signal),
listWorkspaces(controller.signal),
]);
if (controller.signal.aborted) return;
+ const items = environmentResults[0].status === "fulfilled"
+ ? environmentResults[0].value
+ : [];
+ const workspaces = environmentResults[1].status === "fulfilled"
+ ? environmentResults[1].value
+ : [];
+ const environmentErrors = environmentResults.flatMap((result) =>
+ result.status === "rejected" &&
+ !isOptionalStudioStorageUnavailable(result.reason)
+ ? [result.reason instanceof Error ? result.reason.message : String(result.reason)]
+ : []
+ );
const availableEnvironments = items.filter((environment) =>
["aio-sandbox", "codex-sandbox"].includes(environment.baseEnvironment) &&
environment.latestVersion?.status === "available" &&
@@ -5623,6 +5773,7 @@ export default function App() {
// workspaces disappear from both the picker and existing Session mounts.
setSessionEnvironments(availableEnvironments);
setSessionWorkspaces(workspaces);
+ setSessionEnvironmentsError(environmentErrors.join("\n"));
setEnvironmentMountsBySession((current) => Object.fromEntries(
Object.entries(current).map(([key, selections]) => [
key,
@@ -5748,49 +5899,29 @@ export default function App() {
const activeStudioToolSelectionKey = sessionId
? studioToolSelectionKey(appName, userId, sessionId)
: "";
- const storedStudioToolIds = sessionId
- ? (studioToolIdsBySession[activeStudioToolSelectionKey] ?? [])
- : draftStudioToolIds;
const allStudioToolIds = new Set(
studioToolCapabilities?.tools.map((tool) => tool.id) ?? [],
);
- const availableStudioToolIds = new Set(
- [...allStudioToolIds].filter((toolId) => !agentInfo?.tools.includes(toolId)),
- );
const selectedEnvironmentMounts = sessionId
? environmentMountsBySession[activeStudioToolSelectionKey] ?? []
: [];
- const selectedEnvironmentWorkspaceIds = sessionId
- ? environmentWorkspaceIdsBySession[activeStudioToolSelectionKey] ?? []
- : [];
const canMountSessionEnvironment = ENVIRONMENT_STUDIO_TOOL_IDS.every((toolId) =>
allStudioToolIds.has(toolId)
);
- const selectedStudioToolIds = [...new Set([
- ...storedStudioToolIds.filter((toolId) => availableStudioToolIds.has(toolId)),
- ...(selectedEnvironmentMounts.length > 0 && canMountSessionEnvironment
- ? [...ENVIRONMENT_STUDIO_TOOL_IDS]
- : []),
- ])];
- const visibleStudioTools = studioToolCapabilities?.tools.filter((tool) =>
- !ENVIRONMENT_STUDIO_TOOL_IDS.includes(
- tool.id as (typeof ENVIRONMENT_STUDIO_TOOL_IDS)[number],
- ) || selectedEnvironmentMounts.length > 0
- ) ?? [];
- const updateSelectedStudioToolIds = (selectedIds: string[]) => {
- const next = [...new Set([
- ...selectedIds,
- ...(selectedEnvironmentMounts.length > 0 ? [...ENVIRONMENT_STUDIO_TOOL_IDS] : []),
- ])].filter((toolId) =>
- availableStudioToolIds.has(toolId),
- );
- if (!sessionId) {
- setDraftStudioToolIds(next);
- return;
- }
- setStudioToolIdsBySession((current) => ({
+ const environmentStudioToolIds = selectedEnvironmentMounts.length > 0 && canMountSessionEnvironment
+ ? [...ENVIRONMENT_STUDIO_TOOL_IDS]
+ : [];
+ const selectedSessionSkills = sessionId
+ ? sessionSkillsBySession[activeStudioToolSelectionKey] ?? []
+ : [];
+ const selectedEnvironmentWorkspaceIds = sessionId
+ ? environmentWorkspaceIdsBySession[activeStudioToolSelectionKey] ?? []
+ : [];
+ const updateSelectedSessionSkills = (skills: SelectedSkill[]) => {
+ if (!sessionId) return;
+ setSessionSkillsBySession((current) => ({
...current,
- [activeStudioToolSelectionKey]: next,
+ [activeStudioToolSelectionKey]: skills,
}));
};
const updateSelectedEnvironments = async (
@@ -5829,26 +5960,8 @@ export default function App() {
...current,
[activeStudioToolSelectionKey]: workspaceIds,
}));
- setStudioToolIdsBySession((current) => {
- const selectedIds = current[activeStudioToolSelectionKey] ?? [];
- return {
- ...current,
- [activeStudioToolSelectionKey]: selections.length > 0
- ? [...new Set([...selectedIds, ...ENVIRONMENT_STUDIO_TOOL_IDS])]
- : selectedIds.filter((toolId) => !ENVIRONMENT_STUDIO_TOOL_IDS.includes(
- toolId as (typeof ENVIRONMENT_STUDIO_TOOL_IDS)[number],
- )),
- };
- });
};
- const studioToolsUnavailableReason = studioToolsError
- ? studioToolsError
- : studioToolCapabilities && !studioToolCapabilities.enabled
- ? t("errors.localBffToolsNotConfigured")
- : studioToolCapabilities && !studioToolCapabilities.supported
- ? t("errors.runtimeBffToolsDisabled")
- : "";
const sessionEnvironmentsUnavailableReason = sessionEnvironmentsError
|| (!studioToolsLoading && studioToolCapabilities && !canMountSessionEnvironment
? t("errors.sandboxToolsUnavailable")
@@ -6145,6 +6258,7 @@ export default function App() {
.filter((status) => status.state === "running")
.map((status) => status.sessionId) ?? [],
));
+ startNewChat();
setAppName(id);
exitAgentDetailContext();
setFocusedDeploymentTaskId("");
@@ -6157,7 +6271,6 @@ export default function App() {
setIntelligentDeployment(null);
setWorkspaceView(false);
setEnvironmentView(false);
- startNewChat();
};
const openIntelligentDeploymentChat = async (agentId: string) => {
@@ -6220,6 +6333,10 @@ export default function App() {
} = {},
) => {
if (!agent.runtime) return;
+ // Task reads do not depend on the Runtime's chat/session protocol.
+ setSelectedCronRuntime({
+ runtimeId: agent.runtime.runtimeId, name: agent.name, region: agent.runtime.region,
+ });
try {
const agentId = await connectRuntimeForUser(
agent,
@@ -6836,11 +6953,11 @@ export default function App() {
atts,
selectedInvocation,
"composer",
- selectedStudioToolIds,
+ undefined,
);
releaseAttachmentPreviews(atts);
}}
- onStop={busy ? stopCurrentGeneration : undefined}
+ onStop={busy && !agentInfo?.turnLifecycleControl ? stopCurrentGeneration : undefined}
disabled={
sandboxSession
? false
@@ -6868,6 +6985,23 @@ export default function App() {
modelName={
modelNameFromRuntime(agentInfo?.model) || activeTokenUsage.modelName
}
+ selectableModels={agentInfo?.selectableModels}
+ selectedModel={
+ agentInfo?.selectableModels?.includes(
+ selectedModelBySession[sessionId || `new:${appName}`] || "",
+ )
+ ? selectedModelBySession[sessionId || `new:${appName}`]
+ : agentInfo?.model || agentInfo?.selectableModels?.[0] || ""
+ }
+ onSelectedModelChange={(model) => {
+ setSelectedModelBySession((current) => ({
+ ...current,
+ [sessionId || `new:${appName}`]: model,
+ }));
+ }}
+ turnControl={activeTurnIsControllable ? activeTurnControl : null}
+ turnControlBusy={turnControlBusy}
+ onTurnControl={(action) => void applyTurnControl(action)}
tokenUsage={activeTokenUsage}
systemTokenEstimate={systemTokenEstimate}
allowAttachments={!sandboxSession}
@@ -7064,7 +7198,7 @@ export default function App() {
setCreateView("workspace");
} : undefined} />
) : cronJobsView ? (
-
+
) : applicationsView === "coding-agents" ? (
setApplicationsView("catalog")}
@@ -7819,35 +7953,37 @@ export default function App() {
>
)}
{pending ? (
- turnIsStreaming ? : null
+ turnIsStreaming ? : null
) : (
<>
- completeStreamPresentation(sessionId)
- : undefined
- }
- onAction={onAction}
- onAuth={onAuth}
- onArtifactDownload={(filename, version) =>
- downloadArtifact(appName, userId, sessionId, filename, version)
- }
- onArtifactPreview={(filename, version) =>
- previewArtifact(appName, userId, sessionId, filename, version)
- }
- onResolveDelivery={resolveIntelligentDelivery}
- onResolveDeliveryComparison={resolveIntelligentDeliveryComparison}
- onDownloadDelivery={downloadIntelligentDelivery}
- onDeployDelivery={setIntelligentDeployment}
- onBranchSelect={(branch) => {
- setInput(t("conversation.continueBranch", { branch: branch.label }));
- }}
- />
+
+ completeStreamPresentation(sessionId)
+ : undefined
+ }
+ onAction={onAction}
+ onAuth={onAuth}
+ onArtifactDownload={(filename, version) =>
+ downloadArtifact(appName, userId, sessionId, filename, version)
+ }
+ onArtifactPreview={(filename, version) =>
+ previewArtifact(appName, userId, sessionId, filename, version)
+ }
+ onResolveDelivery={resolveIntelligentDelivery}
+ onResolveDeliveryComparison={resolveIntelligentDeliveryComparison}
+ onDownloadDelivery={downloadIntelligentDelivery}
+ onDeployDelivery={setIntelligentDeployment}
+ onBranchSelect={(branch) => {
+ setInput(t("conversation.continueBranch", { branch: branch.label }));
+ }}
+ />
+
{/* Finalized turn that produced no visible answer (e.g. only
thinking + an empty A2UI surface) — show a fallback note. */}
{!turnIsStreaming && !turnHasVisibleContent(turn) && (
@@ -7982,23 +8118,10 @@ export default function App() {
{!sandboxSession && (
0
- ? ENVIRONMENT_STUDIO_TOOL_IDS
- : []}
- studioToolsLoading={studioToolsLoading}
- studioToolsDisabled={conversationBusy}
- studioToolsUnavailableReason={studioToolsUnavailableReason}
- onStudioToolsChange={
- studioToolRuntime ? updateSelectedStudioToolIds : undefined
- }
+ selectedSessionSkills={selectedSessionSkills}
+ onSessionSkillsChange={sessionId ? updateSelectedSessionSkills : undefined}
environments={sessionEnvironments}
workspaces={sessionWorkspaces}
selectedEnvironments={selectedEnvironmentMounts}
diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts
index ce54146b0..11158c6b4 100644
--- a/frontend/src/adk/client.ts
+++ b/frontend/src/adk/client.ts
@@ -1,3 +1,4 @@
+import { fetchMpaCronTasks, manageMpaTask, fetchMpaRuns, type MpaRuntime } from "./mpaCronTasks";
// Thin client for the Google ADK API server (the same server `veadk frontend`
// launches). Uses relative URLs so it works same-origin in production and via
// the Vite dev proxy in development.
@@ -91,6 +92,8 @@ export interface AdkEvent {
timestamp?: number;
usageMetadata?: AdkUsage;
usage_metadata?: AdkUsage;
+ customMetadata?: Record;
+ custom_metadata?: Record;
// Set when the model/run fails; /run_sse emits it as a `data: {"error": ...}`
// frame (also seen as errorMessage / error_message).
error?: string;
@@ -1203,6 +1206,19 @@ function decodeArtifactData(value: string): Uint8Array {
return bytes;
}
+export async function fetchSessionFile(
+ appName: string,
+ sessionId: string,
+ path: string,
+ signal: AbortSignal,
+): Promise {
+ const { ep } = resolve(appName);
+ const url = `/api/v1/sessions/${encodeURIComponent(sessionId)}/files/download?path=${encodeURIComponent(path)}`;
+ const response = await apiFetch(url, { signal }, ep, TRANSFER_REQUEST_TIMEOUT_MS);
+ if (!response.ok) throw new Error(`SESSION_FILE_HTTP_${response.status}`);
+ return response.blob();
+}
+
export async function downloadArtifact(
appName: string,
userId: string,
@@ -1466,10 +1482,16 @@ export interface AgentTarget {
}
export interface FrontendInvocation {
- skills: AgentSkill[];
+ skills: SessionSkillSelection[];
targetAgent?: AgentTarget;
}
+export interface SessionSkillSelection extends AgentSkill {
+ skillSpaceId?: string;
+ skillId?: string;
+ version?: string;
+}
+
/** Introspected metadata for an agent app, served locally or by Agent Server. */
export interface AgentInfo {
/** Real ADK app id used in runtime proxy paths; display names may differ. */
@@ -1478,6 +1500,9 @@ export interface AgentInfo {
description: string;
type?: AgentNodeType;
model: string;
+ selectableModels?: string[];
+ turnLifecycleControl?: TurnLifecycleCapability;
+ resourceTopology?: ResourceTopology;
tools: string[];
skills: AgentSkill[];
/** False when an older Agent Server omits Skill introspection entirely. */
@@ -1518,6 +1543,9 @@ async function fetchAgentInfo(
description: info.description ?? "",
type: info.type,
model: info.model ?? "",
+ selectableModels: info.selectableModels ?? [],
+ turnLifecycleControl: info.turnLifecycleControl,
+ resourceTopology: info.resourceTopology,
tools: info.tools ?? [],
skillsPreviewSupported: Array.isArray(info.skills),
skills: info.skills ?? [],
@@ -1529,6 +1557,82 @@ async function fetchAgentInfo(
};
}
+export type TurnControlAction = "pause" | "resume";
+
+export interface TurnLifecycleCapability {
+ actions: TurnControlAction[];
+ pauseMode: "cooperative-safe-point" | string;
+ processReplacementResume: boolean;
+}
+
+export interface ResourceTopology {
+ nodes: Array<{ id: string; kind: string; name: string; status: string; resourceId?: string }>;
+ edges: Array<{ source: string; target: string; relation: string }>;
+}
+
+export interface TurnControlState {
+ taskId: string;
+ state: string;
+ generation: number;
+ desiredState?: string | null;
+ safePoint?: string | null;
+ checkpoint?: Record | null;
+ allowedActions: TurnControlAction[];
+ idempotentReplay: boolean;
+ pausedAt?: string | null;
+ resumableUntil?: string | null;
+ resumeDisposition?: "same_turn" | "new_turn_required";
+ continuationPrompt?: string | null;
+}
+
+export class TurnControlConflictError extends Error {
+ constructor(readonly authoritativeState: TurnControlState) {
+ super("Turn state changed before the control request completed");
+ this.name = "TurnControlConflictError";
+ }
+}
+
+export async function getTurnControl(
+ appName: string,
+ sessionId: string,
+): Promise {
+ const { ep } = resolve(appName);
+ const res = await apiFetch(
+ `/api/v1/a2a/tasks/by-session/${encodeURIComponent(sessionId)}/control`,
+ { cache: "no-store" },
+ ep,
+ );
+ if (!res.ok) throw new Error(await httpErrorMessage(res, "turn control failed"));
+ return res.json();
+}
+
+export async function controlTurn(
+ appName: string,
+ sessionId: string,
+ action: TurnControlAction,
+ expectedGeneration: number,
+): Promise {
+ const { ep } = resolve(appName);
+ const res = await apiFetch(
+ `/api/v1/a2a/tasks/by-session/${encodeURIComponent(sessionId)}/control/${action}`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": crypto.randomUUID(),
+ },
+ body: JSON.stringify({ expectedGeneration }),
+ },
+ ep,
+ );
+ if (res.status === 409) {
+ const body = await res.json().catch(() => null) as { detail?: TurnControlState } | null;
+ if (body?.detail?.taskId) throw new TurnControlConflictError(body.detail);
+ }
+ if (!res.ok) throw new Error(await httpErrorMessage(res, "turn control failed"));
+ return res.json();
+}
+
export async function getAgentInfo(appName: string): Promise {
const { app, ep } = resolve(appName);
return fetchAgentInfo(app, ep, false);
@@ -1698,6 +1802,7 @@ export interface RunArgs {
userId: string;
sessionId: string;
text: string;
+ modelId?: string;
attachments?: Attachment[];
invocation?: FrontendInvocation;
/** Complete set of local BFF tool IDs selected for this run. */
@@ -1784,6 +1889,7 @@ export async function* runSSE({
userId,
sessionId,
text,
+ modelId,
attachments = [],
invocation,
platformTools,
@@ -1850,6 +1956,7 @@ export async function* runSSE({
session_id: sessionId,
new_message: { role: "user", parts },
streaming: true,
+ ...(modelId?.trim() ? { model_id: modelId.trim() } : {}),
...(platformTools !== undefined
? { platform_tools: [...platformTools] }
: {}),
@@ -4098,6 +4205,8 @@ export interface CloudRuntime {
memoryMb?: number | null;
createdAt?: string;
currentVersion?: number | null;
+ /** Product family used by the new-chat picker. */
+ agentCategory?: "general" | "mpa";
/** True when this runtime was deployed by the current user (veadk:author). */
isMine: boolean;
/** Server-authorized deletion capability for this managed Runtime. */
@@ -4307,6 +4416,7 @@ export class RuntimeListError extends Error {
* page continues pagination; the server derives ownership from identity. */
export async function getRuntimes(
opts: {
+ agentCategory?: "general" | "mpa";
nextToken?: string;
pageSize?: number;
region?: string;
@@ -4319,6 +4429,7 @@ export async function getRuntimes(
page_size: String(opts.pageSize ?? 30),
region: opts.region ?? "all",
});
+ if (opts.agentCategory) p.set("agentCategory", opts.agentCategory);
if (opts.nextToken) p.set("next_token", opts.nextToken);
const res = await apiFetch(`/web/runtimes?${p.toString()}`, {
signal: opts.signal,
@@ -4811,7 +4922,7 @@ export interface RuntimeDetail {
maxInstance?: number | null;
maxConcurrency?: number | null;
};
- envs: { key: string; value: string }[];
+ envs: { key: string; value: string; sensitive?: boolean; configured?: boolean }[];
memoryId: string;
toolId: string;
knowledgeId: string;
@@ -4875,6 +4986,21 @@ export async function getRuntimeDetail(
}
}
+export async function copyRuntimeEnvironmentSecret(
+ runtimeId: string,
+ region: string,
+ key: string,
+): Promise {
+ const params = new URLSearchParams({ runtimeId, region, key });
+ const response = await apiFetch(`/web/runtime-env/copy?${params.toString()}`, { method: "POST" });
+ if (!response.ok) throw new Error(await httpErrorMessage(response, adkT("client.copySecretFailed")));
+ const payload = await response.json() as { value?: unknown };
+ if (typeof payload.value !== "string" || !payload.value) {
+ throw new Error(adkT("client.copySecretFailed"));
+ }
+ await navigator.clipboard.writeText(payload.value);
+}
+
export function getCachedRuntimeDetail(
runtimeId: string,
region = "cn-beijing",
@@ -5119,3 +5245,10 @@ export async function updateSandboxTool(kind: SandboxToolKind): Promise<{
if (typeof payload.updated !== "boolean") throw new Error(adkT("client.invalidSandboxUpdate"));
return { updated: payload.updated, state: sandboxImageState(payload.state) };
}
+
+export function listMpaCronTasks(runtime: MpaRuntime, offset: number, query: string, signal?: AbortSignal) {
+ return fetchMpaCronTasks(apiFetch, runtime, offset, query, signal);
+}
+
+export function requestMpaTask(runtime: MpaRuntime, method: string, suffix: string, payload?: unknown, signal?: AbortSignal) { return manageMpaTask(apiFetch, runtime, method, suffix, payload, signal); }
+export function listMpaRuns(runtime: MpaRuntime, taskId: string, offset: number, signal?: AbortSignal) { return fetchMpaRuns(apiFetch, runtime, taskId, offset, signal); }
diff --git a/frontend/src/adk/mpaCronTasks.ts b/frontend/src/adk/mpaCronTasks.ts
new file mode 100644
index 000000000..00dfb523f
--- /dev/null
+++ b/frontend/src/adk/mpaCronTasks.ts
@@ -0,0 +1,210 @@
+import type { AdkEndpoint } from "./client";
+
+export interface MpaRuntime {
+ runtimeId: string;
+ region: string;
+ name: string;
+}
+export interface MpaCronTask {
+ id: string;
+ name: string;
+ enabled: boolean;
+ schedule: Record & { type: string };
+ prompt?: string;
+ agentId?: string;
+ version?: number;
+ delivery?: Record;
+ jitterSeconds?: number;
+ timeoutSeconds?: number;
+ lastRunAt?: string | null;
+ runningAt?: string | null;
+ createdAt?: string;
+ nextRunAt?: string | null;
+ lastRunStatus?: string | null;
+}
+export interface MpaCronTaskPage {
+ overview?: { executionCount: number; successRate: number };
+ items: MpaCronTask[];
+ total: number;
+ hasMore: boolean;
+ nextOffset: number | null;
+}
+type Request = (
+ path: string,
+ init: RequestInit,
+ endpoint: AdkEndpoint,
+) => Promise;
+
+function validSchedule(schedule: Record): boolean {
+ if (schedule.timezone != null) {
+ if (typeof schedule.timezone !== "string") return false;
+ try {
+ new Intl.DateTimeFormat("en-US", { timeZone: schedule.timezone });
+ } catch {
+ return false;
+ }
+ }
+ if (
+ schedule.runAt != null &&
+ (typeof schedule.runAt !== "string" ||
+ !Number.isFinite(Date.parse(schedule.runAt)))
+ )
+ return false;
+ return [schedule.weekdays, schedule.monthDays].every(
+ (days) =>
+ days == null || (Array.isArray(days) && days.every(Number.isInteger)),
+ );
+}
+
+export async function fetchMpaCronTasks(
+ request: Request,
+ runtime: MpaRuntime,
+ offset: number,
+ query: string,
+ signal?: AbortSignal,
+): Promise {
+ const response = await request(
+ `/web/mpa-cron/${encodeURIComponent(runtime.runtimeId)}?region=${encodeURIComponent(runtime.region)}&offset=${offset}&query=${encodeURIComponent(query)}`,
+ { signal },
+ {},
+ );
+ // Do not expose arbitrary upstream bodies, which may contain credentials.
+ if (!response.ok) throw new Error(`MPA_HTTP_${response.status}`);
+ let data;
+ try {
+ data = await response.json();
+ } catch {
+ throw new Error("MPA_INVALID_RESPONSE");
+ }
+ if (
+ !data ||
+ !Array.isArray(data.items) ||
+ !Number.isInteger(data.total) ||
+ data.total < 0 ||
+ typeof data.hasMore !== "boolean" ||
+ (data.hasMore &&
+ (!Number.isInteger(data.nextOffset) || data.nextOffset <= offset)) ||
+ (data.overview != null &&
+ (!Number.isFinite(data.overview.executionCount) ||
+ !Number.isFinite(data.overview.successRate))) ||
+ !data.items.every(
+ (item: MpaCronTask) =>
+ item &&
+ typeof item.id === "string" &&
+ typeof item.name === "string" &&
+ typeof item.enabled === "boolean" &&
+ (item.prompt == null || typeof item.prompt === "string") &&
+ item.schedule &&
+ typeof item.schedule.type === "string" &&
+ validSchedule(item.schedule) &&
+ [item.agentId, item.lastRunAt, item.runningAt, item.createdAt].every(
+ (value) => value == null || typeof value === "string",
+ ) &&
+ (item.nextRunAt == null || typeof item.nextRunAt === "string") &&
+ (item.lastRunStatus == null || typeof item.lastRunStatus === "string"),
+ )
+ ) {
+ throw new Error("MPA_INVALID_RESPONSE");
+ }
+ return data;
+}
+
+export interface MpaRun {
+ id: string;
+ status: string;
+ startedAt?: string | null;
+ scheduledAt: string;
+ durationMs?: number | null;
+ errorMessage?: string | null;
+ sessionId?: string | null;
+}
+export interface MpaRunPage {
+ items: MpaRun[];
+ total: number;
+ hasMore: boolean;
+ nextOffset: number | null;
+}
+export type TaskFields = Pick & {
+ agentId?: string;
+ prompt: string;
+ delivery?: Record;
+ jitterSeconds?: number;
+ timeoutSeconds?: number;
+};
+export async function manageMpaTask(
+ request: Request,
+ runtime: MpaRuntime,
+ method: string,
+ suffix: string,
+ payload?: unknown,
+ signal?: AbortSignal,
+): Promise> {
+ const response = await request(
+ `/web/mpa-cron/${encodeURIComponent(runtime.runtimeId)}${suffix}?region=${encodeURIComponent(runtime.region)}`,
+ {
+ method,
+ signal,
+ ...(payload === undefined
+ ? {}
+ : {
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ }),
+ },
+ {},
+ );
+ if (!response.ok) throw new Error(`MPA_HTTP_${response.status}`);
+ let data;
+ try {
+ data = await response.json();
+ } catch {
+ throw new Error("MPA_INVALID_RESPONSE");
+ }
+ if (!data || typeof data !== "object" || Array.isArray(data))
+ throw new Error("MPA_INVALID_RESPONSE");
+ return data;
+}
+
+export async function fetchMpaRuns(
+ request: Request,
+ runtime: MpaRuntime,
+ taskId: string,
+ offset: number,
+ signal?: AbortSignal,
+): Promise {
+ const response = await request(
+ `/web/mpa-cron/${encodeURIComponent(runtime.runtimeId)}/${encodeURIComponent(taskId)}/runs?region=${encodeURIComponent(runtime.region)}&offset=${offset}`,
+ { signal },
+ {},
+ );
+ if (!response.ok) throw new Error(`MPA_HTTP_${response.status}`);
+ let data;
+ try {
+ data = await response.json();
+ } catch {
+ throw new Error("MPA_INVALID_RESPONSE");
+ }
+ if (
+ !data ||
+ !Array.isArray(data.items) ||
+ !Number.isInteger(data.total) ||
+ data.total < 0 ||
+ typeof data.hasMore !== "boolean" ||
+ (data.hasMore &&
+ (!Number.isInteger(data.nextOffset) || data.nextOffset <= offset)) ||
+ !data.items.every(
+ (run: MpaRun) =>
+ run &&
+ typeof run.id === "string" &&
+ typeof run.status === "string" &&
+ typeof run.scheduledAt === "string" &&
+ [run.startedAt, run.errorMessage, run.sessionId].every(
+ (value) => value == null || typeof value === "string",
+ ) &&
+ (run.durationMs == null ||
+ (Number.isFinite(run.durationMs) && run.durationMs >= 0)),
+ )
+ )
+ throw new Error("MPA_INVALID_RESPONSE");
+ return data;
+}
diff --git a/frontend/src/blocks.ts b/frontend/src/blocks.ts
index 0565cd7cc..930f82721 100644
--- a/frontend/src/blocks.ts
+++ b/frontend/src/blocks.ts
@@ -94,7 +94,13 @@ export interface CodexSandboxActivity {
export type Block =
| { kind: "progress"; text: string }
- | { kind: "thinking"; text: string; done: boolean }
+ | { kind: "activity-source"; label: string }
+ | {
+ kind: "thinking";
+ text: string;
+ done: boolean;
+ thoughtKind?: "reasoning" | "thought";
+ }
| { kind: "text"; text: string }
| {
kind: "tool";
@@ -105,6 +111,7 @@ export type Block =
done: boolean;
status?: "running" | "completed" | "failed";
defaultOpen?: boolean;
+ source?: "codex-sandbox" | "studio" | "runtime";
codexActivity?: CodexSandboxActivity;
}
| {
@@ -148,6 +155,7 @@ export interface Acc {
}
export interface TurnMeta {
+ a2aStatus?: string;
author?: string;
localId?: string;
streaming?: boolean;
@@ -183,6 +191,7 @@ export function emptyAcc(): Acc {
}
const MAX_PENDING_CODEX_PROGRESS = 64;
+const MAX_SEEN_EVENT_IDS = 2_048;
function applyCodexProgressToTool(
blocks: Block[],
@@ -198,7 +207,10 @@ function applyCodexProgressToTool(
if (block.kind !== "tool" || block.name !== progress.toolName) continue;
if (block.callId === progress.requestId) {
if (block.done) return "completed";
- block.codexActivity = applyCodexSandboxProgress(block.codexActivity, progress);
+ block.codexActivity = applyCodexSandboxProgress(
+ block.codexActivity,
+ progress,
+ );
block.status = progress.terminalStatus ?? "running";
if (progress.terminalStatus) block.done = true;
return "applied";
@@ -214,7 +226,10 @@ function applyCodexProgressToTool(
if (fallbackIndex >= 0) {
const block = blocks[fallbackIndex];
if (block.kind !== "tool") return "unmatched";
- block.codexActivity = applyCodexSandboxProgress(block.codexActivity, progress);
+ block.codexActivity = applyCodexSandboxProgress(
+ block.codexActivity,
+ progress,
+ );
block.status = progress.terminalStatus ?? "running";
if (progress.terminalStatus) block.done = true;
return "applied";
@@ -227,10 +242,13 @@ function codexResponseStatus(response: unknown): "completed" | "failed" {
return "completed";
}
const result = response as Record;
- const status = typeof result.status === "string" ? result.status.toLowerCase() : "";
+ const status =
+ typeof result.status === "string" ? result.status.toLowerCase() : "";
if (
- result.ok === false
- || ["error", "failed", "denied", "declined", "cancelled", "timeout"].includes(status)
+ result.ok === false ||
+ ["error", "failed", "denied", "declined", "cancelled", "timeout"].includes(
+ status,
+ )
) {
return "failed";
}
@@ -238,7 +256,8 @@ function codexResponseStatus(response: unknown): "completed" | "failed" {
}
function codexDirectAnswer(response: unknown): string {
- if (!response || typeof response !== "object" || Array.isArray(response)) return "";
+ if (!response || typeof response !== "object" || Array.isArray(response))
+ return "";
const result = response as Record;
if (result.ok !== true || typeof result.message !== "string") return "";
return result.message.trim();
@@ -259,6 +278,60 @@ export interface AssistantEventProjection {
const fnCall = (p: AdkPart) => p.functionCall ?? p.function_call;
const fnResp = (p: AdkPart) => p.functionResponse ?? p.function_response;
+function toolNamesMatch(left: string, right: string): boolean {
+ if (left === right) return true;
+ const commandAliases = new Set([
+ "exec_command",
+ "commandExecution",
+ "command_execution",
+ "Run command",
+ ]);
+ return commandAliases.has(left) && commandAliases.has(right);
+}
+
+function toolResponseState(
+ response: unknown,
+ partial = false,
+): { done: boolean; status: "running" | "completed" | "failed" } {
+ const responseStatus =
+ response && typeof response === "object"
+ ? String((response as Record).status ?? "").toLowerCase()
+ : "";
+ const failed =
+ ["failed", "error", "cancelled", "denied", "timeout"].includes(
+ responseStatus,
+ ) || (response as Record | undefined)?.ok === false;
+ const running = ["running", "started", "working"].includes(
+ responseStatus,
+ );
+ return {
+ done: failed || !(partial || running),
+ status: failed ? "failed" : partial || running ? "running" : "completed",
+ };
+}
+
+export function flattenCodexActivityBlocks(blocks: Block[]): Block[] {
+ return blocks.flatMap((block) => {
+ if (
+ block.kind !== "tool" ||
+ block.name !== "delegate_to_codex_sandbox" ||
+ !block.codexActivity?.items.length
+ )
+ return [block];
+ return [
+ {
+ kind: "activity-source" as const,
+ label: block.codexActivity.title || "Codex Sandbox",
+ },
+ ...block.codexActivity.items.map(({ block: child }) =>
+ child.kind === "tool"
+ ? { ...child, source: "codex-sandbox" as const }
+ : child,
+ ),
+ ];
+ });
+}
+
function transferAgentName(args: unknown): string {
if (!args || typeof args !== "object") return "";
const record = args as Record;
@@ -278,18 +351,20 @@ export function attachmentsFromParts(parts: AdkPart[]): AttachmentView[] {
const files: AttachmentView[] = [];
for (const [index, p] of parts.entries()) {
const metadata = (p.partMetadata ?? p.part_metadata) as
- | Record
- | undefined;
- const transport = metadata?.veadkTransport as Record | undefined;
+ Record | undefined;
+ const transport = metadata?.veadkTransport as
+ Record | undefined;
if (transport?.hidden === true) continue;
const stored = metadata?.veadkMedia as Record | undefined;
if (typeof stored?.uri === "string") {
files.push({
id: String(stored.id ?? stored.uri),
- mimeType: typeof stored.mimeType === "string" ? stored.mimeType : undefined,
+ mimeType:
+ typeof stored.mimeType === "string" ? stored.mimeType : undefined,
uri: stored.uri,
name: typeof stored.name === "string" ? stored.name : undefined,
- sizeBytes: typeof stored.sizeBytes === "number" ? stored.sizeBytes : undefined,
+ sizeBytes:
+ typeof stored.sizeBytes === "number" ? stored.sizeBytes : undefined,
});
continue;
}
@@ -319,9 +394,9 @@ export function attachmentsFromParts(parts: AdkPart[]): AttachmentView[] {
function visiblePartText(part: AdkPart): string | undefined {
const metadata = (part.partMetadata ?? part.part_metadata) as
- | Record
- | undefined;
- const transport = metadata?.veadkTransport as Record | undefined;
+ Record | undefined;
+ const transport = metadata?.veadkTransport as
+ Record | undefined;
return transport?.hideText === true ? undefined : part.text;
}
@@ -334,7 +409,9 @@ const AGENT_NODE_TYPES = new Set([
]);
/** Restore slash-skill and @agent selections persisted in part metadata. */
-export function invocationFromParts(parts: AdkPart[]): FrontendInvocation | undefined {
+export function invocationFromParts(
+ parts: AdkPart[],
+): FrontendInvocation | undefined {
for (const part of parts) {
const raw = (part.partMetadata ?? part.part_metadata)?.veadkInvocation;
if (!raw || typeof raw !== "object") continue;
@@ -344,10 +421,15 @@ export function invocationFromParts(parts: AdkPart[]): FrontendInvocation | unde
if (!item || typeof item !== "object") return [];
const skill = item as Record;
return typeof skill.name === "string"
- ? [{
+ ? [
+ {
name: skill.name,
- description: typeof skill.description === "string" ? skill.description : "",
- }]
+ description:
+ typeof skill.description === "string"
+ ? skill.description
+ : "",
+ },
+ ]
: [];
})
: [];
@@ -365,9 +447,12 @@ export function invocationFromParts(parts: AdkPart[]): FrontendInvocation | unde
) {
targetAgent = {
name: target.name,
- description: typeof target.description === "string" ? target.description : "",
+ description:
+ typeof target.description === "string" ? target.description : "",
type: type as AgentNodeType,
- path: target.path.filter((item): item is string => typeof item === "string"),
+ path: target.path.filter(
+ (item): item is string => typeof item === "string",
+ ),
};
}
}
@@ -383,24 +468,55 @@ function appendAttachments(blocks: Block[], files: AttachmentView[]) {
else blocks.push({ kind: "attachment", files });
}
-function appendArtifacts(blocks: Block[], files: { filename: string; version: number }[]) {
+function appendArtifacts(
+ blocks: Block[],
+ files: { filename: string; version: number }[],
+) {
if (!files.length) return;
const last = blocks[blocks.length - 1];
if (last?.kind === "artifact") {
for (const file of files) {
- if (!last.files.some((item) =>
- item.filename === file.filename && item.version === file.version
- )) last.files.push(file);
+ if (
+ !last.files.some(
+ (item) =>
+ item.filename === file.filename && item.version === file.version,
+ )
+ )
+ last.files.push(file);
}
return;
}
blocks.push({ kind: "artifact", files });
}
-function appendText(blocks: Block[], kind: "thinking" | "text", text: string) {
+function appendText(
+ blocks: Block[],
+ kind: "thinking" | "text",
+ text: string,
+ thoughtKind?: "reasoning" | "thought",
+) {
const last = blocks[blocks.length - 1];
- if (last && last.kind === kind) last.text += text;
- else blocks.push(kind === "thinking" ? { kind, text, done: false } : { kind, text });
+ if (
+ last &&
+ last.kind === kind &&
+ (last.kind !== "thinking" || last.thoughtKind === thoughtKind)
+ )
+ last.text += text;
+ else
+ blocks.push(
+ kind === "thinking"
+ ? { kind, text, done: false, thoughtKind }
+ : { kind, text },
+ );
+}
+
+function thoughtKindOf(event: AdkEvent): "reasoning" | "thought" {
+ const metadata = event.customMetadata ?? event.custom_metadata;
+ if (metadata && typeof metadata === "object") {
+ const value = (metadata as Record).thoughtKind;
+ if (value === "reasoning" || value === "thought") return value;
+ }
+ return "reasoning";
}
function closeThinking(blocks: Block[]) {
@@ -430,14 +546,20 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
for (let index = blocks.length - 1; index >= 0; index -= 1) {
const block = blocks[index];
if (
- block.kind !== "tool"
- || block.done
- || block.name !== progress.toolName
- || (progress.requestId && block.callId && block.callId !== progress.requestId)
+ block.kind !== "tool" ||
+ block.done ||
+ block.name !== progress.toolName ||
+ (progress.requestId &&
+ block.callId &&
+ block.callId !== progress.requestId)
) {
continue;
}
- block.response = applyBranchCompareProgress(block.args, block.response, progress);
+ block.response = applyBranchCompareProgress(
+ block.args,
+ block.response,
+ progress,
+ );
block.status = "running";
break;
}
@@ -445,8 +567,9 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
for (const progress of codexProgressUpdates) {
const outcome = applyCodexProgressToTool(blocks, progress);
if (outcome === "unmatched") {
- pendingCodexProgress = [...pendingCodexProgress, progress]
- .slice(-MAX_PENDING_CODEX_PROGRESS);
+ pendingCodexProgress = [...pendingCodexProgress, progress].slice(
+ -MAX_PENDING_CODEX_PROGRESS,
+ );
}
}
return { blocks, liveStart, pendingCodexProgress };
@@ -458,7 +581,12 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
for (const p of parts) {
const text = visiblePartText(p);
if (typeof text === "string" && text)
- appendText(blocks, p.thought ? "thinking" : "text", text);
+ appendText(
+ blocks,
+ p.thought ? "thinking" : "text",
+ text,
+ p.thought ? thoughtKindOf(ev) : undefined,
+ );
}
return { blocks, liveStart, pendingCodexProgress };
}
@@ -472,7 +600,12 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
const files = attachmentsFromParts([p]);
const text = visiblePartText(p);
if (typeof text === "string" && text) {
- appendText(blocks, p.thought ? "thinking" : "text", text);
+ appendText(
+ blocks,
+ p.thought ? "thinking" : "text",
+ text,
+ p.thought ? thoughtKindOf(ev) : undefined,
+ );
} else if (files.length) {
closeThinking(blocks);
appendAttachments(blocks, files);
@@ -491,7 +624,9 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
const authConfig = args.authConfig ?? args.auth_config ?? args;
// functionCallId looks like "_adk_toolset_auth_McpToolset"; surface the
// toolset name so the card can say what is being authorized.
- const rawId = String(args.functionCallId ?? args.function_call_id ?? "");
+ const rawId = String(
+ args.functionCallId ?? args.function_call_id ?? "",
+ );
const label = rawId.replace(/^_adk_toolset_auth_/, "") || undefined;
blocks.push({
kind: "auth",
@@ -502,6 +637,23 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
done: false,
});
} else {
+ const existingTool = fc.id
+ ? [...blocks]
+ .reverse()
+ .find(
+ (block: Block) =>
+ block.kind === "tool" && block.callId === fc.id,
+ )
+ : undefined;
+ if (existingTool?.kind === "tool") {
+ existingTool.name = fc.name ?? existingTool.name;
+ existingTool.args = fc.args ?? existingTool.args;
+ if (existingTool.response === undefined) {
+ existingTool.done = false;
+ existingTool.status = "running";
+ }
+ continue;
+ }
const toolBlock: Extract = {
kind: "tool",
name: fc.name ?? "",
@@ -514,8 +666,8 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
const stillPending: CodexSandboxProgress[] = [];
for (const progress of pendingCodexProgress) {
if (
- progress.toolName === toolBlock.name
- && progress.requestId === toolBlock.callId
+ progress.toolName === toolBlock.name &&
+ progress.requestId === toolBlock.callId
) {
applyCodexProgressToTool(blocks, progress);
} else {
@@ -546,34 +698,64 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
}
}
}
+ let matchedTool = false;
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i];
- const isCodexTool = b.kind === "tool" && b.name === "delegate_to_codex_sandbox";
+ const isCodexTool =
+ b.kind === "tool" && b.name === "delegate_to_codex_sandbox";
if (
- b.kind === "tool"
- && (!b.done || isCodexTool)
- && b.name === fr.name
- && (!fr.id || !b.callId || b.callId === fr.id)
+ b.kind === "tool" &&
+ (!b.done || isCodexTool || Boolean(fr.id && b.callId === fr.id)) &&
+ ((Boolean(fr.id) && Boolean(b.callId) && b.callId === fr.id) ||
+ ((!fr.id || !b.callId) && toolNamesMatch(b.name, fr.name ?? "")))
) {
const previousAnswer = isCodexTool
? codexDirectAnswer(b.response)
: "";
- b.done = true;
+ const responseState = toolResponseState(
+ fr.response,
+ ev.partial === true,
+ );
+ b.done = responseState.done;
b.response = fr.response;
+ b.status = responseState.status;
if (isCodexTool) {
b.codexActivity = hydrateCodexSandboxActivity(
b.codexActivity,
fr.response,
);
- b.status = codexResponseStatus(fr.response);
+ b.status =
+ responseState.status === "running"
+ ? "running"
+ : codexResponseStatus(fr.response);
const answer = codexDirectAnswer(fr.response);
if (answer && answer !== previousAnswer) {
appendText(blocks, "text", answer);
}
}
+ matchedTool = true;
break;
}
}
+ if (
+ !matchedTool &&
+ fr.name !== TRANSFER_AGENT_TOOL &&
+ fr.name !== REQUEST_EUC &&
+ fr.name !== A2UI_TOOL
+ ) {
+ const responseState = toolResponseState(
+ fr.response,
+ ev.partial === true,
+ );
+ blocks.push({
+ kind: "tool",
+ name: fr.name ?? "",
+ callId: fr.id,
+ response: fr.response,
+ done: responseState.done,
+ status: responseState.status,
+ });
+ }
if (fr.name === A2UI_TOOL) {
const msgs = (fr.response?.[VALIDATED_JSON_KEY] as A2uiMessage[]) ?? [];
if (msgs.length) {
@@ -588,7 +770,10 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
if (artifactDelta) {
appendArtifacts(
blocks,
- Object.entries(artifactDelta).map(([filename, version]) => ({ filename, version })),
+ Object.entries(artifactDelta).map(([filename, version]) => ({
+ filename,
+ version,
+ })),
);
}
closeThinking(blocks); // a consolidated thinking segment is complete
@@ -608,26 +793,42 @@ function completesAssistantResponse(ev: AdkEvent, blocks: Block[]): boolean {
});
const hasA2ui = parts.some((part) => {
const response = fnResp(part);
- return response?.name === A2UI_TOOL &&
+ return (
+ response?.name === A2UI_TOOL &&
Array.isArray(response.response?.[VALIDATED_JSON_KEY]) &&
- response.response[VALIDATED_JSON_KEY].length > 0;
+ response.response[VALIDATED_JSON_KEY].length > 0
+ );
});
const artifactDelta = ev.actions?.artifactDelta ?? ev.actions?.artifact_delta;
- const hasArtifact = Boolean(artifactDelta && Object.keys(artifactDelta).length > 0);
+ const hasArtifact = Boolean(
+ artifactDelta && Object.keys(artifactDelta).length > 0,
+ );
const agentEnded = Boolean(
- ev.actions?.endOfAgent ?? ev.actions?.end_of_agent ?? ev.actions?.escalate
+ ev.actions?.endOfAgent ?? ev.actions?.end_of_agent ?? ev.actions?.escalate,
);
- const hasAnswerBlock = blocks.some((block) =>
+ const hasAnswerBlock = blocks.some(
+ (block) =>
block.kind === "text" ||
block.kind === "attachment" ||
block.kind === "artifact" ||
block.kind === "a2ui" ||
- block.kind === "delivery"
+ block.kind === "delivery",
+ );
+ return (
+ hasFinalAnswerPart ||
+ hasA2ui ||
+ hasArtifact ||
+ (agentEnded && hasAnswerBlock)
);
- return hasFinalAnswerPart || hasA2ui || hasArtifact || (agentEnded && hasAnswerBlock);
+}
+
+function a2aStatusOf(ev: AdkEvent): string | undefined {
+ const status = (ev.customMetadata ?? ev.custom_metadata)?.a2aStatus;
+ return typeof status === "string" ? status : undefined;
}
function eventAffectsAssistantTurn(ev: AdkEvent): boolean {
+ if (a2aStatusOf(ev)) return true;
const artifactDelta = ev.actions?.artifactDelta ?? ev.actions?.artifact_delta;
if (artifactDelta && Object.keys(artifactDelta).length > 0) return true;
return (ev.content?.parts ?? []).some((part) =>
@@ -635,8 +836,8 @@ function eventAffectsAssistantTurn(ev: AdkEvent): boolean {
visiblePartText(part) ||
attachmentsFromParts([part]).length > 0 ||
fnCall(part) ||
- fnResp(part)
- )
+ fnResp(part),
+ ),
);
}
@@ -651,6 +852,8 @@ export function createAssistantEventProjector(
) {
let sequence = 0;
const active = new Map();
+ const seenEventIds = new Set();
+ const eventIdOrder: string[] = [];
let seededKey: string | undefined;
const keyFor = (author: string, invocationId: string) =>
@@ -659,7 +862,8 @@ export function createAssistantEventProjector(
if (initialTurn?.role === "assistant") {
const author = initialTurn.meta?.author ?? "";
const invocationId = initialTurn.meta?.invocationId ?? "";
- const localId = initialTurn.meta?.localId ?? `${localIdPrefix}-${sequence++}`;
+ const localId =
+ initialTurn.meta?.localId ?? `${localIdPrefix}-${sequence++}`;
const acc = emptyAcc();
acc.blocks = initialTurn.blocks;
acc.liveStart = initialTurn.blocks.length;
@@ -678,6 +882,22 @@ export function createAssistantEventProjector(
return {
project(ev: AdkEvent): AssistantEventProjection {
+ // Some Runtime streams replay the submitted user event. The UI already
+ // inserted that turn; agent-authored tool responses still belong here.
+ if (ev.author === "user" || (ev.id && seenEventIds.has(ev.id))) {
+ return {
+ turn: { role: "assistant", blocks: [] },
+ completed: false,
+ ignored: true,
+ };
+ }
+ if (ev.id) {
+ seenEventIds.add(ev.id);
+ eventIdOrder.push(ev.id);
+ if (eventIdOrder.length > MAX_SEEN_EVENT_IDS) {
+ seenEventIds.delete(eventIdOrder.shift()!);
+ }
+ }
const author = ev.author && ev.author !== "user" ? ev.author : "";
const invocationId = ev.invocationId ?? ev.invocation_id ?? "";
const key = keyFor(author, invocationId);
@@ -702,7 +922,10 @@ export function createAssistantEventProjector(
state = {
acc: emptyAcc(),
localId,
- meta: { author: author || undefined, invocationId: invocationId || undefined },
+ meta: {
+ author: author || undefined,
+ invocationId: invocationId || undefined,
+ },
};
}
@@ -714,6 +937,7 @@ export function createAssistantEventProjector(
author: author || state.meta.author,
localId: state.localId,
streaming: !completed,
+ a2aStatus: a2aStatusOf(ev),
tokens: usage?.totalTokenCount || state.meta.tokens,
ts: ev.timestamp || state.meta.ts,
invocationId: invocationId || state.meta.invocationId,
@@ -736,7 +960,9 @@ export function createAssistantEventProjector(
finish(): Turn[] {
const turns = [...active.values()].map((state): Turn => ({
role: "assistant",
- blocks: state.acc.blocks,
+ blocks: state.acc.blocks.map((block) =>
+ block.kind === "thinking" ? { ...block, done: true } : block
+ ),
meta: { ...state.meta, streaming: false },
}));
active.clear();
@@ -745,7 +971,10 @@ export function createAssistantEventProjector(
};
}
-export function upsertProjectedAssistantTurn(turns: Turn[], projected: Turn): Turn[] {
+export function upsertProjectedAssistantTurn(
+ turns: Turn[],
+ projected: Turn,
+): Turn[] {
const localId = projected.meta?.localId;
if (!localId) return [...turns, projected];
const index = turns.findIndex((turn) => turn.meta?.localId === localId);
@@ -776,7 +1005,10 @@ export function eventsToTurns(
if (turns[i].role !== "assistant") continue;
for (let j = turns[i].blocks.length - 1; j >= 0; j--) {
const b = turns[i].blocks[j];
- if (b.kind === "auth") { b.done = true; break; }
+ if (b.kind === "auth") {
+ b.done = true;
+ break;
+ }
}
break;
}
@@ -799,7 +1031,8 @@ export function eventsToTurns(
if (files.length) blocks.push({ kind: "attachment", files });
if (text) blocks.push({ kind: "text", text });
turns.push({ role: "user", blocks, meta: { ts: ev.timestamp } });
- projector = createAssistantEventProjector("adk-history");
+ // Upserts span the full history, so each user turn needs a unique prefix.
+ projector = createAssistantEventProjector(`adk-history-${turns.length}`);
} else {
const projection = projector.project(ev);
if (!projection.ignored) {
diff --git a/frontend/src/cronjobs/CronJobs.tsx b/frontend/src/cronjobs/CronJobs.tsx
index 9f3283f94..d8bb9e442 100644
--- a/frontend/src/cronjobs/CronJobs.tsx
+++ b/frontend/src/cronjobs/CronJobs.tsx
@@ -1,3 +1,5 @@
+import { MpaCronTasks } from "./MpaCronTasks";
+import type { MpaRuntime } from "../adk/mpaCronTasks";
import {
useCallback,
useEffect,
@@ -77,6 +79,7 @@ import { CronJobFinalAnswer } from "./CronJobFinalAnswer";
import "./CronJobs.css";
interface CronJobsProps {
+ selectedRuntime?: MpaRuntime;
cloudProvider: CloudProvider;
}
@@ -560,7 +563,8 @@ function JobDetail({
);
}
-export function CronJobs({ cloudProvider }: CronJobsProps) {
+export function CronJobs({ cloudProvider, selectedRuntime }: CronJobsProps) {
+ const [source, setSource] = useState("studio");
useTranslation("cronjobs");
const [jobs, setJobs] = useState([]);
const [runtimes, setRuntimes] = useState([]);
@@ -744,6 +748,11 @@ export function CronJobs({ cloudProvider }: CronJobsProps) {
className="cronjobs-page-head"
title={cronText("page.title")}
/>
+
+
+
+ {source === "runtime" ? : <>
{loading && jobs.length === 0 ? : error ? {cronText("page.loadFailed")}{error} : 0} onCreate={() => setDrawerJob(null)} onSelect={(job) => setSelectedId(job.jobId)} />}
+ >}
{drawerJob !== undefined ? setDrawerJob(undefined)} onSubmit={submitDrawer} /> : null}
);
diff --git a/frontend/src/cronjobs/MpaCronTasks.css b/frontend/src/cronjobs/MpaCronTasks.css
new file mode 100644
index 000000000..77d73828c
--- /dev/null
+++ b/frontend/src/cronjobs/MpaCronTasks.css
@@ -0,0 +1,427 @@
+.mpa-cron {
+ min-width: 0;
+ padding-bottom: 24px;
+ font-size: 13px;
+ color: hsl(var(--foreground));
+}
+.mpa-cron-target {
+ display: flex;
+ gap: 8px 16px;
+ flex-wrap: wrap;
+ overflow-wrap: anywhere;
+ color: hsl(var(--muted-foreground));
+ font-size: 12px;
+ margin-bottom: 20px;
+}
+.mpa-cron-target strong {
+ color: hsl(var(--foreground));
+ font-weight: 500;
+}
+.mpa-cron-heading,
+.mpa-toolbar,
+.mpa-calendar-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+.mpa-cron-heading {
+ margin-bottom: 24px;
+}
+.mpa-segment {
+ display: flex;
+ padding: 3px;
+ background: hsl(var(--muted));
+ border-radius: 999px;
+}
+.mpa-segment button {
+ border: 0;
+ background: transparent;
+ color: hsl(var(--muted-foreground));
+ padding: 4px 16px;
+ border-radius: 999px;
+ min-height: 28px;
+ cursor: pointer;
+}
+.mpa-segment button[aria-pressed="true"] {
+ background: hsl(var(--panel));
+ color: hsl(var(--foreground));
+}
+.mpa-create {
+ border-radius: 999px !important;
+ gap: 8px;
+}
+.mpa-cron-search {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+.mpa-cron-search input {
+ width: 180px;
+ min-width: 0;
+ height: 34px;
+ border: 1px solid hsl(var(--border));
+ border-radius: 6px;
+ padding: 0 12px;
+ background: hsl(var(--panel));
+ color: inherit;
+}
+.mpa-cron-overview {
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+ color: hsl(var(--muted-foreground));
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+ margin-left: auto;
+}
+.mpa-cron-overview strong {
+ color: hsl(var(--foreground));
+ font-weight: 500;
+}
+.mpa-cron-table {
+ overflow-x: auto;
+ border: 1px solid hsl(var(--border));
+ border-radius: 8px;
+ margin-top: 12px;
+}
+.mpa-cron table,
+.mpa-detail table {
+ width: 100%;
+ border-collapse: collapse;
+ text-align: left;
+ table-layout: fixed;
+ min-width: 850px;
+}
+.mpa-cron th,
+.mpa-cron td,
+.mpa-detail th,
+.mpa-detail td {
+ padding: 18px 20px;
+ border-bottom: 1px solid hsl(var(--border));
+ vertical-align: middle;
+ font-weight: 400;
+ overflow-wrap: anywhere;
+}
+.mpa-cron th {
+ font-weight: 500;
+ padding-top: 12px;
+ padding-bottom: 12px;
+}
+.mpa-cron tbody tr:last-child td {
+ border-bottom: 0;
+}
+.mpa-cron tbody tr:hover {
+ background: hsl(var(--muted) / 0.45);
+}
+.mpa-cron th:last-child {
+ width: 140px;
+}
+.mpa-cron th:nth-child(3) {
+ width: 24%;
+}
+.mpa-cron th:nth-child(4) {
+ width: 180px;
+}
+.mpa-cron th .deployment-select {
+ width: 100%;
+}
+.mpa-task-link {
+ border: 0;
+ padding: 0;
+ background: none;
+ color: hsl(var(--feature-link));
+ cursor: pointer;
+ text-align: left;
+ line-height: 1.6;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.mpa-status {
+ display: inline-block;
+ border: 1px solid hsl(var(--border));
+ border-radius: 4px;
+ padding: 2px 8px;
+ white-space: nowrap;
+ color: hsl(var(--muted-foreground));
+ font-size: 12px;
+}
+.mpa-status-Succeeded {
+ color: hsl(150 52% 35%);
+ border-color: hsl(150 35% 75%);
+}
+.mpa-status-Failed,
+.mpa-status-TimedOut {
+ color: hsl(var(--destructive));
+ border-color: hsl(var(--destructive) / 0.3);
+}
+.mpa-status-Running {
+ color: hsl(var(--primary));
+}
+.mpa-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.mpa-icon-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ background: none;
+ color: hsl(var(--muted-foreground));
+ width: 32px;
+ height: 32px;
+ border-radius: 6px;
+ cursor: pointer;
+}
+.mpa-icon-button:hover {
+ background: hsl(var(--muted));
+ color: hsl(var(--foreground));
+}
+.mpa-switch {
+ display: inline-flex;
+ align-items: center;
+ border: 0;
+ padding: 3px;
+ border-radius: 999px;
+ background: hsl(var(--muted));
+ width: 32px;
+ height: 20px;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+.mpa-switch span {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: hsl(var(--panel));
+ border: 1px solid hsl(var(--border));
+}
+.mpa-switch[aria-checked="true"] {
+ background: hsl(var(--primary));
+ justify-content: flex-end;
+}
+.mpa-cron button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.mpa-cron :focus-visible {
+ outline: 2px solid hsl(var(--primary));
+ outline-offset: 2px;
+}
+.mpa-cron-pages {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-top: 16px;
+ font-variant-numeric: tabular-nums;
+}
+.mpa-cron-pages span {
+ margin-right: auto;
+ color: hsl(var(--muted-foreground));
+}
+.mpa-state {
+ padding: 40px 24px;
+ text-align: center;
+ color: hsl(var(--muted-foreground));
+}
+.mpa-cron-error {
+ color: hsl(var(--destructive));
+ line-height: 1.6;
+}
+.mpa-dialog {
+ width: min(680px, calc(100vw - 32px));
+}
+.mpa-editor fieldset {
+ border: 0;
+ padding: 0;
+ margin: 0;
+ display: grid;
+ gap: 16px;
+ min-width: 0;
+}
+.mpa-editor label {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ font-size: 13px;
+}
+.mpa-editor textarea {
+ height: auto;
+ resize: vertical;
+ min-height: 120px;
+}
+.mpa-fields {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+.mpa-editor .mpa-checkbox {
+ flex-direction: row;
+ align-items: center;
+}
+.mpa-details {
+ display: grid;
+ grid-template-columns: 100px 1fr;
+ gap: 16px;
+ margin: 0;
+}
+.mpa-details dt {
+ color: hsl(var(--muted-foreground));
+}
+.mpa-details dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+.mpa-prompt {
+ white-space: pre-wrap;
+ max-height: 220px;
+ overflow: auto;
+}
+.mpa-history-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 24px;
+}
+.mpa-history-title h3 {
+ font-size: 15px;
+ font-weight: 500;
+}
+.mpa-detail {
+ width: min(900px, calc(100vw - 32px));
+}
+.mpa-detail table {
+ min-width: 650px;
+}
+.mpa-calendar-bar {
+ margin: 20px 0 12px;
+ gap: 8px;
+}
+.mpa-calendar-bar strong {
+ margin-right: auto;
+ font-size: 17px;
+ font-weight: 500;
+}
+.mpa-calendar-bar span {
+ font-size: 12px;
+ color: hsl(var(--muted-foreground));
+}
+.mpa-calendar {
+ display: grid;
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+ border: 1px solid hsl(var(--border));
+ border-radius: 8px;
+ overflow: hidden;
+}
+.mpa-calendar-weekday {
+ text-align: center;
+ padding: 12px;
+ color: hsl(var(--muted-foreground));
+ background: hsl(var(--muted) / 0.35);
+}
+.mpa-calendar-day {
+ min-height: 110px;
+ padding: 8px;
+ border-top: 1px solid hsl(var(--border));
+ border-right: 1px solid hsl(var(--border));
+ overflow: hidden;
+}
+.mpa-calendar-day:nth-child(7n) {
+ border-right: 0;
+}
+.mpa-calendar-day time {
+ font-variant-numeric: tabular-nums;
+ display: block;
+ margin-bottom: 8px;
+}
+.mpa-calendar-day.is-outside {
+ background: hsl(var(--muted) / 0.3);
+ color: hsl(var(--muted-foreground));
+}
+.mpa-calendar-task {
+ display: block;
+ width: 100%;
+ border: 0;
+ border-radius: 4px;
+ text-align: left;
+ padding: 4px 6px;
+ margin-top: 4px;
+ color: hsl(var(--primary));
+ background: hsl(var(--primary) / 0.07);
+ cursor: pointer;
+ font-size: 12px;
+}
+.mpa-calendar-task span {
+ display: block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.mpa-cron > p {
+ line-height: 1.6;
+}
+@media (max-width: 760px) {
+ .mpa-toolbar {
+ align-items: stretch;
+ }
+ .mpa-cron-overview {
+ margin-left: 0;
+ }
+ .mpa-cron-search {
+ width: 100%;
+ }
+ .mpa-cron-search input {
+ flex: 1;
+ }
+ .mpa-fields {
+ grid-template-columns: 1fr;
+ }
+ .mpa-calendar-day {
+ min-height: 80px;
+ padding: 4px;
+ }
+ .mpa-calendar-task {
+ padding: 4px 2px;
+ }
+ .mpa-details {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ }
+}
+.mpa-status-filter {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ background: none;
+ border: 0;
+ padding: 0;
+ color: inherit;
+ cursor: pointer;
+ white-space: nowrap;
+ min-height: 28px;
+}
+.mpa-status-filter .icon {
+ width: 14px;
+ height: 14px;
+ color: hsl(var(--muted-foreground));
+}
+.mpa-editor input[type="checkbox"] {
+ appearance: auto;
+ width: 16px;
+ height: 16px;
+ accent-color: hsl(var(--primary));
+}
+.mpa-dialog form {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+}
+.mpa-dialog .mpa-editor {
+ flex: 1;
+}
diff --git a/frontend/src/cronjobs/MpaCronTasks.tsx b/frontend/src/cronjobs/MpaCronTasks.tsx
new file mode 100644
index 000000000..2eefbc13e
--- /dev/null
+++ b/frontend/src/cronjobs/MpaCronTasks.tsx
@@ -0,0 +1,621 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Menu } from "@openai/apps-sdk-ui/components/Menu";
+import { listMpaCronTasks, requestMpaTask } from "../adk/client";
+import type {
+ MpaCronTask,
+ MpaCronTaskPage,
+ MpaRuntime,
+ TaskFields,
+} from "../adk/mpaCronTasks";
+import { TextShimmer } from "../ui/text-shimmer/TextShimmer";
+import { DialogShell } from "../ui/SandboxControls";
+import { MpaTaskEditor } from "./MpaTaskEditor";
+import { MpaTaskDetail, mpaErrorKey } from "./MpaTaskDetail";
+import { MpaIcon } from "./MpaTaskIcons";
+import {
+ calendarTimes,
+ formatTime,
+ monthDays,
+ runStatuses,
+ scheduleText,
+ taskStatus,
+} from "./mpaSchedule";
+import "../create/CustomCreate.css";
+import "../ui/ProjectPreview.css";
+import "./MpaCronTasks.css";
+
+export function MpaCronTasks({ runtime }: { runtime?: MpaRuntime }) {
+ const { t } = useTranslation("cronjobs");
+ return runtime ? (
+
+ ) : (
+ {t("mpa.selectRuntime")}
+ );
+}
+function RuntimeTasks({ runtime }: { runtime: MpaRuntime }) {
+ const { t, i18n } = useTranslation("cronjobs");
+ const label = (key: string) => t(`mpa.manage.${key}`);
+ const [draftQuery, setDraftQuery] = useState(""),
+ [query, setQuery] = useState(""),
+ [index, setIndex] = useState(0),
+ [refresh, setRefresh] = useState(0);
+ const [page, setPage] = useState(null),
+ [loading, setLoading] = useState(true),
+ [error, setError] = useState(""),
+ [notice, setNotice] = useState("");
+ const [view, setView] = useState("list"),
+ [status, setStatus] = useState("all"),
+ [month, setMonth] = useState(() => new Date());
+ const [editor, setEditor] = useState<{
+ task?: MpaCronTask;
+ copy: boolean;
+ } | null>(null),
+ [detail, setDetail] = useState(null),
+ [deleting, setDeleting] = useState(null);
+ const [busy, setBusy] = useState(false),
+ [writeError, setWriteError] = useState("");
+ const locked = useRef(false),
+ composing = useRef(false);
+ const lifetime = useRef(new AbortController());
+ const tokens = useRef(new Map());
+ useEffect(() => {
+ const controller = new AbortController();
+ lifetime.current = controller;
+ return () => controller.abort();
+ }, []);
+ useEffect(() => {
+ const controller = new AbortController();
+ setLoading(true);
+ setError("");
+ setPage(null);
+ void (async () => {
+ const first = await listMpaCronTasks(
+ runtime,
+ 0,
+ query,
+ controller.signal,
+ );
+ const items = [...first.items];
+ let current = first;
+ let offset = 0;
+ while (current.hasMore) {
+ if (controller.signal.aborted) return;
+ if (
+ current.nextOffset === null ||
+ current.nextOffset <= offset ||
+ items.length > 100000
+ )
+ throw new Error("MPA_INVALID_RESPONSE");
+ offset = current.nextOffset;
+ current = await listMpaCronTasks(
+ runtime,
+ offset,
+ query,
+ controller.signal,
+ );
+ items.push(...current.items);
+ }
+ if (!controller.signal.aborted)
+ setPage({
+ ...first,
+ items: [...new Map(items.map((task) => [task.id, task])).values()],
+ hasMore: false,
+ nextOffset: null,
+ });
+ })()
+ .catch((e) => {
+ if (!controller.signal.aborted) setError(mpaErrorKey(e));
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setLoading(false);
+ });
+ return () => controller.abort();
+ }, [runtime.runtimeId, runtime.region, query, refresh]);
+ const filtered = useMemo(
+ () =>
+ page?.items.filter(
+ (task) => status === "all" || taskStatus(task) === status,
+ ) || [],
+ [page, status],
+ );
+ const days = useMemo(() => monthDays(month), [month]);
+ const calendar = useMemo(
+ () =>
+ days.map((day) => ({
+ day,
+ tasks: filtered.flatMap((task) => {
+ const entry = calendarTimes(task, day);
+ return entry ? [{ task, ...entry }] : [];
+ }),
+ })),
+ [days, filtered],
+ );
+ const visible = filtered.slice(index, index + 10);
+ function token(key: string) {
+ let value = tokens.current.get(key);
+ if (!value) {
+ value = crypto.randomUUID();
+ tokens.current.set(key, value);
+ }
+ return value;
+ }
+ async function mutate(
+ kind: "create" | "edit" | "delete" | "run" | "toggle",
+ task?: MpaCronTask,
+ fields?: TaskFields,
+ ) {
+ if (locked.current) return;
+ locked.current = true;
+ setBusy(true);
+ setWriteError("");
+ setNotice("");
+ const operation = lifetime.current;
+ const key =
+ kind === "create" ? JSON.stringify(fields) : `${kind}:${task?.id}`;
+ try {
+ const suffix = task
+ ? `/${encodeURIComponent(task.id)}${kind === "run" ? "/run" : ""}`
+ : "";
+ if (
+ (kind === "edit" || kind === "toggle") &&
+ (!Number.isInteger(task?.version) || Number(task?.version) < 1)
+ )
+ throw new Error("MPA_INVALID_RESPONSE");
+ const payload =
+ kind === "delete"
+ ? undefined
+ : kind === "run"
+ ? { clientToken: token(key), mode: "Force" }
+ : kind === "create"
+ ? { ...fields, clientToken: token(key) }
+ : {
+ ...(kind === "toggle" ? { enabled: !task!.enabled } : fields),
+ expectedVersion: task!.version,
+ };
+ const result = await requestMpaTask(
+ runtime,
+ kind === "delete" ? "DELETE" : "POST",
+ suffix,
+ payload,
+ operation.signal,
+ );
+ if (
+ kind === "delete"
+ ? result.removed !== true
+ : kind === "run"
+ ? !result.run
+ : !result.task
+ )
+ throw new Error("MPA_INVALID_RESPONSE");
+ tokens.current.delete(key);
+ if (!operation.signal.aborted) {
+ setEditor(null);
+ setDeleting(null);
+ setDetail(null);
+ setNotice(label(kind === "run" ? "queued" : "saved"));
+ setIndex(0);
+ setRefresh((n) => n + 1);
+ }
+ } catch (e) {
+ if (!operation.signal.aborted) setWriteError(t(mpaErrorKey(e)));
+ } finally {
+ locked.current = false;
+ if (!operation.signal.aborted) setBusy(false);
+ }
+ }
+ function openEditor(task?: MpaCronTask, copy = false) {
+ setWriteError("");
+ setEditor({ task, copy });
+ }
+ return (
+
+
+ {runtime.name}
+
+ {runtime.runtimeId} · {runtime.region}
+
+
+
+
+ {["list", "calendar"].map((mode) => (
+
+ ))}
+
+
+
+
+
+ {page?.overview && (
+
+ {label("overview")}
+
+ {t("mpa.successRate")}{" "}
+ {Math.round(page.overview.successRate * 100)}%
+
+
+ {t("mpa.executionCount")}{" "}
+ {page.overview.executionCount}
+
+
+ )}
+
+ {error && (
+
+ {t(error)}
+
+ )}
+ {writeError && !editor && !deleting && (
+
+ {writeError}
+
+ )}
+ {notice && {notice}
}
+ {loading && (
+
+ {t("mpa.loading")}
+
+ )}
+ {!loading && !error && page && (
+ <>
+ {view === "list" ? (
+ <>
+
+
+
+
+ | {label("name")} |
+ {label("lastRun")} |
+ {label("schedule")} |
+
+
+ |
+ {label("agent")} |
+ {label("actions")} |
+
+
+
+ {visible.map((task) => (
+
+ |
+
+ |
+ {formatTime(task.lastRunAt, i18n.language)} |
+ {scheduleText(task, label, i18n.language)} |
+
+
+ {label(taskStatus(task))}
+
+ |
+ {task.agentId || "—"} |
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+ {filtered.length === 0 && (
+
+ {t("mpa.empty")}
+
+ )}
+
+
+ >
+ ) : (
+ <>
+
+
+ {new Intl.DateTimeFormat(i18n.language, {
+ year: "numeric",
+ month: "long",
+ }).format(month)}
+
+
+ {label("calendarZone")}{" "}
+ {Intl.DateTimeFormat().resolvedOptions().timeZone}
+
+
+
+
+
+
+ {days.slice(0, 7).map((d) => (
+
+ {new Intl.DateTimeFormat(i18n.language, {
+ weekday: "short",
+ }).format(d)}
+
+ ))}
+ {calendar.map(({ day, tasks }) => (
+
+
+ {tasks.map(({ task, at, count, nextOnly }) => (
+
+ ))}
+
+ ))}
+
+ {label("calendarHint")}
+ >
+ )}
+ >
+ )}
+ {editor && (
+ setEditor(null)}
+ onSave={(fields) =>
+ void mutate(
+ editor.task && !editor.copy ? "edit" : "create",
+ editor.copy ? undefined : editor.task,
+ fields,
+ )
+ }
+ />
+ )}
+ {detail && (
+ setDetail(null)}
+ />
+ )}
+ {deleting && (
+ }
+ busy={busy}
+ onClose={() => setDeleting(null)}
+ >
+
+
{t("mpa.manage.deleteConfirm", { name: deleting.name })}
+ {writeError && (
+
+ {writeError}
+
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/cronjobs/MpaTaskDetail.tsx b/frontend/src/cronjobs/MpaTaskDetail.tsx
new file mode 100644
index 000000000..af177bb03
--- /dev/null
+++ b/frontend/src/cronjobs/MpaTaskDetail.tsx
@@ -0,0 +1,147 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { listMpaRuns } from "../adk/client";
+import type { MpaCronTask, MpaRunPage, MpaRuntime } from "../adk/mpaCronTasks";
+import { DialogShell } from "../ui/SandboxControls";
+import { TextShimmer } from "../ui/text-shimmer/TextShimmer";
+import { formatTime, scheduleText } from "./mpaSchedule";
+import { MpaIcon } from "./MpaTaskIcons";
+export function mpaErrorKey(error: unknown): string {
+ const message = error instanceof Error ? error.message : "";
+ return (
+ (
+ {
+ MPA_HTTP_401: "mpa.authRequired",
+ MPA_HTTP_403: "mpa.forbidden",
+ MPA_HTTP_404: "mpa.unsupported",
+ MPA_HTTP_409: "mpa.manage.conflict",
+ MPA_HTTP_422: "mpa.manage.invalid",
+ MPA_INVALID_RESPONSE: "mpa.invalidResponse",
+ } as Record
+ )[message] || "mpa.loadFailed"
+ );
+}
+export function MpaTaskDetail({
+ task,
+ runtime,
+ onClose,
+}: {
+ task: MpaCronTask;
+ runtime: MpaRuntime;
+ onClose: () => void;
+}) {
+ const { t, i18n } = useTranslation("cronjobs");
+ const label = (key: string) => t(`mpa.manage.${key}`);
+ const [offset, setOffset] = useState(0),
+ [refresh, setRefresh] = useState(0),
+ [page, setPage] = useState(null),
+ [error, setError] = useState(""),
+ [loading, setLoading] = useState(true);
+ useEffect(() => {
+ const controller = new AbortController();
+ setLoading(true);
+ setError("");
+ setPage(null);
+ void listMpaRuns(runtime, task.id, offset, controller.signal)
+ .then((data) => {
+ if (!controller.signal.aborted) setPage(data);
+ })
+ .catch((e) => {
+ if (!controller.signal.aborted) setError(mpaErrorKey(e));
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setLoading(false);
+ });
+ return () => controller.abort();
+ }, [runtime.runtimeId, runtime.region, task.id, offset, refresh]);
+ return (
+ }
+ className="mpa-dialog mpa-detail"
+ onClose={onClose}
+ >
+
+
+ - {label("agent")}
+ - {task.agentId || "—"}
+ - {label("schedule")}
+ - {scheduleText(task, label, i18n.language)}
+ - {t("mpa.columns.next")}
+ - {formatTime(task.nextRunAt, i18n.language)}
+ - {label("prompt")}
+ - {task.prompt}
+
+
+
{label("history")}
+
+
+ {error && (
+
+ {t(error)}
+
+ )}
+ {loading &&
{t("mpa.loading")}}
+ {page && (
+ <>
+
+
+
+
+ {["lastRun", "lastStatus", "duration", "result"].map(
+ (k) => (
+ | {label(k)} |
+ ),
+ )}
+
+
+
+ {page.items.map((run) => (
+
+ |
+ {formatTime(
+ run.startedAt || run.scheduledAt,
+ i18n.language,
+ )}
+ |
+ {label(run.status)} |
+
+ {run.durationMs == null ? "—" : `${run.durationMs} ms`}
+ |
+ {run.errorMessage || run.sessionId || "—"} |
+
+ ))}
+
+
+
+ {page.items.length === 0 &&
{label("noRuns")}
}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/cronjobs/MpaTaskEditor.tsx b/frontend/src/cronjobs/MpaTaskEditor.tsx
new file mode 100644
index 000000000..321b5be84
--- /dev/null
+++ b/frontend/src/cronjobs/MpaTaskEditor.tsx
@@ -0,0 +1,346 @@
+import { useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { DialogShell } from "../ui/SandboxControls";
+import { DeploymentSelect } from "../ui/DeploymentSelect";
+import type { MpaCronTask, TaskFields } from "../adk/mpaCronTasks";
+import { scheduleTypes } from "./mpaSchedule";
+import { MpaIcon } from "./MpaTaskIcons";
+
+export function MpaTaskEditor({
+ task,
+ copy,
+ busy,
+ error,
+ onClose,
+ onSave,
+}: {
+ task?: MpaCronTask;
+ copy: boolean;
+ busy: boolean;
+ error: string;
+ onClose: () => void;
+ onSave: (fields: TaskFields) => void;
+}) {
+ const { t } = useTranslation("cronjobs");
+ const label = (key: string) => t(`mpa.manage.${key}`);
+ const initial = task?.schedule;
+ const [name, setName] = useState(
+ task ? task.name + (copy ? ` (${label("copy")})` : "") : "",
+ );
+ const [prompt, setPrompt] = useState(task?.prompt || "");
+ const [type, setType] = useState(initial?.type || "Daily");
+ const [zone, setZone] = useState(
+ String(initial?.timezone || "Asia/Shanghai"),
+ );
+ const [time, setTime] = useState(String(initial?.time || "09:00"));
+ const [date, setDate] = useState(() => {
+ if (!initial?.runAt) return "";
+ const d = new Date(String(initial.runAt));
+ return new Date(d.getTime() - d.getTimezoneOffset() * 60000)
+ .toISOString()
+ .slice(0, 16);
+ });
+ const [interval, setInterval] = useState(
+ String(initial?.intervalSeconds || 3600),
+ );
+ const [days, setDays] = useState(String(initial?.weekdays || "1,2,3,4,5"));
+ const [monthDays, setMonthDays] = useState(String(initial?.monthDays || "1"));
+ const [cron, setCron] = useState(
+ String(initial?.cronExpression || "0 9 * * *"),
+ );
+ const [enabled, setEnabled] = useState(task?.enabled ?? true);
+ const [channel, setChannel] = useState(
+ String(task?.delivery?.channel || "Web"),
+ );
+ const [target, setTarget] = useState(String(task?.delivery?.targetId || ""));
+ const [validation, setValidation] = useState("");
+ const composing = useRef(false);
+ const nameRef = useRef(null);
+ function save() {
+ if (busy || composing.current) return;
+ try {
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
+ } catch {
+ setValidation(label("timezoneInvalid"));
+ return;
+ }
+ const schedule: TaskFields["schedule"] = { type, timezone: zone };
+ if (type === "Once") {
+ const at = Date.parse(date);
+ if (!Number.isFinite(at) || at <= Date.now()) {
+ setValidation(label("futureTime"));
+ return;
+ }
+ schedule.runAt = new Date(at).toISOString();
+ } else if (type === "Interval") {
+ const seconds = Number(interval);
+ if (!Number.isInteger(seconds) || seconds < 30 || seconds > 31536000) {
+ setValidation(label("intervalInvalid"));
+ return;
+ }
+ schedule.intervalSeconds = seconds;
+ if (initial?.type === type && initial.anchorAt)
+ schedule.anchorAt = initial.anchorAt;
+ } else if (type === "Cron") {
+ if (cron.trim().split(/\s+/).length !== 5) {
+ setValidation(t("validation.cronFields"));
+ return;
+ }
+ schedule.cronExpression = cron.trim();
+ } else {
+ schedule.time = time;
+ if (type === "Weekly" || type === "Monthly") {
+ const value = (type === "Weekly" ? days : monthDays)
+ .split(",")
+ .map(Number);
+ const max = type === "Weekly" ? 7 : 31;
+ if (
+ !value.length ||
+ value.some((n) => !Number.isInteger(n) || n < 1 || n > max) ||
+ new Set(value).size !== value.length
+ ) {
+ setValidation(label("daysInvalid"));
+ return;
+ }
+ schedule[type === "Weekly" ? "weekdays" : "monthDays"] = value;
+ }
+ }
+ if (
+ !name.trim() ||
+ !prompt.trim() ||
+ (channel === "Feishu" && !target.trim())
+ ) {
+ setValidation(label("required"));
+ return;
+ }
+ const delivery =
+ task?.delivery?.channel === channel &&
+ String(task.delivery.targetId || "") === target
+ ? task.delivery
+ : {
+ channel,
+ targetId: channel === "Web" ? null : target.trim(),
+ receiveIdType: "chat_id",
+ bestEffort: true,
+ };
+ setValidation("");
+ onSave({
+ name: name.trim(),
+ ...(task?.agentId ? { agentId: task.agentId } : {}),
+ prompt: prompt.trim(),
+ enabled,
+ schedule,
+ delivery,
+ jitterSeconds: task?.jitterSeconds ?? 0,
+ timeoutSeconds: task?.timeoutSeconds ?? 3600,
+ });
+ }
+ return (
+ }
+ className="mpa-dialog"
+ initialFocusRef={nameRef}
+ busy={busy}
+ onClose={onClose}
+ >
+
+
+ );
+}
diff --git a/frontend/src/cronjobs/MpaTaskIcons.tsx b/frontend/src/cronjobs/MpaTaskIcons.tsx
new file mode 100644
index 000000000..2d37d3beb
--- /dev/null
+++ b/frontend/src/cronjobs/MpaTaskIcons.tsx
@@ -0,0 +1,31 @@
+export function MpaIcon({
+ kind,
+}: {
+ kind:
+ "plus" | "play" | "edit" | "more" | "detail" | "copy" | "delete" | "filter";
+}) {
+ const paths = {
+ filter: "M4 5h16l-6 7v7l-4-2v-5Z",
+ plus: "M12 5v14M5 12h14",
+ play: "M8 5l11 7-11 7Z",
+ edit: "m15 4 5 5-11 11H4v-5ZM13 6l5 5",
+ more: "M5 12h.01M12 12h.01M19 12h.01",
+ detail: "M7 3h10l3 3v15H4V3ZM8 9h8M8 13h8M8 17h5",
+ copy: "M9 8V3h12v13h-5M3 8h13v13H3Z",
+ delete: "M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7",
+ };
+ return (
+
+ );
+}
diff --git a/frontend/src/cronjobs/mpaSchedule.ts b/frontend/src/cronjobs/mpaSchedule.ts
new file mode 100644
index 000000000..00d60953a
--- /dev/null
+++ b/frontend/src/cronjobs/mpaSchedule.ts
@@ -0,0 +1,185 @@
+import type { MpaCronTask } from "../adk/mpaCronTasks";
+
+export const scheduleTypes = [
+ "Once",
+ "Interval",
+ "Daily",
+ "Weekly",
+ "Monthly",
+ "Cron",
+] as const;
+export const runStatuses = [
+ "Queued",
+ "Running",
+ "Succeeded",
+ "Failed",
+ "TimedOut",
+ "Cancelled",
+ "Skipped",
+] as const;
+export function taskStatus(task: MpaCronTask): string {
+ return task.runningAt ? "Running" : task.lastRunStatus || "Pending";
+}
+export function formatTime(
+ value: unknown,
+ locale: string,
+ zone?: string,
+): string {
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value)))
+ return "—";
+ return new Intl.DateTimeFormat(locale, {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ timeZone: zone,
+ }).format(new Date(value));
+}
+export function scheduleText(
+ task: MpaCronTask,
+ label: (key: string) => string,
+ locale: string,
+): string {
+ const s = task.schedule;
+ const zone = typeof s.timezone === "string" ? s.timezone : "Asia/Shanghai";
+ const type = s.type;
+ const value =
+ type === "Once"
+ ? formatTime(s.runAt, locale, zone)
+ : type === "Interval"
+ ? `${s.intervalSeconds} ${label("seconds")}`
+ : type === "Cron"
+ ? String(s.cronExpression)
+ : `${s.time}${type === "Weekly" ? ` · ${String(s.weekdays)}` : type === "Monthly" ? ` · ${String(s.monthDays)}` : ""}`;
+ return `${label(type)} · ${value} (${zone})`;
+}
+export function monthDays(month: Date): Date[] {
+ const start = new Date(month.getFullYear(), month.getMonth(), 1);
+ start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
+ return Array.from(
+ { length: 42 },
+ (_, i) =>
+ new Date(start.getFullYear(), start.getMonth(), start.getDate() + i),
+ );
+}
+const parts = (date: Date, zone: string) =>
+ Object.fromEntries(
+ new Intl.DateTimeFormat("en-US", {
+ timeZone: zone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hourCycle: "h23",
+ })
+ .formatToParts(date)
+ .map((p) => [p.type, p.value]),
+ );
+// Convert wall time using candidate offsets on both sides of a possible DST transition.
+function wallTimes(
+ y: number,
+ m: number,
+ d: number,
+ h: number,
+ min: number,
+ zone: string,
+): number[] {
+ const wall = Date.UTC(y, m - 1, d, h, min);
+ const candidates = new Set();
+ for (const delta of [-86400000, 0, 86400000]) {
+ const base = wall + delta;
+ const p = parts(new Date(base), zone);
+ const offset =
+ Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute) - base;
+ const at = wall - offset;
+ const actual = parts(new Date(at), zone);
+ if (
+ +actual.year === y &&
+ +actual.month === m &&
+ +actual.day === d &&
+ +actual.hour === h &&
+ +actual.minute === min
+ )
+ candidates.add(at);
+ }
+ return [...candidates];
+}
+export function calendarTimes(
+ task: MpaCronTask,
+ day: Date,
+): { at: number; count: number; nextOnly: boolean } | null {
+ const start = day.getTime();
+ const end = new Date(
+ day.getFullYear(),
+ day.getMonth(),
+ day.getDate() + 1,
+ ).getTime();
+ const s = task.schedule;
+ const zone = String(s.timezone || "Asia/Shanghai");
+ const created = Date.parse(task.createdAt || "") || -Infinity;
+ const within = (at: number) => at >= Math.max(start, created) && at < end;
+ if (s.type === "Once") {
+ const at = Date.parse(String(s.runAt));
+ return within(at) ? { at, count: 1, nextOnly: false } : null;
+ }
+ if (!task.enabled) return null;
+ if (s.type === "Interval") {
+ const anchor = Date.parse(String(s.anchorAt || task.nextRunAt));
+ const step = Number(s.intervalSeconds) * 1000;
+ if (!Number.isFinite(anchor) || !Number.isFinite(step) || step < 30000)
+ return null;
+ const at =
+ anchor +
+ Math.max(0, Math.ceil((Math.max(start, created) - anchor) / step)) * step;
+ return within(at)
+ ? { at, count: Math.ceil((end - at) / step), nextOnly: false }
+ : null;
+ }
+ let time = String(s.time);
+ let weekdays = s.weekdays as number[] | undefined;
+ let dates = s.monthDays as number[] | undefined;
+ if (s.type === "Cron") {
+ const f = String(s.cronExpression).trim().split(/\s+/);
+ if (
+ f.length !== 5 ||
+ !/^\d+$/.test(f[0]) ||
+ +f[0] > 59 ||
+ !/^\d+$/.test(f[1]) ||
+ +f[1] > 23 ||
+ f[3] !== "*" ||
+ !/^(\*|\d+(,\d+)*)$/.test(f[2]) ||
+ !/^(\*|\d+(,\d+)*)$/.test(f[4]) ||
+ (f[2] !== "*" && f[4] !== "*")
+ ) {
+ const at = Date.parse(task.nextRunAt || "");
+ return within(at) ? { at, count: 1, nextOnly: true } : null;
+ }
+ time = `${f[1]}:${f[0]}`;
+ dates = f[2] === "*" ? undefined : f[2].split(",").map(Number);
+ weekdays =
+ f[4] === "*"
+ ? undefined
+ : f[4].split(",").map((n) => (+n === 0 ? 7 : +n));
+ }
+ const [h, min] = time.split(":").map(Number);
+ if (!Number.isFinite(h) || !Number.isFinite(min)) return null;
+ const found: number[] = [];
+ for (let delta = -1; delta <= 1; delta++) {
+ const p = parts(new Date(start + delta * 86400000), zone);
+ const y = +p.year,
+ m = +p.month,
+ d = +p.day;
+ const weekday = new Date(Date.UTC(y, m - 1, d)).getUTCDay() || 7;
+ if (
+ (weekdays && !weekdays.includes(weekday)) ||
+ (dates && !dates.includes(d))
+ )
+ continue;
+ found.push(...wallTimes(y, m, d, h, min, zone).filter(within));
+ }
+ const unique = [...new Set(found)].sort((a, b) => a - b);
+ return unique.length
+ ? { at: unique[0], count: unique.length, nextOnly: false }
+ : null;
+}
diff --git a/frontend/src/i18n/resources/en-US/adk.json b/frontend/src/i18n/resources/en-US/adk.json
index b682971f0..7905d8328 100644
--- a/frontend/src/i18n/resources/en-US/adk.json
+++ b/frontend/src/i18n/resources/en-US/adk.json
@@ -42,7 +42,8 @@
"resourceCollectionExpiredHint": "Tip: This resource collection has expired. Send the task again so the system can collect the resources before creating the Agent.",
"networkConfigurationHint": "Tip: Check network settings such as the shared public egress, then try again.",
"modelQuotaHint": "Tip: The model has reached its TPM/RPM quota. Try again later or increase the model quota.",
- "rawResponseLabel": "Raw response: "
+ "rawResponseLabel": "Raw response: ",
+ "copySecretFailed": "Failed to copy the secret value"
},
"runtimeLogs": {
"httpStatus": "HTTP status: {{status}}",
diff --git a/frontend/src/i18n/resources/en-US/conversation.json b/frontend/src/i18n/resources/en-US/conversation.json
index c2d848c92..cec0578dc 100644
--- a/frontend/src/i18n/resources/en-US/conversation.json
+++ b/frontend/src/i18n/resources/en-US/conversation.json
@@ -50,6 +50,7 @@
"connectingDescription": "Establishing a secure log stream through the Studio BFF.",
"emptyTitle": "No logs yet",
"emptyDescription": "Connected to the instance and waiting for new log output.",
+ "download": "Download logs",
"retention": "Logs refresh automatically. Only the latest {{count}} lines are kept."
},
"trace": {
@@ -102,11 +103,18 @@
"downloadFormat": "Download {{format}}"
},
"blocks": {
+ "a2a": {
+ "connecting": "Waiting for Runtime response…",
+ "submitted": "Task queued…",
+ "working": "Task running…"
+ },
"unsupportedComponent": "Unsupported component: {{component}}",
"sandboxIdentity": "Codex Sandbox execution identifiers",
"useSkill": "Use the {{name}} skill",
- "thinkingDone": "Finished thinking",
- "thinking": "Thinking",
+ "thinkingDone": "Finished working thought",
+ "thinking": "Working thought",
+ "reasoningDone": "Finished model reasoning",
+ "reasoning": "Model reasoning",
"justNow": "Just now",
"sourceUnavailable": "The generated source is temporarily unavailable. Please try again later.",
"downloadStarted": "Download started",
@@ -234,6 +242,79 @@
"webSearch": { "running": "Searching the web", "completed": "Web search complete", "failed": "Web search did not complete" },
"errorDetail": "Codex execution did not complete.",
"errorTitle": "Codex encountered an error"
+ },
+ "toolActivity": {
+ "status": {
+ "queued": "Queued",
+ "running": "Running",
+ "completed": "Completed",
+ "failed": "Failed"
+ },
+ "goal": {
+ "queued": "Waiting to create goal",
+ "running": "Creating goal",
+ "completed": "Created goal",
+ "failed": "Failed to create goal"
+ },
+ "command": {
+ "queued": "Waiting to run command",
+ "running": "Running command",
+ "completed": "Ran command",
+ "failed": "Command failed"
+ },
+ "read": {
+ "queued": "Waiting to read",
+ "running": "Reading",
+ "completed": "Read content",
+ "failed": "Read failed"
+ },
+ "search": {
+ "queued": "Waiting to search",
+ "running": "Searching",
+ "completed": "Search complete",
+ "failed": "Search failed"
+ },
+ "file-change": {
+ "queued": "Waiting to change files",
+ "running": "Changing files",
+ "completed": "Changed files",
+ "failed": "File change failed"
+ },
+ "mcp": {
+ "queued": "Waiting to call external tool",
+ "running": "Calling external tool",
+ "completed": "Called external tool",
+ "failed": "External tool call failed"
+ },
+ "authorization": {
+ "queued": "Waiting for authorization",
+ "running": "Waiting for authorization",
+ "completed": "Authorization complete",
+ "failed": "Authorization failed"
+ },
+ "generic": {
+ "queued": "Waiting to call tool",
+ "running": "Calling tool",
+ "completed": "Called tool",
+ "failed": "Tool call failed",
+ "named": {
+ "queued": "Waiting to call {{tool}}",
+ "running": "Calling {{tool}}",
+ "completed": "Called {{tool}}",
+ "failed": "{{tool}} call failed"
+ }
+ },
+ "exitCode": "exit {{code}}",
+ "omittedLines": "… {{count}} lines omitted …",
+ "omittedCharacters": "… {{count}} characters omitted …",
+ "rawData": "View raw data",
+ "callId": "Call ID",
+ "copyCallId": "Copy call ID",
+ "copy": "Copy",
+ "copied": "Copied",
+ "copyFailed": "Copy failed. Try again.",
+ "explored_one": "Explored 1 item",
+ "explored_other": "Explored {{count}} items"
}
},
"tokenUsage": {
@@ -258,6 +339,16 @@
"summaryTokens": "{{used}} used, {{remaining}} remaining, {{total}} total",
"overflow": "Context exceeded by {{count}} tokens",
"title": "Context usage",
+ "category": "Category",
+ "currentTurn": "Current Turn",
+ "sessionTotal": "Session total",
+ "details": {
+ "input": "Input",
+ "output": "Output",
+ "reasoning": "Reasoning",
+ "cache": "Cache hit",
+ "total": "Total"
+ },
"unknownModel": "The context window for this model is not available",
"unknownRuntime": "The current runtime did not provide model information"
},
@@ -274,8 +365,8 @@
"connecting": "Connecting…",
"connect": "Connect and add"
},
- "composer": { "placeholder": "Type a message…", "inputAria": "Message", "generating": "Generating", "send": "Send" },
+ "composer": { "placeholder": "Type a message…", "inputAria": "Message", "generating": "Generating", "send": "Send", "model": "Model" },
"invocation": { "ariaLabel": "Invocation context for this turn", "removeSkill": "Remove skill {{name}}", "removeAgent": "Remove agent {{name}}" },
"visualization": { "cardAria": "{{label}} chart", "viewAria": "{{label}} display mode", "preview": "Preview", "code": "Code", "invalidEcharts": "The ECharts configuration is not a valid, safe data object. Switch to Code to inspect it.", "renderFailed": "The chart cannot be rendered right now. Switch to Code to inspect it.", "echartsAria": "ECharts preview", "rendering": "Rendering chart…", "mermaidFailed": "The chart cannot be rendered right now. Switch to Code to inspect the Mermaid source.", "mermaidAria": "Mermaid preview" }
- ,"markdown": { "playVideo": "Play video: {{name}}", "enlargeImage": "Enlarge image preview: {{name}}", "image": "image", "enlargeVideo": "Enlarge video", "videoPreview": "Video preview", "downloadVideo": "Download video", "close": "Close" }
+ ,"markdown": { "downloadFile": "Download file", "downloading": "Downloading…", "downloadFailed": "Download failed. Check that the Sandbox and file are still available, then click to retry.", "playVideo": "Play video: {{name}}", "enlargeImage": "Enlarge image preview: {{name}}", "image": "image", "enlargeVideo": "Enlarge video", "videoPreview": "Video preview", "downloadVideo": "Download video", "close": "Close" }
}
diff --git a/frontend/src/i18n/resources/en-US/cronjobs.json b/frontend/src/i18n/resources/en-US/cronjobs.json
index 248030a91..4ff5864cb 100644
--- a/frontend/src/i18n/resources/en-US/cronjobs.json
+++ b/frontend/src/i18n/resources/en-US/cronjobs.json
@@ -1,4 +1,111 @@
{
+ "mpa": {
+ "source": "Task source",
+ "studio": "Studio scheduled tasks",
+ "title": "Runtime scheduled tasks",
+ "selectRuntime": "Select a cloud Runtime from the Agent list first.",
+ "scope": "Scheduled tasks for the current Studio user in this Runtime.",
+ "refresh": "Refresh",
+ "loading": "Loading Runtime tasks",
+ "empty": "No matching scheduled tasks in this Runtime.",
+ "authRequired": "Runtime rejected the request. Check gateway access and the Runtime authentication setting.",
+ "forbidden": "The current user cannot access this Runtime.",
+ "unsupported": "This Runtime does not provide the MPA scheduled tasks API.",
+ "invalidResponse": "The tasks API returned unrecognized data. Check the Runtime version.",
+ "loadFailed": "Could not load tasks. Check the network and Runtime status, then refresh.",
+ "pagination": "Runtime task pagination",
+ "total": "{{total}} tasks",
+ "previous": "Previous",
+ "next": "Next",
+ "columns": {
+ "name": "Task name",
+ "enabled": "Status",
+ "schedule": "Schedule",
+ "next": "Next run",
+ "last": "Latest result"
+ },
+ "schedule": {
+ "once": "Once",
+ "interval": "Interval",
+ "daily": "Daily",
+ "weekly": "Weekly",
+ "monthly": "Monthly",
+ "cron": "Cron"
+ },
+ "search": "Search tasks",
+ "executionCount": "Executions",
+ "successRate": "Success rate",
+ "prompt": "Task content",
+ "manage": {
+ "view": "Task view",
+ "list": "List",
+ "calendar": "Calendar",
+ "create": "Create scheduled task",
+ "edit": "Edit task",
+ "copy": "Copy",
+ "delete": "Delete",
+ "detail": "Details",
+ "run": "Run now",
+ "name": "Task",
+ "lastRun": "Last execution",
+ "schedule": "Schedule",
+ "lastStatus": "Last execution status",
+ "agent": "Executing Agent",
+ "actions": "Actions",
+ "all": "All statuses",
+ "Pending": "Not run yet",
+ "Queued": "Queued",
+ "Running": "Running",
+ "Succeeded": "Succeeded",
+ "Failed": "Failed",
+ "TimedOut": "Timed out",
+ "Cancelled": "Cancelled",
+ "Skipped": "Skipped",
+ "overview": "Execution statistics:",
+ "prompt": "Task content",
+ "timezone": "Time zone",
+ "localTime": "Execution time (device time zone)",
+ "time": "Time",
+ "Once": "Once",
+ "Interval": "Interval",
+ "Daily": "Daily",
+ "Weekly": "Weekly",
+ "Monthly": "Monthly",
+ "Cron": "Cron expression",
+ "seconds": "seconds",
+ "interval": "Interval in seconds",
+ "weekdays": "Weekdays (1 = Monday, 7 = Sunday; comma separated)",
+ "monthDays": "Days of month (1–31; comma separated)",
+ "delivery": "Delivery",
+ "Web": "Web",
+ "Feishu": "Feishu",
+ "target": "Feishu recipient ID",
+ "enabled": "Enable task",
+ "cancel": "Cancel",
+ "save": "Save",
+ "saving": "Saving",
+ "required": "Enter a task name, Agent ID, prompt and delivery target.",
+ "timezoneInvalid": "Enter a valid IANA time zone, such as Asia/Shanghai.",
+ "futureTime": "Choose a future execution time.",
+ "intervalInvalid": "Interval must be an integer between 30 and 31536000 seconds.",
+ "daysInvalid": "Enter valid, unique days.",
+ "history": "Execution history",
+ "duration": "Duration",
+ "result": "Result / session",
+ "noRuns": "No executions yet.",
+ "conflict": "The task changed or the request conflicts. Close the dialog, refresh and retry.",
+ "invalid": "Request validation failed. Check user identity, execution time, Agent ID and task settings.",
+ "queued": "Execution request submitted.",
+ "saved": "Task updated.",
+ "deleteConfirm": "Delete “{{name}}”? It will no longer be scheduled.",
+ "calendarZone": "Display time zone:",
+ "prevMonth": "Previous month",
+ "nextMonth": "Next month",
+ "today": "Today",
+ "nextOnly": "Next only",
+ "calendarHint": "The calendar shows expected executions. Complex Cron expressions show only the next execution returned by the server."
+ }
+ },
"actions": {
"backToList": "Back to scheduled tasks",
"cancel": "Cancel",
diff --git a/frontend/src/i18n/resources/en-US/newChat.json b/frontend/src/i18n/resources/en-US/newChat.json
index 47d1f71d6..d0136c11e 100644
--- a/frontend/src/i18n/resources/en-US/newChat.json
+++ b/frontend/src/i18n/resources/en-US/newChat.json
@@ -55,6 +55,7 @@
"types": {
"agent": "Agent",
"general": "General Agent",
+ "mpa": "MPA Agent",
"codex": "Codex Agent",
"deepseekHarness": "DeepSeek Harness",
"openclaw": "OpenClaw Agent",
diff --git a/frontend/src/i18n/resources/en-US/ui.json b/frontend/src/i18n/resources/en-US/ui.json
index bece1e527..2a2dd8fb0 100644
--- a/frontend/src/i18n/resources/en-US/ui.json
+++ b/frontend/src/i18n/resources/en-US/ui.json
@@ -439,6 +439,7 @@
"environmentCenter": {
"title": "Environments",
"loadFailed": "Failed to load environments. Check the storage configuration and try again.",
+ "storageUnavailable": "Persistent storage is not configured. Environment management is unavailable.",
"create": "New environment",
"configure": "Configure environment",
"details": "Environment details",
@@ -1158,6 +1159,7 @@
},
"workspace": {
"title": "Workspaces",
+ "storageUnavailable": "Persistent storage is not configured. Workspace management is unavailable.",
"detail": "Workspace details",
"create": "New workspace",
"editorDescription": "Group frequently used environments. An environment can belong to multiple workspaces.",
@@ -1233,6 +1235,24 @@
}
},
"composer": {
+ "model": "Model",
+ "turnControls": "Turn controls",
+ "pause": "Pause",
+ "resume": "Resume",
+ "interrupt": "Interrupt",
+ "cancel": "Cancel",
+ "pauseTurn": "Pause the whole flow at a safe point",
+ "resumeTurn": "Resume the whole flow",
+ "interruptTurn": "Interrupt this flow and retain Session context",
+ "cancelTurn": "Cancel this flow permanently",
+ "turnState": {
+ "running": "Running",
+ "pausing": "Reaching safe point",
+ "paused": "Paused",
+ "resuming": "Resuming",
+ "interrupting": "Interrupting",
+ "cancelling": "Cancelling"
+ },
"tasks": {
"ppt": "Presentation",
"image": "Image generation",
@@ -1373,6 +1393,7 @@
"agent": "Agents",
"agentTypes": {
"general": "General Agents",
+ "mpa": "MPA Agents",
"codex": "Codex",
"deepseek-harness": "DeepSeek",
"openclaw": "OpenClaw",
diff --git a/frontend/src/i18n/resources/en-US/workspaceTools.json b/frontend/src/i18n/resources/en-US/workspaceTools.json
index b53197bc5..e8ae186f7 100644
--- a/frontend/src/i18n/resources/en-US/workspaceTools.json
+++ b/frontend/src/i18n/resources/en-US/workspaceTools.json
@@ -254,8 +254,13 @@
"deleteRuntime": "Delete this Runtime",
"loadingDetail": "Loading details…",
"agentStructure": "Agent structure",
- "secretHidden": "Sensitive value hidden. Select to reveal it.",
- "revealSecret": "Show the value of {{key}}",
+ "secretHidden": "Sensitive value is never shown. Copy it with an authorized request.",
+ "revealSecret": "Sensitive value for {{key}}",
+ "copySecret": "Copy the value of {{key}}",
+ "copy": "Copy",
+ "copied": "Copied",
+ "copyFailed": "Copy failed",
+ "notConfigured": "Not configured",
"fields": {
"model": "Model",
"description": "Description",
@@ -283,9 +288,10 @@
"itemCount_one": "{{count}} item",
"itemCount_other": "{{count}} items",
"info": "Agent information",
- "infoAndTopology": "Agent information and topology",
+ "infoAndTopology": "Agent information and settings",
"loadingInfo": "Loading Agent information…",
"unnamedAgent": "Unnamed Agent",
+ "agentsMd": "AGENTS.md",
"tools": "Tools",
"toolList": "Tool list",
"studioTool": "Studio Tool",
@@ -296,11 +302,16 @@
"addStudioToolHere": "Add Studio tools to this chat",
"skills": "Skills",
"skillList": "Skill list",
+ "addSkill": "Add Skill Space skills",
+ "addSkillHere": "Add Skill Space skills to this Session",
+ "skillMountNextTurn": "Mounted versions are frozen when the next Turn starts.",
"previewUnsupported": "Preview is not supported",
"sessionEnvironment": "Session environment",
"environment": "Environment",
"agentCanvas": "Agent canvas",
"topology": "Structure",
+ "nodeKinds": { "agent": "Agent", "sandbox": "Sandbox", "worker-session": "Worker Session", "skill-space": "Skill Space", "skill": "Skill", "studio-tool": "Studio Tool", "mcp": "MCP", "environment": "Environment", "knowledge": "Knowledge", "sub-agent": "Sub-agent" },
+ "nodeStatuses": { "configured": "Configured", "mounted": "Mounted", "running": "Running", "paused": "Paused" },
"viewCanvasFullscreen": "View Agent canvas in full screen",
"viewFullscreen": "View full screen",
"executionCanvas": "Agent execution canvas",
diff --git a/frontend/src/i18n/resources/zh-CN/adk.json b/frontend/src/i18n/resources/zh-CN/adk.json
index 8d8b41c58..f2044d601 100644
--- a/frontend/src/i18n/resources/zh-CN/adk.json
+++ b/frontend/src/i18n/resources/zh-CN/adk.json
@@ -42,7 +42,8 @@
"resourceCollectionExpiredHint": "提示:本次资源清单已失效,请重新发送任务;系统会重新收集资源后再创建 Agent。",
"networkConfigurationHint": "提示:请检查共享公网出口等网络配置,然后重试。",
"modelQuotaHint": "提示:模型当前触发了 TPM/RPM 配额限制,请稍后重试或提高模型配额。",
- "rawResponseLabel": "原始响应:"
+ "rawResponseLabel": "原始响应:",
+ "copySecretFailed": "复制敏感值失败"
},
"runtimeLogs": {
"httpStatus": "HTTP 状态码:{{status}}",
diff --git a/frontend/src/i18n/resources/zh-CN/conversation.json b/frontend/src/i18n/resources/zh-CN/conversation.json
index 52597ec01..b0f0e9495 100644
--- a/frontend/src/i18n/resources/zh-CN/conversation.json
+++ b/frontend/src/i18n/resources/zh-CN/conversation.json
@@ -50,6 +50,7 @@
"connectingDescription": "正在通过 Studio BFF 建立安全日志流。",
"emptyTitle": "暂无日志",
"emptyDescription": "已连接实例,等待新的日志输出。",
+ "download": "下载日志",
"retention": "日志自动刷新,仅保留最近 {{count}} 行"
},
"trace": {
@@ -102,11 +103,18 @@
"downloadFormat": "下载 {{format}}"
},
"blocks": {
+ "a2a": {
+ "connecting": "正在等待 Runtime 响应…",
+ "submitted": "任务排队中…",
+ "working": "任务执行中…"
+ },
"unsupportedComponent": "不支持的组件:{{component}}",
"sandboxIdentity": "Codex Sandbox 执行标识",
"useSkill": "使用 {{name}} 技能",
- "thinkingDone": "已完成思考",
- "thinking": "思考中",
+ "thinkingDone": "已完成工作思考",
+ "thinking": "工作思考中",
+ "reasoningDone": "已完成模型推理",
+ "reasoning": "模型推理中",
"justNow": "刚刚",
"sourceUnavailable": "暂时无法读取生成的源码,请稍后重试。",
"downloadStarted": "已开始下载",
@@ -234,6 +242,79 @@
"webSearch": { "running": "正在进行网络搜索", "completed": "已完成网络搜索", "failed": "网络搜索未完成" },
"errorDetail": "Codex 执行未完成。",
"errorTitle": "Codex 执行遇到错误"
+ },
+ "toolActivity": {
+ "status": {
+ "queued": "等待执行",
+ "running": "正在执行",
+ "completed": "执行成功",
+ "failed": "执行失败"
+ },
+ "goal": {
+ "queued": "等待创建目标",
+ "running": "正在创建目标",
+ "completed": "已创建目标",
+ "failed": "创建目标失败"
+ },
+ "command": {
+ "queued": "等待运行命令",
+ "running": "正在运行命令",
+ "completed": "已运行命令",
+ "failed": "命令执行失败"
+ },
+ "read": {
+ "queued": "等待读取",
+ "running": "正在读取",
+ "completed": "已读取内容",
+ "failed": "读取失败"
+ },
+ "search": {
+ "queued": "等待搜索",
+ "running": "正在搜索",
+ "completed": "已完成搜索",
+ "failed": "搜索失败"
+ },
+ "file-change": {
+ "queued": "等待修改文件",
+ "running": "正在修改文件",
+ "completed": "已修改文件",
+ "failed": "文件修改失败"
+ },
+ "mcp": {
+ "queued": "等待调用外部工具",
+ "running": "正在调用外部工具",
+ "completed": "已调用外部工具",
+ "failed": "外部工具调用失败"
+ },
+ "authorization": {
+ "queued": "等待授权",
+ "running": "等待授权",
+ "completed": "授权已完成",
+ "failed": "授权失败"
+ },
+ "generic": {
+ "queued": "等待调用工具",
+ "running": "正在调用工具",
+ "completed": "已调用工具",
+ "failed": "工具调用失败",
+ "named": {
+ "queued": "等待调用 {{tool}}",
+ "running": "正在调用 {{tool}}",
+ "completed": "已调用 {{tool}}",
+ "failed": "{{tool}} 调用失败"
+ }
+ },
+ "exitCode": "退出码 {{code}}",
+ "omittedLines": "… 省略 {{count}} 行 …",
+ "omittedCharacters": "… 省略 {{count}} 个字符 …",
+ "rawData": "查看原始数据",
+ "callId": "调用 ID",
+ "copyCallId": "复制调用 ID",
+ "copy": "复制",
+ "copied": "已复制",
+ "copyFailed": "复制失败,请重试。",
+ "explored_one": "已探索 1 项",
+ "explored_other": "已探索 {{count}} 项"
}
},
"tokenUsage": {
@@ -258,6 +339,16 @@
"summaryTokens": "{{used}} 已用,剩余 {{remaining}},总计 {{total}}",
"overflow": "已超出上下文 {{count}} Token",
"title": "上下文用量",
+ "category": "类型",
+ "currentTurn": "当前轮",
+ "sessionTotal": "会话累计",
+ "details": {
+ "input": "输入",
+ "output": "输出",
+ "reasoning": "推理",
+ "cache": "缓存命中",
+ "total": "总计"
+ },
"unknownModel": "暂未收录该模型的上下文窗口",
"unknownRuntime": "当前 Runtime 未提供模型信息"
},
@@ -274,8 +365,8 @@
"connecting": "连接中…",
"connect": "连接并添加"
},
- "composer": { "placeholder": "输入消息…", "inputAria": "输入消息", "generating": "正在生成", "send": "发送" },
+ "composer": { "placeholder": "输入消息…", "inputAria": "输入消息", "generating": "正在生成", "send": "发送", "model": "模型" },
"invocation": { "ariaLabel": "本轮调用上下文", "removeSkill": "移除技能 {{name}}", "removeAgent": "移除 Agent {{name}}" },
"visualization": { "cardAria": "{{label}} 图表", "viewAria": "{{label}} 显示方式", "preview": "预览", "code": "代码", "invalidEcharts": "ECharts 配置不是有效且安全的数据对象,请切换到代码检查内容。", "renderFailed": "图表暂时无法渲染,请切换到代码检查内容。", "echartsAria": "ECharts 图表预览", "rendering": "正在渲染图表…", "mermaidFailed": "图表暂时无法渲染,请切换到代码查看 Mermaid 内容。", "mermaidAria": "Mermaid 图表预览" }
- ,"markdown": { "playVideo": "点击播放视频:{{name}}", "enlargeImage": "放大预览:{{name}}", "image": "图片", "enlargeVideo": "点击放大视频", "videoPreview": "视频预览", "downloadVideo": "下载视频", "close": "关闭" }
+ ,"markdown": { "downloadFile": "下载文件", "downloading": "下载中…", "downloadFailed": "下载失败。请确认 Sandbox 和文件仍可用,然后点击重试。", "playVideo": "点击播放视频:{{name}}", "enlargeImage": "放大预览:{{name}}", "image": "图片", "enlargeVideo": "点击放大视频", "videoPreview": "视频预览", "downloadVideo": "下载视频", "close": "关闭" }
}
diff --git a/frontend/src/i18n/resources/zh-CN/cronjobs.json b/frontend/src/i18n/resources/zh-CN/cronjobs.json
index ba1623d96..71309e641 100644
--- a/frontend/src/i18n/resources/zh-CN/cronjobs.json
+++ b/frontend/src/i18n/resources/zh-CN/cronjobs.json
@@ -1,4 +1,111 @@
{
+ "mpa": {
+ "source": "任务来源",
+ "studio": "Studio 定时任务",
+ "title": "Runtime 定时任务",
+ "selectRuntime": "请先在 Agent 列表中选择一个云端 Runtime。",
+ "scope": "展示当前 Studio 用户在此 Runtime 中的定时任务。",
+ "refresh": "刷新",
+ "loading": "正在获取 Runtime 任务",
+ "empty": "此 Runtime 中没有符合条件的定时任务。",
+ "authRequired": "Runtime 拒绝了请求,请检查网关访问权限及 Runtime 的鉴权开关。",
+ "forbidden": "当前用户没有此 Runtime 的访问权限。",
+ "unsupported": "此 Runtime 未提供 mpa 定时任务接口。",
+ "invalidResponse": "任务接口返回了无法识别的数据,请检查 Runtime 版本。",
+ "loadFailed": "获取任务失败,请检查网络和 Runtime 状态后刷新。",
+ "pagination": "Runtime 任务分页",
+ "total": "共 {{total}} 条",
+ "previous": "上一页",
+ "next": "下一页",
+ "columns": {
+ "name": "任务名称",
+ "enabled": "状态",
+ "schedule": "执行计划",
+ "next": "下次执行",
+ "last": "最近结果"
+ },
+ "schedule": {
+ "once": "单次",
+ "interval": "间隔",
+ "daily": "每天",
+ "weekly": "每周",
+ "monthly": "每月",
+ "cron": "Cron"
+ },
+ "search": "搜索任务",
+ "executionCount": "执行次数",
+ "successRate": "成功率",
+ "prompt": "任务内容",
+ "manage": {
+ "view": "任务视图",
+ "list": "列表",
+ "calendar": "日历",
+ "create": "创建定时任务",
+ "edit": "编辑任务",
+ "copy": "复制",
+ "delete": "删除",
+ "detail": "详情",
+ "run": "立即执行",
+ "name": "任务",
+ "lastRun": "上次执行",
+ "schedule": "执行周期",
+ "lastStatus": "上次执行状态",
+ "agent": "执行 Agent",
+ "actions": "操作",
+ "all": "全部状态",
+ "Pending": "尚未执行",
+ "Queued": "已排队",
+ "Running": "执行中",
+ "Succeeded": "执行成功",
+ "Failed": "执行失败",
+ "TimedOut": "执行超时",
+ "Cancelled": "已取消",
+ "Skipped": "已跳过",
+ "overview": "执行统计:",
+ "prompt": "任务内容",
+ "timezone": "时区",
+ "localTime": "执行时间(当前设备时区)",
+ "time": "执行时间",
+ "Once": "单次",
+ "Interval": "间隔",
+ "Daily": "每天",
+ "Weekly": "每周",
+ "Monthly": "每月",
+ "Cron": "Cron 表达式",
+ "seconds": "秒",
+ "interval": "间隔秒数",
+ "weekdays": "星期(1 为周一,7 为周日,逗号分隔)",
+ "monthDays": "每月日期(1–31,逗号分隔)",
+ "delivery": "结果投递",
+ "Web": "Web",
+ "Feishu": "飞书",
+ "target": "飞书接收 ID",
+ "enabled": "启用任务",
+ "cancel": "取消",
+ "save": "保存",
+ "saving": "保存中",
+ "required": "请填写任务名称、Agent ID、任务内容和投递目标。",
+ "timezoneInvalid": "请输入有效的 IANA 时区,例如 Asia/Shanghai。",
+ "futureTime": "请选择未来的执行时间。",
+ "intervalInvalid": "间隔需要为 30–31536000 的整数秒。",
+ "daysInvalid": "请输入有效且不重复的日期或星期。",
+ "history": "执行记录",
+ "duration": "耗时",
+ "result": "结果 / 会话",
+ "noRuns": "暂无执行记录。",
+ "conflict": "任务已被修改或请求发生冲突,请关闭弹窗并刷新后重试。",
+ "invalid": "请求校验失败,请检查用户身份、执行时间、Agent ID 和任务配置。",
+ "queued": "已提交执行请求。",
+ "saved": "任务已更新。",
+ "deleteConfirm": "确定删除“{{name}}”?删除后将不再调度此任务。",
+ "calendarZone": "显示时区:",
+ "prevMonth": "上个月",
+ "nextMonth": "下个月",
+ "today": "今天",
+ "nextOnly": "仅下次",
+ "calendarHint": "日历展示预计执行安排;复杂 Cron 仅显示服务端给出的下一次执行时间。"
+ }
+ },
"actions": {
"backToList": "返回定时任务列表",
"cancel": "取消",
diff --git a/frontend/src/i18n/resources/zh-CN/newChat.json b/frontend/src/i18n/resources/zh-CN/newChat.json
index 00a30059c..a13ed8730 100644
--- a/frontend/src/i18n/resources/zh-CN/newChat.json
+++ b/frontend/src/i18n/resources/zh-CN/newChat.json
@@ -55,6 +55,7 @@
"types": {
"agent": "智能体",
"general": "通用智能体",
+ "mpa": "MPA 智能体",
"codex": "Codex 智能体",
"deepseekHarness": "DeepSeek Harness",
"openclaw": "OpenClaw 智能体",
diff --git a/frontend/src/i18n/resources/zh-CN/ui.json b/frontend/src/i18n/resources/zh-CN/ui.json
index 1804d3e0d..3e200f776 100644
--- a/frontend/src/i18n/resources/zh-CN/ui.json
+++ b/frontend/src/i18n/resources/zh-CN/ui.json
@@ -439,6 +439,7 @@
"environmentCenter": {
"title": "环境",
"loadFailed": "环境加载失败,请检查存储配置后重试。",
+ "storageUnavailable": "管理员未配置持久化存储,环境管理暂不可用。",
"create": "新建环境",
"configure": "配置环境",
"details": "环境详情",
@@ -1158,6 +1159,7 @@
},
"workspace": {
"title": "工作区",
+ "storageUnavailable": "管理员未配置持久化存储,工作区管理暂不可用。",
"detail": "工作区详情",
"create": "新建工作区",
"editorDescription": "将常用环境组合在一起;同一个环境可以加入多个工作区。",
@@ -1233,6 +1235,24 @@
}
},
"composer": {
+ "model": "模型",
+ "turnControls": "流程控制",
+ "pause": "暂停",
+ "resume": "恢复",
+ "interrupt": "中断",
+ "cancel": "取消",
+ "pauseTurn": "在安全点暂停完整流程",
+ "resumeTurn": "恢复完整流程",
+ "interruptTurn": "中断当前流程并保留会话上下文",
+ "cancelTurn": "取消当前流程且不可恢复",
+ "turnState": {
+ "running": "运行中",
+ "pausing": "正在到达安全点",
+ "paused": "已暂停",
+ "resuming": "正在恢复",
+ "interrupting": "正在中断",
+ "cancelling": "正在取消"
+ },
"tasks": {
"ppt": "PPT",
"image": "图片生成",
@@ -1373,6 +1393,7 @@
"agent": "智能体",
"agentTypes": {
"general": "通用智能体",
+ "mpa": "MPA 智能体",
"codex": "Codex",
"deepseek-harness": "DeepSeek",
"openclaw": "OpenClaw",
diff --git a/frontend/src/i18n/resources/zh-CN/workspaceTools.json b/frontend/src/i18n/resources/zh-CN/workspaceTools.json
index e768a3d69..c9129307b 100644
--- a/frontend/src/i18n/resources/zh-CN/workspaceTools.json
+++ b/frontend/src/i18n/resources/zh-CN/workspaceTools.json
@@ -254,8 +254,13 @@
"deleteRuntime": "删除该 Runtime",
"loadingDetail": "读取详情…",
"agentStructure": "Agent 结构",
- "secretHidden": "敏感值已隐藏,点击显示",
- "revealSecret": "显示 {{key}} 的值",
+ "secretHidden": "敏感值不展示,可通过授权请求直接复制",
+ "revealSecret": "{{key}} 的敏感值",
+ "copySecret": "复制 {{key}} 的值",
+ "copy": "复制",
+ "copied": "已复制",
+ "copyFailed": "复制失败",
+ "notConfigured": "未配置",
"fields": {
"model": "模型",
"description": "描述",
@@ -283,9 +288,10 @@
"itemCount_one": "{{count}} 项",
"itemCount_other": "{{count}} 项",
"info": "Agent 信息",
- "infoAndTopology": "Agent 信息与拓扑",
+ "infoAndTopology": "Agent 信息与设置",
"loadingInfo": "正在读取 Agent 信息…",
"unnamedAgent": "未命名 Agent",
+ "agentsMd": "AGENTS.md",
"tools": "工具",
"toolList": "工具列表",
"studioTool": "Studio Tool",
@@ -296,11 +302,16 @@
"addStudioToolHere": "在此对话中添加 Studio 工具",
"skills": "技能",
"skillList": "技能列表",
+ "addSkill": "添加技能空间技能",
+ "addSkillHere": "向此会话添加技能空间技能",
+ "skillMountNextTurn": "挂载的版本将在下一 Turn 开始时冻结。",
"previewUnsupported": "暂不支持预览",
"sessionEnvironment": "会话环境",
"environment": "环境",
"agentCanvas": "Agent 画布",
"topology": "结构拓扑",
+ "nodeKinds": { "agent": "Agent", "sandbox": "沙箱", "worker-session": "Worker 会话", "skill-space": "技能空间", "skill": "技能", "studio-tool": "Studio 工具", "mcp": "MCP", "environment": "环境", "knowledge": "知识库", "sub-agent": "子 Agent" },
+ "nodeStatuses": { "configured": "已配置", "mounted": "已挂载", "running": "运行中", "paused": "已暂停" },
"viewCanvasFullscreen": "全屏查看 Agent 画布",
"viewFullscreen": "全屏查看",
"executionCanvas": "Agent 执行画布",
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index f43adbe45..39018cc6d 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -1707,6 +1707,34 @@ body {
.md li > ul, .md li > ol { margin: 0.15em 0; }
.md a { color: hsl(var(--primary)); text-decoration: underline; text-underline-offset: 2px; }
.md a:hover { opacity: 0.8; }
+.md a.sandbox-file-download {
+ display: flex;
+ width: fit-content;
+ align-items: center;
+ gap: 10px;
+ max-width: 100%;
+ min-height: 44px;
+ box-sizing: border-box;
+ margin: 6px 0;
+ padding: 10px 18px;
+ border: 1px solid hsl(var(--primary));
+ border-radius: 10px;
+ background: hsl(var(--primary));
+ color: hsl(var(--primary-foreground));
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 1.5;
+ text-decoration: none;
+ vertical-align: middle;
+ overflow-wrap: anywhere;
+}
+.md a.sandbox-file-download > span:last-child { min-width: 0; }
+.md a.sandbox-file-download:focus-visible {
+ outline: 2px solid hsl(var(--ring));
+ outline-offset: 3px;
+}
+.md a.sandbox-file-download[aria-disabled="true"] { opacity: 0.65; cursor: wait; }
+
.md blockquote {
margin: 0 0 0.7em;
padding: 0.1em 0.9em;
@@ -3031,6 +3059,7 @@ body {
0 8px 32px rgb(0 0 0 / 0.028),
0 24px 72px 8px rgb(0 0 0 / 0.02);
}
+.composer--new-chat .composer-box--has-model { padding-bottom: 56px; }
.composer-input-stack {
position: relative;
display: flex;
@@ -3153,11 +3182,7 @@ body {
opacity: 1;
transform: scale(1);
}
-.composer--new-chat .comp-send {
- position: absolute;
- right: 10px;
- bottom: 10px;
-}
+.composer--new-chat .comp-send { position: static; }
.composer--new-chat .comp-send .icon {
width: 20px;
height: 20px;
@@ -3440,7 +3465,87 @@ body {
height: 36px;
flex-shrink: 0;
}
-.composer--new-chat .composer-submit-actions { display: contents; }
+
+.composer-model-select {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: hsl(var(--muted-foreground));
+ font-size: 11px;
+}
+.composer-turn-status { display: inline-flex; min-width: 0; align-items: center; }
+.composer-turn-state { color: hsl(var(--muted-foreground)); font-size: 11px; }
+
+.composer-model-select .new-chat-compact-select {
+ width: min(260px, 30vw);
+ max-width: 260px;
+}
+
+.composer-model-select .new-chat-compact-select__trigger {
+ width: 100%;
+ max-width: none;
+ min-height: 36px;
+ border: 0;
+ border-radius: 7px;
+ background: transparent;
+ color: hsl(var(--muted-foreground));
+ padding: 0 8px;
+ font: inherit;
+ font-size: 13px;
+}
+
+.composer-model-select .new-chat-compact-select__menu {
+ top: auto;
+ right: 0;
+ bottom: calc(100% + 6px);
+ left: auto;
+ width: min(360px, calc(100vw - 32px));
+}
+
+.composer-model-select .new-chat-compact-select__list {
+ max-height: min(260px, calc(100dvh - 220px));
+}
+.composer--new-chat .composer-submit-actions {
+ position: absolute;
+ z-index: 5;
+ left: auto;
+ right: 10px;
+ bottom: 10px;
+ display: flex;
+ width: auto;
+ max-width: calc(100% - 300px);
+ min-width: 0;
+ pointer-events: none;
+}
+.composer--new-chat .composer-submit-actions > * { pointer-events: auto; }
+.composer--new-chat .composer-model-select {
+ min-width: 0;
+ margin-left: auto;
+}
+.composer--new-chat .composer-model-select .new-chat-compact-select__trigger {
+ width: min(260px, 30vw);
+ min-width: 0;
+}
+@media (max-width: 640px) {
+ .composer--new-chat .new-chat-agent-picker { bottom: 54px; }
+ .composer--new-chat .composer-box--has-model { padding-bottom: 96px; }
+ .composer--new-chat .composer-submit-actions {
+ left: 10px;
+ right: 10px;
+ width: calc(100% - 20px);
+ max-width: none;
+ justify-content: flex-end;
+ }
+ .composer--new-chat .composer-model-select {
+ margin-left: 0;
+ margin-right: auto;
+ }
+ .composer--new-chat .composer-model-select,
+ .composer--new-chat .composer-model-select .new-chat-compact-select {
+ width: min(210px, 52vw);
+ max-width: min(210px, 52vw);
+ }
+}
.comp-send {
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
@@ -3684,6 +3789,11 @@ body {
text-overflow: ellipsis;
white-space: nowrap;
}
+.token-usage-detail { margin-top: 10px; border-top: 1px solid hsl(var(--border)); padding-top: 8px; }
+.token-usage-detail__head, .token-usage-detail__row { display: grid; grid-template-columns: minmax(84px, 1fr) auto auto; gap: 12px; align-items: baseline; }
+.token-usage-detail__head { color: hsl(var(--muted-foreground)); font-size: 10px; margin-bottom: 4px; }
+.token-usage-detail__row { font-size: 11px; padding: 2px 0; }
+.token-usage-detail__row strong { font-variant-numeric: tabular-nums; text-align: right; }
@media (prefers-reduced-motion: reduce) {
.token-usage-indicator,
@@ -5893,7 +6003,6 @@ a.search-result { text-decoration: none; color: inherit; }
line-height: 1;
white-space: nowrap;
}
-.topo-remove-capability:disabled,
.topo-capability-add-slot:disabled {
cursor: not-allowed;
opacity: 0.45;
@@ -5919,8 +6028,18 @@ a.search-result { text-decoration: none; color: inherit; }
outline-offset: 3px;
border-radius: 5px;
}
-.topo-tools-scroll { max-height: 104px; }
+.topo-agents-md-scroll { max-height: 132px; }
.topo-skills-scroll { max-height: 152px; }
+.topo-agents-md-content {
+ margin: 0;
+ color: hsl(var(--foreground));
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+ "Liberation Mono", "Courier New", monospace;
+ font-size: 11px;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
.session-environment-select {
display: flex;
min-width: 0;
@@ -6221,41 +6340,6 @@ a.search-result { text-decoration: none; color: inherit; }
outline: 2px solid hsl(var(--ring) / 0.38);
outline-offset: 2px;
}
-.topo-custom-badge {
- display: inline-flex;
- align-items: center;
- height: 17px;
- padding: 0 5px;
- flex-shrink: 0;
- border-radius: 5px;
- background: hsl(var(--primary) / 0.1);
- color: hsl(var(--primary));
- font-size: 9.5px;
- font-weight: 650;
- line-height: 1;
-}
-.topo-remove-capability {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 20px;
- height: 20px;
- margin-left: auto;
- padding: 0;
- flex-shrink: 0;
- border: 0;
- border-radius: 6px;
- background: transparent;
- color: hsl(var(--muted-foreground));
- font-size: 15px;
- line-height: 1;
- cursor: pointer;
-}
-.topo-remove-capability svg { width: 14px; height: 14px; }
-.topo-remove-capability:hover:not(:disabled) {
- background: hsl(var(--destructive) / 0.1);
- color: hsl(var(--destructive));
-}
.topo-skill-list {
display: flex;
min-width: 0;
@@ -6302,119 +6386,6 @@ a.search-result { text-decoration: none; color: inherit; }
font-size: 11.5px;
line-height: 1.5;
}
-.topo-topology {
- min-height: 0;
-}
-.topo-canvas-heading {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- margin-bottom: 9px;
-}
-.topo-canvas-expand,
-.topo-canvas-dialog-header button {
- display: inline-flex;
- width: 30px;
- height: 30px;
- align-items: center;
- justify-content: center;
- padding: 0;
- border: 0;
- border-radius: 8px;
- background: transparent;
- color: hsl(var(--muted-foreground));
- cursor: pointer;
-}
-.topo-canvas-expand:hover,
-.topo-canvas-dialog-header button:hover {
- background: hsl(var(--muted));
- color: hsl(var(--foreground));
-}
-.topo-canvas-expand:focus-visible,
-.topo-canvas-dialog-header button:focus-visible {
- outline: 2px solid hsl(var(--ring) / 0.5);
- outline-offset: 2px;
-}
-.topo-canvas-expand svg,
-.topo-canvas-dialog-header button svg {
- width: 16px;
- height: 16px;
-}
-.topo-canvas-preview {
- position: relative;
- min-height: 120px;
- flex: 1;
- overflow: hidden;
- border: 1px solid hsl(var(--border));
- border-radius: 12px;
- background: hsl(var(--background));
-}
-.topo-canvas-preview .abc-root,
-.topo-canvas-dialog-body .abc-root {
- width: 100%;
- height: 100%;
- min-width: 0;
- flex: 1 1 auto;
- border: 0;
-}
-.topo-canvas-preview .abc-minimap { display: none; }
-.topo-canvas-dialog {
- position: fixed;
- z-index: 1200;
- inset: 0;
- display: flex;
- min-width: 0;
- min-height: 0;
- flex-direction: column;
- background: hsl(var(--background));
-}
-.topo-canvas-dialog-header {
- display: flex;
- min-height: 64px;
- align-items: center;
- justify-content: space-between;
- gap: 24px;
- padding: 0 24px;
- border-bottom: 1px solid hsl(var(--border));
-}
-.topo-canvas-dialog-header > div {
- display: flex;
- min-width: 0;
- align-items: baseline;
- gap: 10px;
-}
-.topo-canvas-dialog-header strong {
- font-size: 15px;
- font-weight: 600;
-}
-.topo-canvas-dialog-header span {
- overflow: hidden;
- color: hsl(var(--muted-foreground));
- font-size: 13px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.topo-canvas-dialog-body {
- display: flex;
- min-width: 0;
- min-height: 0;
- flex: 1;
- padding: 16px;
-}
-.topo-canvas-dialog-body .abc-canvas {
- overflow: hidden;
- border: 1px solid hsl(var(--border));
- border-radius: 16px;
-}
-@media (max-width: 640px) {
- .topo-canvas-dialog-header { padding: 0 16px; }
- .topo-canvas-dialog-body { padding: 8px; }
-}
-/* Keep the fullscreen control visually quiet inside the information rail. */
-.topo-canvas-heading .topo-section-count {
- flex-shrink: 0;
-}
@media (min-width: 1280px) {
.agent-info-trigger { display: none; }
.topo:not(.is-drawer) .topo-module-scroll { max-height: none; }
diff --git a/frontend/src/ui/AgentSelector.tsx b/frontend/src/ui/AgentSelector.tsx
index 43b3d51ca..e9387399c 100644
--- a/frontend/src/ui/AgentSelector.tsx
+++ b/frontend/src/ui/AgentSelector.tsx
@@ -914,7 +914,7 @@ function RuntimeDetailContent({ runtime }: { runtime: SelectedRuntime }) {
{detail.envs.map((e) => (
{e.key}
- {e.value}
+ {e.sensitive ? "••••••••" : e.value}
))}
diff --git a/frontend/src/ui/AgentTopology.tsx b/frontend/src/ui/AgentTopology.tsx
index e6a88f2a2..cd48ad591 100644
--- a/frontend/src/ui/AgentTopology.tsx
+++ b/frontend/src/ui/AgentTopology.tsx
@@ -1,85 +1,19 @@
-import { useEffect, useRef, useState, type RefObject } from "react";
-import type { TFunction } from "i18next";
+import { useEffect, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
-import { Maximize2, X } from "lucide-react";
+import { X } from "lucide-react";
import type {
AgentInfo,
- AgentNode,
SessionEnvironmentMountSelection,
- StudioBffTool,
StudioEnvironment,
StudioWorkspace,
} from "../adk/client";
-import { AgentBuildCanvas } from "../create/AgentBuildCanvas";
-import {
- modelConfigurationFromRuntime,
- modelNameFromRuntime,
-} from "../create/runtimeModelName";
-import { emptyDraft, type AgentDraft } from "../create/types";
-import {
- studioToolLabel,
- StudioToolDialog,
-} from "./StudioToolDialog";
+import { SkillSpacePicker } from "../create/SkillSpacePicker";
+import type { SelectedSkill } from "../create/skills/types";
+import { modelNameFromRuntime } from "../create/runtimeModelName";
import { TextShimmer } from "./text-shimmer/TextShimmer";
import { SessionEnvironmentPicker } from "./SessionEnvironmentPicker";
-function totalNodes(node: AgentNode): number {
- return 1 + node.children.reduce((count, child) => count + totalNodes(child), 0);
-}
-
-function nodeId(node: AgentNode): string {
- return node.id || node.name;
-}
-
-/** Older generated runtimes exposed only Python variable names. Keep those
- * identifiers for event matching, but do not leak them into the UI. */
-function legacyDisplayName(node: AgentNode, isRoot: boolean, t: TFunction): string {
- const id = nodeId(node);
- if (node.id && node.name && node.name !== id) return node.name;
- if (isRoot && id === "agent") return t("agentTopology.mainAgent");
- const subAgent = /^agent_sub_(\d+)$/.exec(id);
- return subAgent
- ? t("agentTopology.subAgent", { index: subAgent[1] })
- : node.name || id;
-}
-
-function normalizeLegacyNames(
- node: AgentNode,
- t: TFunction,
- isRoot = true,
-): AgentNode {
- return {
- ...node,
- id: nodeId(node),
- name: legacyDisplayName(node, isRoot, t),
- children: node.children.map((child) => normalizeLegacyNames(child, t, false)),
- };
-}
-
-function graphNodeToCanvasDraft(node: AgentNode): AgentDraft {
- const fallback = emptyDraft();
- const runtimeModel = modelConfigurationFromRuntime(node.model);
- return {
- ...fallback,
- name: node.name,
- description: node.description,
- instruction: node.instruction || fallback.instruction,
- agentType: node.type,
- modelName: runtimeModel.modelName,
- modelProvider: runtimeModel.modelProvider,
- tools: node.tools ?? [],
- skills: (node.skills ?? []).map((skill) => skill.name),
- subAgents: node.children.map(graphNodeToCanvasDraft),
- };
-}
-
-function uniqueValues(values: string[]): string[] {
- return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
-}
-
-const INTERNAL_AGENT_TOOL_NAMES = new Set(["StudioExternalToolset"]);
-
function uniqueSkills(skills: AgentInfo["skills"]): AgentInfo["skills"] {
return [
...new Map(
@@ -113,20 +47,11 @@ function ModuleTitle({ title, count }: ModuleTitleProps) {
}
interface AgentInfoPanelProps {
- appName: string;
info: AgentInfo | null;
loading: boolean;
- activeAgent: string;
- seenAgents: Set;
- execPath?: string[];
variant?: "rail" | "drawer";
- studioTools?: StudioBffTool[];
- selectedStudioToolIds?: readonly string[];
- managedStudioToolIds?: readonly string[];
- studioToolsLoading?: boolean;
- studioToolsDisabled?: boolean;
- studioToolsUnavailableReason?: string;
- onStudioToolsChange?: (selectedIds: string[]) => void;
+ selectedSessionSkills?: readonly SelectedSkill[];
+ onSessionSkillsChange?: (skills: SelectedSkill[]) => void;
environments?: StudioEnvironment[];
workspaces?: StudioWorkspace[];
selectedEnvironments?: readonly SessionEnvironmentMountSelection[];
@@ -145,17 +70,11 @@ interface AgentInfoPanelProps {
* right whitespace. The parent owns metadata loading so this display component
* never issues a duplicate `/web/agent-info` request. */
export function AgentInfoPanel({
- appName,
info,
loading,
variant = "rail",
- studioTools = [],
- selectedStudioToolIds = [],
- managedStudioToolIds = [],
- studioToolsLoading = false,
- studioToolsDisabled = false,
- studioToolsUnavailableReason = "",
- onStudioToolsChange,
+ selectedSessionSkills = [],
+ onSessionSkillsChange,
environments = [],
workspaces = [],
selectedEnvironments = [],
@@ -167,26 +86,7 @@ export function AgentInfoPanel({
onEnvironmentsRefresh,
}: AgentInfoPanelProps) {
const { t } = useTranslation("workspaceTools");
- const [dialog, setDialog] = useState<"tool" | null>(null);
- const [canvasExpanded, setCanvasExpanded] = useState(false);
- const expandCanvasRef = useRef(null);
- const closeCanvas = () => {
- setCanvasExpanded(false);
- window.requestAnimationFrame(() => expandCanvasRef.current?.focus());
- };
- useEffect(() => {
- if (!canvasExpanded) return;
- const previousOverflow = document.body.style.overflow;
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === "Escape") closeCanvas();
- };
- document.body.style.overflow = "hidden";
- document.addEventListener("keydown", handleKeyDown);
- return () => {
- document.body.style.overflow = previousOverflow;
- document.removeEventListener("keydown", handleKeyDown);
- };
- }, [canvasExpanded]);
+ const [dialog, setDialog] = useState<"skill" | null>(null);
if (loading && !info) {
return (
- {canvasExpanded && createPortal(
-
-
-
- {t("agentTopology.executionCanvas")}
- {info.name}
-
+ {dialog === "skill" && onSessionSkillsChange && createPortal(
+
-
-
- {renderCanvas(`conversation-canvas-fullscreen:${appName}`)}
-
- ,
- document.body,
- )}
+ className="studio-tool-dialog-scrim"
+ aria-label={t("agentTopology.close")}
+ onClick={() => setDialog(null)}
+ />
+
+
,
+ document.body,
+ )}
+
>
);
}
@@ -476,19 +276,8 @@ function CloseIcon() {
}
export function AgentInfoDrawer({
- appName,
info,
loading,
- activeAgent,
- seenAgents,
- execPath,
- studioTools,
- selectedStudioToolIds,
- managedStudioToolIds,
- studioToolsLoading,
- studioToolsDisabled,
- studioToolsUnavailableReason,
- onStudioToolsChange,
environments,
workspaces,
selectedEnvironments,
@@ -501,19 +290,8 @@ export function AgentInfoDrawer({
onClose,
returnFocusRef,
}: {
- appName: string;
info: AgentInfo | null;
loading: boolean;
- activeAgent: string;
- seenAgents: Set;
- execPath: string[];
- studioTools?: StudioBffTool[];
- selectedStudioToolIds?: readonly string[];
- managedStudioToolIds?: readonly string[];
- studioToolsLoading?: boolean;
- studioToolsDisabled?: boolean;
- studioToolsUnavailableReason?: string;
- onStudioToolsChange?: (selectedIds: string[]) => void;
environments?: StudioEnvironment[];
workspaces?: StudioWorkspace[];
selectedEnvironments?: readonly SessionEnvironmentMountSelection[];
@@ -573,19 +351,8 @@ export function AgentInfoDrawer({
{info || loading ? (
void;
@@ -292,10 +307,12 @@ export function ThinkingBlock({
/>
{done ? (
- {t("blocks.thinkingDone")}
+
+ {t(thoughtKind === "reasoning" ? "blocks.reasoningDone" : "blocks.thinkingDone")}
+
) : (
- {t("blocks.thinking")}
+ {t(thoughtKind === "reasoning" ? "blocks.reasoning" : "blocks.thinking")}
)}
@@ -375,7 +392,9 @@ function DeliveryCard({
? t("blocks.justNow")
: Number.isNaN(validatedAt.getTime())
? value.validatedAt
- : validatedAt.toLocaleString(i18n.resolvedLanguage ?? i18n.language, { hour12: false });
+ : validatedAt.toLocaleString(i18n.resolvedLanguage ?? i18n.language, {
+ hour12: false,
+ });
useEffect(() => {
if (!downloadStatus) return;
@@ -456,7 +475,11 @@ function DeliveryCard({
<>
-
{value.verified ? t("blocks.validationTime") : t("blocks.generationTime")}
+
+ {value.verified
+ ? t("blocks.validationTime")
+ : t("blocks.generationTime")}
+
{time}
@@ -496,9 +525,7 @@ function DeliveryCard({
· {value.artifactSha256.slice(0, 12)}
{!value.verified ? (
-
- {t("blocks.sourceGuidance")}
-
+ {t("blocks.sourceGuidance")}
) : null}
) : null}
- {busyAction === "compare" ? t("blocks.preparing") : t("blocks.viewChanges")}
+ {busyAction === "compare"
+ ? t("blocks.preparing")
+ : t("blocks.viewChanges")}
) : null}
) : null}
- {busyAction === "download" ? t("blocks.preparing") : t("blocks.downloadSource")}
+ {busyAction === "download"
+ ? t("blocks.preparing")
+ : t("blocks.downloadSource")}
;
+ }
return
;
}
@@ -726,6 +761,7 @@ function studioToolArtifacts(response: unknown): StudioToolArtifact[] {
* treatments share the same header and detail alignment. */
function ToolBlock({
name,
+ callId,
args,
response,
done,
@@ -733,10 +769,12 @@ function ToolBlock({
defaultOpen = false,
retrying = false,
codexActivity,
+ source,
onBranchSelect,
onAction,
}: {
name: string;
+ callId?: string;
args?: unknown;
response?: unknown;
done: boolean;
@@ -744,6 +782,7 @@ function ToolBlock({
defaultOpen?: boolean;
retrying?: boolean;
codexActivity?: Extract
["codexActivity"];
+ source?: Extract["source"];
onBranchSelect?: (branch: BranchCompareBranch) => void;
onAction: BlocksProps["onAction"];
}) {
@@ -776,6 +815,50 @@ function ToolBlock({
};
const label = name === A2UI_TOOL ? t("blocks.renderUi") : name;
const studioArtifacts = studioToolArtifacts(response);
+ if ((!builtinTool || !DetailRenderer) && !codexActivity) {
+ const activityTitle = builtinTool
+ ? toolStatus === "failed"
+ ? t(`blocks.tools.${builtinTool.name}.failed`, {
+ defaultValue: builtinTool.failedLabel ?? builtinTool.doneLabel,
+ })
+ : toolStatus === "running"
+ ? t(`blocks.tools.${builtinTool.name}.running`, {
+ defaultValue: builtinTool.runningLabel,
+ })
+ : t(`blocks.tools.${builtinTool.name}.done`, {
+ defaultValue: builtinTool.doneLabel,
+ })
+ : undefined;
+ return (
+
+ {studioArtifacts.length > 0 ? (
+
+ ) : null}
+
+ );
+ }
const respText =
response == null
? null
@@ -802,7 +885,8 @@ function ToolBlock({
? t("blocks.agentAdjusting")
: toolStatus === "failed"
? t(`blocks.tools.${builtinTool.name}.failed`, {
- defaultValue: builtinTool.failedLabel ?? builtinTool.doneLabel,
+ defaultValue:
+ builtinTool.failedLabel ?? builtinTool.doneLabel,
})
: loadSkillLabel(name, args, t)
}
@@ -877,7 +961,9 @@ function ToolBlock({
{args != null && (
-
{t("blocks.arguments")}
+
+ {t("blocks.arguments")}
+
{JSON.stringify(args, null, 2)}
@@ -891,7 +977,9 @@ function ToolBlock({
)}
{studioArtifacts.length > 0 && (
-
{t("blocks.artifacts")}
+
+ {t("blocks.artifacts")}
+
-

+
@@ -1128,7 +1221,9 @@ function AuthCard({
>
- {t("blocks.authorizationRequired", { tool: toolLabel })}
+
+ {t("blocks.authorizationRequired", { tool: toolLabel })}
+
{!block.authUri && (
- {t("blocks.missingAuthorizationUrl")}
+
+ {t("blocks.missingAuthorizationUrl")}
+
)}
{err && {err}
}
@@ -1200,6 +1297,90 @@ export interface BlocksProps {
onBranchSelect?: (branch: BranchCompareBranch) => void;
}
+type DisplayBlock =
+ Block | { kind: "tool-exploration"; items: ToolActivityInput[] };
+
+function toolActivityTitle(
+ block: Extract,
+ status: "running" | "completed" | "failed",
+ t: TFunction,
+): string | undefined {
+ const definition = getBuiltinToolDefinition(block.name);
+ const skillLabel = loadSkillLabel(block.name, block.args, t);
+ if (skillLabel || !definition) return skillLabel;
+ const key =
+ status === "failed"
+ ? "failed"
+ : status === "running"
+ ? "running"
+ : "done";
+ const fallback =
+ status === "failed"
+ ? (definition.failedLabel ?? definition.doneLabel)
+ : status === "running"
+ ? definition.runningLabel
+ : definition.doneLabel;
+ return t(`blocks.tools.${definition.name}.${key}`, { defaultValue: fallback });
+}
+
+function toolInput(
+ block: Extract,
+ t?: TFunction,
+): ToolActivityInput {
+ const status = block.status ?? (block.done ? "completed" : "running");
+ return {
+ name: block.name,
+ title: t ? toolActivityTitle(block, status, t) : undefined,
+ callId: block.callId,
+ args: block.args,
+ response: block.response,
+ done: block.done,
+ status: block.status,
+ defaultOpen: block.defaultOpen,
+ source: block.source,
+ };
+}
+
+function groupDisplayBlocks(blocks: Block[], t: TFunction): DisplayBlock[] {
+ const output: DisplayBlock[] = [];
+ let candidates: Extract[] = [];
+ const flush = () => {
+ if (!candidates.length) return;
+ output.push(
+ candidates.length > 1
+ ? {
+ kind: "tool-exploration",
+ items: candidates.map((item) => toolInput(item, t)),
+ }
+ : candidates[0],
+ );
+ candidates = [];
+ };
+ for (const block of blocks) {
+ if (
+ block.kind !== "tool" ||
+ getBuiltinToolDefinition(block.name)?.detailRenderer
+ ) {
+ flush();
+ output.push(block);
+ continue;
+ }
+ const presentation = presentToolActivity(toolInput(block));
+ const sameSource =
+ !candidates.length ||
+ candidates[candidates.length - 1].source === block.source;
+ if (isSafeExploration(presentation) && sameSource) {
+ candidates.push(block);
+ continue;
+ }
+ flush();
+ if (isSafeExploration(presentation)) candidates.push(block);
+ else output.push(block);
+ }
+ flush();
+ return output;
+}
+
export function Blocks({
blocks,
appName = "",
@@ -1216,18 +1397,33 @@ export function Blocks({
onDeployDelivery,
onBranchSelect,
}: BlocksProps) {
- const lastTextBlockIndex = blocks.reduce(
+ const { t } = useTranslation("conversation");
+ const displayBlocks = groupDisplayBlocks(
+ flattenCodexActivityBlocks(blocks),
+ t,
+ );
+ const lastTextBlockIndex = displayBlocks.reduce(
(lastIndex, block, index) => (block.kind === "text" ? index : lastIndex),
-1,
);
return (
<>
- {blocks.map((b, i) => {
+ {displayBlocks.map((b, i) => {
switch (b.kind) {
case "progress":
return ;
+ case "activity-source":
+ return (
+
+ {b.label}
+
+ );
+ case "tool-exploration":
+ return (
+
+ );
case "thinking": {
- const answerStarted = blocks
+ const answerStarted = displayBlocks
.slice(i + 1)
.some(
(block) => block.kind === "text" && Boolean(block.text.trim()),
@@ -1237,6 +1433,7 @@ export function Blocks({
key={i}
text={b.text}
done={b.done}
+ thoughtKind={b.thoughtKind}
answerStarted={answerStarted}
streaming={streaming}
onStreamFrame={onStreamFrame}
@@ -1295,7 +1492,7 @@ export function Blocks({
if (b.name === A2UI_TOOL && b.done) return null;
const hasLaterCreateAgentAttempt =
b.name === "create_agents" &&
- blocks
+ displayBlocks
.slice(i + 1)
.some(
(block) =>
@@ -1305,6 +1502,7 @@ export function Blocks({
diff --git a/frontend/src/ui/Composer.tsx b/frontend/src/ui/Composer.tsx
index 6e24b654f..a8a5aa5f3 100644
--- a/frontend/src/ui/Composer.tsx
+++ b/frontend/src/ui/Composer.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useLayoutEffect, useRef, useState } from "react";
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ComponentType, SVGProps } from "react";
import {
AtSign,
@@ -10,6 +10,8 @@ import {
ImageIcon,
Loader2,
MonitorPlay,
+ Pause,
+ Play,
Plus,
Sparkles,
X,
@@ -23,6 +25,7 @@ import type {
CloudRuntime,
FrontendInvocation,
RuntimeScope,
+ TurnControlState,
} from "../adk/client";
import type { CloudProvider } from "../adk/cloudProvider";
import type { RuntimeLogTarget } from "../adk/runtimeLogs";
@@ -138,6 +141,12 @@ export interface ComposerProps {
invocation: FrontendInvocation;
capabilitiesLoading?: boolean;
modelName: string;
+ selectableModels?: readonly string[];
+ selectedModel?: string;
+ onSelectedModelChange?: (model: string) => void;
+ turnControl?: TurnControlState | null;
+ turnControlBusy?: boolean;
+ onTurnControl?: (action: "pause" | "resume") => void;
tokenUsage: SessionTokenUsage;
systemTokenEstimate: number | null;
allowAttachments?: boolean;
@@ -196,6 +205,12 @@ export function Composer({
invocation,
capabilitiesLoading = false,
modelName,
+ selectableModels = [],
+ selectedModel = "",
+ onSelectedModelChange,
+ turnControl = null,
+ turnControlBusy = false,
+ onTurnControl,
tokenUsage,
systemTokenEstimate,
allowAttachments = true,
@@ -236,6 +251,11 @@ export function Composer({
const imageInput = useRef(null);
const documentInput = useRef(null);
const videoInput = useRef(null);
+ const hasTurnModelSelector = selectableModels.length > 1 && Boolean(onSelectedModelChange);
+ const turnModelOptions = useMemo(
+ () => selectableModels.map((model) => ({ value: model, label: model })),
+ [selectableModels],
+ );
const [menuOpen, setMenuOpen] = useState(false);
const [trigger, setTrigger] = useState(null);
const [activeIndex, setActiveIndex] = useState(0);
@@ -331,7 +351,20 @@ export function Composer({
: null;
const videoTaskRunning = isVideoTaskRunning(videoTask);
const canOpenVideoTask = videoMode && Boolean(videoTask) && !value.trim();
- const canStop = busy && Boolean(onStop);
+ const canPauseTurn = turnControl?.allowedActions.includes("pause") === true;
+ const canResumeTurn = turnControl?.allowedActions.includes("resume") === true;
+ const turnControlPending = turnControlBusy || ["pausing", "resuming"].includes(turnControl?.state ?? "");
+ const canStop = busy && Boolean(onStop) && !turnControl;
+ const turnStateLabel = turnControl
+ ? ({
+ running: t("composer.turnState.running"),
+ pausing: t("composer.turnState.pausing"),
+ paused: t("composer.turnState.paused"),
+ resuming: t("composer.turnState.resuming"),
+ interrupting: t("composer.turnState.interrupting"),
+ cancelling: t("composer.turnState.cancelling"),
+ }[turnControl.state] ?? turnControl.state)
+ : "";
const canSend = videoMode
? videoTaskRunning ||
canOpenVideoTask ||
@@ -557,7 +590,7 @@ export function Composer({
? "new-chat-workspace-panel"
: undefined
}
- className="composer-box"
+ className={`composer-box${hasTurnModelSelector ? " composer-box--has-model" : ""}`}
role={newChatLayout && showWorkspaceTabs ? "tabpanel" : undefined}
aria-labelledby={
newChatLayout && showWorkspaceTabs
@@ -895,6 +928,27 @@ export function Composer({
) : null}
+ {turnControl && turnStateLabel ? (
+
+
+ {turnStateLabel}
+
+
+ ) : null}
+ {hasTurnModelSelector && onSelectedModelChange ? (
+
+
+
+ ) : null}
{sessionId && appName && newChatWorkspaceMode === "agent" ? (
onTurnControl("pause")
+ : canResumeTurn && onTurnControl
+ ? () => onTurnControl("resume")
+ : canStop
+ ? onStop
+ : submitComposer
+ }
aria-label={
- canStop
- ? t("composer.stopGenerating")
+ canPauseTurn
+ ? t("composer.pauseTurn")
+ : canResumeTurn
+ ? t("composer.resumeTurn")
+ : turnControlPending
+ ? turnStateLabel
+ : canStop
+ ? t("composer.stopGenerating")
: videoTaskRunning || canOpenVideoTask
? t("composer.viewVideoProgress")
: t("composer.send")
}
- title={canStop ? t("composer.stopGenerating") : videoCapabilitiesError || undefined}
- whileTap={canStop || canSend ? { scale: 0.9 } : undefined}
+ title={canPauseTurn ? t("composer.pauseTurn") : canResumeTurn ? t("composer.resumeTurn") : canStop ? t("composer.stopGenerating") : videoCapabilitiesError || undefined}
+ whileTap={canPauseTurn || canResumeTurn || canStop || canSend ? { scale: 0.9 } : undefined}
transition={{ type: "spring", stiffness: 600, damping: 22 }}
>
- {canStop ? (
+ {turnControlPending ? (
+
+ ) : canPauseTurn ? (
+
+ ) : canResumeTurn ? (
+
+ ) : canStop ? (
) : busy || videoTaskRunning ? (
diff --git a/frontend/src/ui/EnvironmentCenter.tsx b/frontend/src/ui/EnvironmentCenter.tsx
index 7ef926375..ec48b5de9 100644
--- a/frontend/src/ui/EnvironmentCenter.tsx
+++ b/frontend/src/ui/EnvironmentCenter.tsx
@@ -172,6 +172,14 @@ function environmentRepositoryModeOptions(t: TFunction): Option[] {
const MAX_ENVIRONMENT_SHARE_CODES = 20;
const promptedClipboardShareTexts = new Set();
+function isStorageUnavailable(cause: unknown): boolean {
+ const message = cause instanceof Error ? cause.message : String(cause ?? "");
+ return message.includes("HTTP 503") && (
+ message.includes("未配置持久化存储") ||
+ message.includes("storage is not configured")
+ );
+}
+
async function clipboardReadPermissionDenied(): Promise {
if (typeof navigator === "undefined" || !navigator.permissions?.query) return false;
try {
@@ -2118,7 +2126,13 @@ export function EnvironmentCenter({
})
.catch((cause) => {
if ((cause as Error)?.name !== "AbortError") {
- setLoadError(cause instanceof Error ? cause.message : String(cause));
+ if (isStorageUnavailable(cause)) {
+ setEnvironments([]);
+ setStatusError(false);
+ setStatusMessage(t("environmentCenter.storageUnavailable"));
+ } else {
+ setLoadError(cause instanceof Error ? cause.message : String(cause));
+ }
}
})
.finally(() => {
diff --git a/frontend/src/ui/ManageAgents.tsx b/frontend/src/ui/ManageAgents.tsx
index 7490404f2..f595bc051 100644
--- a/frontend/src/ui/ManageAgents.tsx
+++ b/frontend/src/ui/ManageAgents.tsx
@@ -11,6 +11,7 @@ import {
} from "lucide-react";
import {
deleteRuntime,
+ copyRuntimeEnvironmentSecret,
getMyRuntimes,
getRuntimeDetail,
type AgentNode,
@@ -337,10 +338,12 @@ export function ManageAgentsView({
/** Env keys whose values are commonly credentials. */
const SENSITIVE_ENV_RE = /KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL/i;
-function EnvValue({ envKey, value }: { envKey: string; value: string }) {
+function EnvValue({ runtimeId, region, envKey, value, sensitive, configured }: { runtimeId: string; region: string; envKey: string; value: string; sensitive?: boolean; configured?: boolean }) {
const { t } = useTranslation("workspaceTools");
- const [revealed, setRevealed] = useState(false);
- if (!SENSITIVE_ENV_RE.test(envKey) || revealed) {
+ const [copied, setCopied] = useState(false);
+ const [copyFailed, setCopyFailed] = useState(false);
+ const isSensitive = sensitive ?? SENSITIVE_ENV_RE.test(envKey);
+ if (!isSensitive) {
return {value};
}
return (
@@ -348,10 +351,11 @@ function EnvValue({ envKey, value }: { envKey: string; value: string }) {
type="button"
className="manage-env-v manage-env-masked"
title={t("manageAgents.secretHidden")}
- aria-label={t("manageAgents.revealSecret", { key: envKey })}
- onClick={() => setRevealed(true)}
+ aria-label={t("manageAgents.copySecret", { key: envKey })}
+ disabled={!configured}
+ onClick={() => void copyRuntimeEnvironmentSecret(runtimeId, region, envKey).then(() => { setCopyFailed(false); setCopied(true); window.setTimeout(() => setCopied(false), 1500); }).catch(() => setCopyFailed(true))}
>
- ••••••••
+ {copyFailed ? t("manageAgents.copyFailed") : copied ? t("manageAgents.copied") : configured ? t("manageAgents.copy") : t("manageAgents.notConfigured")}
);
}
@@ -411,7 +415,7 @@ function RuntimeDetailCard({ detail }: { detail: RuntimeDetail }) {
{detail.envs.map((e) => (
{e.key}
-
+
))}
diff --git a/frontend/src/ui/Markdown.tsx b/frontend/src/ui/Markdown.tsx
index 5f1fd0ec8..6b3eca2f2 100644
--- a/frontend/src/ui/Markdown.tsx
+++ b/frontend/src/ui/Markdown.tsx
@@ -1,4 +1,5 @@
-import { Children, isValidElement, memo, useState, type ReactNode } from "react";
+import { SandboxFileContext, SandboxFileLink, sandboxFilePath } from "./SandboxFileLink";
+import { Children, isValidElement, memo, useContext, useState, type ReactNode } from "react";
import { Maximize2, X, Download } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { PhotoView } from "react-photo-view";
@@ -84,6 +85,7 @@ function MarkdownImpl({
streaming?: boolean;
}) {
const { t } = useTranslation("conversation");
+ const sandboxContext = useContext(SandboxFileContext);
const [videoViewerOpen, setVideoViewerOpen] = useState(null);
// Extract video src from props or source children
@@ -134,6 +136,7 @@ function MarkdownImpl({
return (
{
const href = props.href;
+ const sandboxPath = sandboxContext && sandboxFilePath(href);
+ if (sandboxPath) {
+ return {props.children};
+ }
if (href && (isVideoUrl(href) || isVideoLink(node))) {
const videoSrc = href;
const linkText = getLinkText(node?.children);
diff --git a/frontend/src/ui/MyAgents.tsx b/frontend/src/ui/MyAgents.tsx
index b85dd3725..d5977ca3d 100644
--- a/frontend/src/ui/MyAgents.tsx
+++ b/frontend/src/ui/MyAgents.tsx
@@ -86,6 +86,7 @@ export interface MyAgentCardData {
export type AgentType =
| "general"
+ | "mpa"
| "codex"
| "deepseek-harness"
| "openclaw"
@@ -93,11 +94,17 @@ export type AgentType =
const AGENT_TYPES: AgentType[] = [
"general",
+ "mpa",
"codex",
"deepseek-harness",
"openclaw",
"hermes",
];
+type RuntimeAgentType = Extract;
+type SandboxMyAgentType = Exclude;
+function isSandboxMyAgentType(type: AgentType): type is SandboxMyAgentType {
+ return type !== "general" && type !== "mpa";
+}
const RUNTIME_PAGE_SIZE = 24;
const RUNTIME_PAGE_CACHE_TTL_MS = 30_000;
const RUNTIME_COMPATIBILITY_TIMEOUT_MS = 7_000;
@@ -196,7 +203,7 @@ function HandoffIcon(props: SVGProps) {
}
function AgentTypeIcon({ type }: { type: AgentType }) {
- if (type === "general") return ;
+ if (type === "general" || type === "mpa") return ;
return ;
}
@@ -305,6 +312,7 @@ function resolveAgentRegion(
}
async function loadRuntimeAgents(
+ agentCategory: RuntimeAgentType,
runtimeScope: RuntimeScope,
region: string,
nextToken: string,
@@ -312,7 +320,7 @@ async function loadRuntimeAgents(
t: TFunction<"ui">,
signal?: AbortSignal,
): Promise {
- const requestKey = `${runtimeScope}:${region}:${nextToken}`;
+ const requestKey = `${agentCategory}:${runtimeScope}:${region}:${nextToken}`;
const cached = runtimePageCache.get(requestKey);
if (cached && cached.expiresAt > Date.now()) {
onList(cached.page.runtimes.map((runtime) => runtimeToAgent(runtime, t)));
@@ -322,6 +330,7 @@ async function loadRuntimeAgents(
let request = runtimePageRequests.get(requestKey);
if (!request) {
request = getRuntimesWithTimeoutRetry({
+ agentCategory,
scope: runtimeScope,
region,
pageSize: RUNTIME_PAGE_SIZE,
@@ -762,7 +771,8 @@ export function MyAgents({
const requestId = ++runtimeRequestRef.current;
setLoadingRuntimes(true);
setRuntimeError("");
- return loadRuntimeAgents(ownership, region, token, (agents) => {
+ const runtimeAgentType: RuntimeAgentType = activeType === "mpa" ? "mpa" : "general";
+ return loadRuntimeAgents(runtimeAgentType, ownership, region, token, (agents) => {
if (runtimeRequestRef.current !== requestId) return;
setRuntimeAgents((current) => reset ? agents : [...current, ...agents]);
}, t, controller.signal)
@@ -780,10 +790,10 @@ export function MyAgents({
runtimeListAbortRef.current = null;
}
});
- }, [ownership, region, t]);
+ }, [activeType, ownership, region, t]);
useEffect(() => {
- if (activeType !== "general") return;
+ if (activeType !== "general" && activeType !== "mpa") return;
setRuntimeAgents([]);
setRuntimeNextToken("");
void fetchRuntimePage("", true);
@@ -796,7 +806,7 @@ export function MyAgents({
}, [activeType, fetchRuntimePage]);
useEffect(() => {
- if (activeType !== "general") {
+ if (activeType !== "general" && activeType !== "mpa") {
for (const controller of runtimeCompatibilityAbortRef.current.values()) {
controller.abort();
}
@@ -900,7 +910,7 @@ export function MyAgents({
runtimeCompatibilityAbortRef.current.clear();
}, []);
- const fetchSandboxAgents = useCallback(async (type: Exclude) => {
+ const fetchSandboxAgents = useCallback(async (type: SandboxMyAgentType) => {
sandboxAbortRef.current?.abort();
const controller = new AbortController();
sandboxAbortRef.current = controller;
@@ -938,7 +948,7 @@ export function MyAgents({
function selectAgentType(type: AgentType) {
if (type === activeType) return;
- if (type === "general") {
+ if (type === "general" || type === "mpa") {
runtimeRequestRef.current += 1;
setRuntimeAgents([]);
setRuntimeNextToken("");
@@ -956,7 +966,7 @@ export function MyAgents({
}
function resetRuntimePagination() {
- if (activeType !== "general") return;
+ if (activeType !== "general" && activeType !== "mpa") return;
runtimeRequestRef.current += 1;
setRuntimeAgents([]);
setRuntimeNextToken("");
@@ -977,7 +987,7 @@ export function MyAgents({
}
useEffect(() => {
- if (activeType === "general") {
+ if (activeType === "general" || activeType === "mpa") {
sandboxAbortRef.current?.abort();
sandboxAbortRef.current = null;
sandboxRequestRef.current += 1;
@@ -994,7 +1004,7 @@ export function MyAgents({
useEffect(() => {
const target = loadMoreRef.current;
const root = resultsRef.current;
- if (!target || !root || activeType !== "general" || !runtimeNextToken || loadingRuntimes) {
+ if (!target || !root || (activeType !== "general" && activeType !== "mpa") || !runtimeNextToken || loadingRuntimes) {
return;
}
const observer = new IntersectionObserver(
@@ -1081,7 +1091,7 @@ export function MyAgents({
const visibleAgents = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
- const source = activeType === "general"
+ const source = activeType === "general" || activeType === "mpa"
? [...draftAgents, ...runtimeAgents]
: sandboxAgents;
const matchingOwnership = ownership === "mine"
@@ -1096,7 +1106,7 @@ export function MyAgents({
agent.name.toLocaleLowerCase().includes(normalizedQuery),
)
: matchingRegion;
- if (activeType !== "general") return matchingAgents;
+ if (activeType !== "general" && activeType !== "mpa") return matchingAgents;
const availableAgents = hiddenRuntimeIds.size > 0
? matchingAgents.filter((agent) =>
!agent.runtime || !hiddenRuntimeIds.has(agent.runtime.runtimeId),
@@ -1124,7 +1134,7 @@ export function MyAgents({
]);
useEffect(() => {
- if (!canUpdate || activeType !== "general") return;
+ if (!canUpdate || (activeType !== "general" && activeType !== "mpa")) return;
const targets = visibleAgents
.filter((agent) => Boolean(agent.runtime))
.filter((agent) => !deploymentTaskForAgent(agent))
@@ -1161,18 +1171,16 @@ export function MyAgents({
const activeLabel = t(`myAgents.agentTypes.${activeType}`, {
defaultValue: t("myAgents.agent"),
});
- const showInitialLoading = activeType === "general"
+ const showInitialLoading = activeType === "general" || activeType === "mpa"
? loadingRuntimes && runtimeAgents.length === 0 && draftAgents.length === 0
: loadingSandboxAgents && sandboxAgents.length === 0;
const showEmpty = !showInitialLoading && visibleAgents.length === 0;
- const canCreateActiveAgent = activeType === "general"
- ? canCreateRuntimeAgents
- : canCreatePersonalAgents;
- const createAgent = canCreateActiveAgent
- ? activeType === "general"
- ? () => onCreateAgent(region)
- : () => onCreateSandboxAgent(activeType)
- : undefined;
+ let createAgent: (() => void) | undefined;
+ if (activeType === "general" && canCreateRuntimeAgents) {
+ createAgent = () => onCreateAgent(region);
+ } else if (isSandboxMyAgentType(activeType) && canCreatePersonalAgents) {
+ createAgent = () => onCreateSandboxAgent(activeType);
+ }
const showCodexProjectUpload =
activeType === "codex" &&
canCreatePersonalAgents &&
@@ -1235,13 +1243,13 @@ export function MyAgents({
>
{showInitialLoading ? (
- ) : (activeType === "general" ? runtimeError : sandboxError) && visibleAgents.length === 0 ? (
+ ) : ((activeType === "general" || activeType === "mpa") ? runtimeError : sandboxError) && visibleAgents.length === 0 ? (
-
{activeType === "general" ? runtimeError : sandboxError}
+
{(activeType === "general" || activeType === "mpa") ? runtimeError : sandboxError}
);
diff --git a/frontend/src/ui/WorkspaceCenter.tsx b/frontend/src/ui/WorkspaceCenter.tsx
index e91c71b49..524535b13 100644
--- a/frontend/src/ui/WorkspaceCenter.tsx
+++ b/frontend/src/ui/WorkspaceCenter.tsx
@@ -50,6 +50,14 @@ type WorkspaceView =
| { kind: "list" }
| { kind: "detail"; workspaceId: string | null };
+function isStorageUnavailable(cause: unknown): boolean {
+ const message = cause instanceof Error ? cause.message : String(cause ?? "");
+ return message.includes("HTTP 503") && (
+ message.includes("未配置持久化存储") ||
+ message.includes("storage is not configured")
+ );
+}
+
function AddIcon(props: SVGProps) {
return (