mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Compare commits
8 commits
188760151d
...
a97927d216
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a97927d216 | ||
|
|
b6f0c562b3 | ||
|
|
333a62cda6 | ||
|
|
def2e9fd2e | ||
|
|
729ce2b0cb | ||
|
|
e8c179d3a6 | ||
|
|
0b493a66c0 | ||
|
|
8448b1105c |
37 changed files with 2817 additions and 492 deletions
16
apps/daemon/bin/od.mjs
Executable file
16
apps/daemon/bin/od.mjs
Executable file
|
|
@ -0,0 +1,16 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const entryDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const distEntry = resolve(entryDir, "../dist/cli.js");
|
||||||
|
|
||||||
|
if (!existsSync(distEntry)) {
|
||||||
|
throw new Error(
|
||||||
|
`Open Design daemon dist entry not found at ${distEntry}. Run "pnpm --filter @open-design/daemon build" first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await import(pathToFileURL(distEntry).href);
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"main": "./dist/cli.js",
|
"main": "./dist/cli.js",
|
||||||
"types": "./dist/cli.d.ts",
|
"types": "./dist/cli.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
"od": "./dist/cli.js"
|
"od": "./bin/od.mjs"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
"bin",
|
||||||
"dist",
|
"dist",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import { isSafeId as isSafeProjectId } from './projects.js';
|
||||||
import { projectKindToTracking } from '@open-design/contracts/analytics';
|
import { projectKindToTracking } from '@open-design/contracts/analytics';
|
||||||
import { proxyDispatcherRequestInit, validateBaseUrlResolved } from './connectionTest.js';
|
import { proxyDispatcherRequestInit, validateBaseUrlResolved } from './connectionTest.js';
|
||||||
import { googleStreamGenerateContentUrl } from './google-models.js';
|
import { googleStreamGenerateContentUrl } from './google-models.js';
|
||||||
import { parseMediaExecutionPolicyInput } from './media-policy.js';
|
|
||||||
import { createRoleMarkerGuard } from './role-marker-guard.js';
|
import { createRoleMarkerGuard } from './role-marker-guard.js';
|
||||||
|
|
||||||
// Allowlist for the `/feedback` route. Mirrors the
|
// Allowlist for the `/feedback` route. Mirrors the
|
||||||
|
|
@ -45,7 +44,7 @@ export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'htt
|
||||||
export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
||||||
const { db, design } = ctx;
|
const { db, design } = ctx;
|
||||||
const { sendApiError, createSseResponse } = ctx.http;
|
const { sendApiError, createSseResponse } = ctx.http;
|
||||||
const { startChatRun, submitToolResultToRun } = ctx.chat;
|
const { submitToolResultToRun } = ctx.chat;
|
||||||
const { testProviderConnection, testAgentConnection, getAgentDef, isKnownModel, sanitizeCustomModel, listProviderModels } = ctx.agents;
|
const { testProviderConnection, testAgentConnection, getAgentDef, isKnownModel, sanitizeCustomModel, listProviderModels } = ctx.agents;
|
||||||
const {
|
const {
|
||||||
handleCritiqueArtifact,
|
handleCritiqueArtifact,
|
||||||
|
|
@ -54,7 +53,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
||||||
critiqueResponseCapBytes,
|
critiqueResponseCapBytes,
|
||||||
critiqueRunRegistry,
|
critiqueRunRegistry,
|
||||||
} = ctx.critique;
|
} = ctx.critique;
|
||||||
const isDaemonShuttingDown = ctx.lifecycle?.isDaemonShuttingDown ?? (() => false);
|
|
||||||
const rejectProxyPluginContext = (body: Record<string, unknown>, res: any) => {
|
const rejectProxyPluginContext = (body: Record<string, unknown>, res: any) => {
|
||||||
if (
|
if (
|
||||||
(typeof body.pluginId === 'string' && body.pluginId.trim().length > 0) ||
|
(typeof body.pluginId === 'string' && body.pluginId.trim().length > 0) ||
|
||||||
|
|
@ -79,6 +77,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
||||||
// so any handler we wired here was shadowed and never executed. Plugin
|
// so any handler we wired here was shadowed and never executed. Plugin
|
||||||
// snapshot resolution, clientType inference, and the daemon-side
|
// snapshot resolution, clientType inference, and the daemon-side
|
||||||
// run_created/finished analytics all live in `server.ts` now.
|
// run_created/finished analytics all live in `server.ts` now.
|
||||||
|
// POST /api/chat is likewise owned by `server.ts`; keep the chat run
|
||||||
|
// launch path single-sourced so validation changes land on the live route.
|
||||||
|
|
||||||
app.get('/api/runs', (req, res) => {
|
app.get('/api/runs', (req, res) => {
|
||||||
const { projectId, conversationId, status } = req.query;
|
const { projectId, conversationId, status } = req.query;
|
||||||
|
|
@ -218,23 +218,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
||||||
res.status(202).json(outcome);
|
res.status(202).json(outcome);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/chat', (req, res) => {
|
|
||||||
if (isDaemonShuttingDown()) {
|
|
||||||
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
|
|
||||||
}
|
|
||||||
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
||||||
const mediaExecution = parseMediaExecutionPolicyInput(
|
|
||||||
(body as { mediaExecution?: unknown }).mediaExecution,
|
|
||||||
);
|
|
||||||
if (!mediaExecution.ok) {
|
|
||||||
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
|
|
||||||
}
|
|
||||||
const runBody = { ...body, mediaExecution: mediaExecution.policy };
|
|
||||||
const run = design.runs.create(runBody);
|
|
||||||
design.runs.stream(run, req, res);
|
|
||||||
design.runs.start(run, () => startChatRun(runBody, run));
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- Connection tests (single-shot JSON; no SSE) ------------------------
|
// ---- Connection tests (single-shot JSON; no SSE) ------------------------
|
||||||
// Settings dialog uses these to verify a config works without sending a
|
// Settings dialog uses these to verify a config works without sending a
|
||||||
// real chat. Always return HTTP 200 with `ok: false` on upstream-caused
|
// real chat. Always return HTTP 200 with `ok: false` on upstream-caused
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
import { redactSecrets } from './redact.js';
|
import { redactSecrets } from './redact.js';
|
||||||
|
|
||||||
export interface ClaudeCliDiagnosticInput {
|
export interface ClaudeCliDiagnosticInput {
|
||||||
|
|
@ -7,6 +9,7 @@ export interface ClaudeCliDiagnosticInput {
|
||||||
stderrTail?: string | null;
|
stderrTail?: string | null;
|
||||||
stdoutTail?: string | null;
|
stdoutTail?: string | null;
|
||||||
env?: Record<string, unknown> | null;
|
env?: Record<string, unknown> | null;
|
||||||
|
resolvedBin?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClaudeCliDiagnostic {
|
export interface ClaudeCliDiagnostic {
|
||||||
|
|
@ -51,6 +54,15 @@ function withContext(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectedClaudeCompatibleRuntime(input: ClaudeCliDiagnosticInput): 'claude' | 'openclaude' {
|
||||||
|
if (typeof input.resolvedBin !== 'string' || !input.resolvedBin.trim()) return 'claude';
|
||||||
|
const base = path
|
||||||
|
.basename(input.resolvedBin.trim().replace(/\\/g, '/'))
|
||||||
|
.replace(/\.(exe|cmd|bat)$/i, '')
|
||||||
|
.toLowerCase();
|
||||||
|
return base === 'openclaude' ? 'openclaude' : 'claude';
|
||||||
|
}
|
||||||
|
|
||||||
export function diagnoseClaudeCliFailure(
|
export function diagnoseClaudeCliFailure(
|
||||||
input: ClaudeCliDiagnosticInput,
|
input: ClaudeCliDiagnosticInput,
|
||||||
): ClaudeCliDiagnostic | null {
|
): ClaudeCliDiagnostic | null {
|
||||||
|
|
@ -61,6 +73,8 @@ export function diagnoseClaudeCliFailure(
|
||||||
const normalized = text.toLowerCase();
|
const normalized = text.toLowerCase();
|
||||||
const hasCustomBaseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL') !== null;
|
const hasCustomBaseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL') !== null;
|
||||||
const hasConfigDir = envValue(input.env, 'CLAUDE_CONFIG_DIR') !== null;
|
const hasConfigDir = envValue(input.env, 'CLAUDE_CONFIG_DIR') !== null;
|
||||||
|
const runtime = selectedClaudeCompatibleRuntime(input);
|
||||||
|
const isOpenClaude = runtime === 'openclaude';
|
||||||
|
|
||||||
const customEndpointConnectionFailure =
|
const customEndpointConnectionFailure =
|
||||||
hasCustomBaseUrl &&
|
hasCustomBaseUrl &&
|
||||||
|
|
@ -90,6 +104,13 @@ export function diagnoseClaudeCliFailure(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (authFailure) {
|
if (authFailure) {
|
||||||
|
if (isOpenClaude) {
|
||||||
|
return withContext(
|
||||||
|
'OpenClaude could not authenticate with its configured endpoint.',
|
||||||
|
'The spawned OpenClaude process exited before producing a response. Check the OpenClaude API key, endpoint, and local configuration, then retry.',
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
}
|
||||||
const configHint = hasConfigDir
|
const configHint = hasConfigDir
|
||||||
? 'The configured Claude config directory may contain stale or expired auth state.'
|
? 'The configured Claude config directory may contain stale or expired auth state.'
|
||||||
: 'If you use multiple Claude profiles, set CLAUDE_CONFIG_DIR in Settings so Open Design spawns the same profile that works in your terminal.';
|
: 'If you use multiple Claude profiles, set CLAUDE_CONFIG_DIR in Settings so Open Design spawns the same profile that works in your terminal.';
|
||||||
|
|
@ -147,6 +168,13 @@ export function diagnoseClaudeCliFailure(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!text.trim() && input.exitCode === 1) {
|
if (!text.trim() && input.exitCode === 1) {
|
||||||
|
if (isOpenClaude) {
|
||||||
|
return withContext(
|
||||||
|
'OpenClaude exited before producing diagnostics.',
|
||||||
|
'Check the OpenClaude API key, endpoint, and local configuration, then retry.',
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
}
|
||||||
const message = hasConfigDir
|
const message = hasConfigDir
|
||||||
? 'Claude Code exited before producing diagnostics while using the configured Claude profile.'
|
? 'Claude Code exited before producing diagnostics while using the configured Claude profile.'
|
||||||
: 'Claude Code exited before producing diagnostics.';
|
: 'Claude Code exited before producing diagnostics.';
|
||||||
|
|
|
||||||
|
|
@ -1869,6 +1869,8 @@ async function testAgentConnectionInternal(
|
||||||
...(def.env || {}),
|
...(def.env || {}),
|
||||||
},
|
},
|
||||||
configuredAgentEnv,
|
configuredAgentEnv,
|
||||||
|
undefined,
|
||||||
|
{ resolvedBin: executableResolution.selectedPath },
|
||||||
);
|
);
|
||||||
const env = applyAgentLaunchEnv(baseEnv, executableResolution);
|
const env = applyAgentLaunchEnv(baseEnv, executableResolution);
|
||||||
const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env);
|
const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env);
|
||||||
|
|
@ -2033,6 +2035,7 @@ async function testAgentConnectionInternal(
|
||||||
stderrTail,
|
stderrTail,
|
||||||
stdoutTail: rawStdoutTail || buffered,
|
stdoutTail: rawStdoutTail || buffered,
|
||||||
env,
|
env,
|
||||||
|
resolvedBin: executableResolution.selectedPath,
|
||||||
});
|
});
|
||||||
if (claudeDiagnostic) {
|
if (claudeDiagnostic) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|
|
||||||
|
|
@ -752,12 +752,23 @@ export function listConversations(db: SqliteDb, projectId: string) {
|
||||||
AND m.run_status IS NOT NULL
|
AND m.run_status IS NOT NULL
|
||||||
)
|
)
|
||||||
WHERE rn = 1
|
WHERE rn = 1
|
||||||
|
),
|
||||||
|
total_run_durations AS (
|
||||||
|
SELECT m.conversation_id AS conversationId,
|
||||||
|
SUM(${terminalRunDurationSql('m')}) AS totalDurationMs
|
||||||
|
FROM messages m
|
||||||
|
JOIN project_conversations c ON c.id = m.conversation_id
|
||||||
|
WHERE m.role = 'assistant'
|
||||||
|
AND m.run_status IN ('succeeded', 'failed', 'canceled')
|
||||||
|
GROUP BY m.conversation_id
|
||||||
)
|
)
|
||||||
SELECT c.id, c.projectId, c.title, c.createdAt, c.updatedAt,
|
SELECT c.id, c.projectId, c.title, c.createdAt, c.updatedAt,
|
||||||
lr.latestRunStatus, lr.latestRunStartedAt,
|
lr.latestRunStatus, lr.latestRunStartedAt,
|
||||||
lr.latestRunEndedAt, lr.latestRunEventsJson
|
lr.latestRunEndedAt, lr.latestRunEventsJson,
|
||||||
|
trd.totalDurationMs
|
||||||
FROM project_conversations c
|
FROM project_conversations c
|
||||||
LEFT JOIN latest_runs lr ON lr.conversationId = c.id
|
LEFT JOIN latest_runs lr ON lr.conversationId = c.id
|
||||||
|
LEFT JOIN total_run_durations trd ON trd.conversationId = c.id
|
||||||
ORDER BY c.updatedAt DESC`,
|
ORDER BY c.updatedAt DESC`,
|
||||||
)
|
)
|
||||||
.all(projectId)).map(normalizeConversation);
|
.all(projectId)).map(normalizeConversation);
|
||||||
|
|
@ -775,6 +786,7 @@ export function getConversation(db: SqliteDb, id: string) {
|
||||||
return {
|
return {
|
||||||
...normalizeConversation(r),
|
...normalizeConversation(r),
|
||||||
latestRun: latestConversationRunSummary(db, r.id) ?? undefined,
|
latestRun: latestConversationRunSummary(db, r.id) ?? undefined,
|
||||||
|
...numberProperty('totalDurationMs', totalConversationRunDurationMs(db, r.id)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -791,10 +803,16 @@ function normalizeConversation(r: DbRow) {
|
||||||
title: r.title ?? null,
|
title: r.title ?? null,
|
||||||
createdAt: Number(r.createdAt),
|
createdAt: Number(r.createdAt),
|
||||||
updatedAt: Number(r.updatedAt),
|
updatedAt: Number(r.updatedAt),
|
||||||
|
...numberProperty('totalDurationMs', r.totalDurationMs),
|
||||||
latestRun: latestRun ?? undefined,
|
latestRun: latestRun ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function numberProperty(key: string, value: unknown) {
|
||||||
|
const n = value == null ? undefined : Number(value);
|
||||||
|
return typeof n === 'number' && Number.isFinite(n) ? { [key]: n } : {};
|
||||||
|
}
|
||||||
|
|
||||||
function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
|
function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|
@ -813,6 +831,50 @@ function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
|
||||||
return conversationRunSummaryFromRow(row);
|
return conversationRunSummaryFromRow(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function totalConversationRunDurationMs(db: SqliteDb, conversationId: string): number | undefined {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT SUM(${terminalRunDurationSql()}) AS totalDurationMs
|
||||||
|
FROM messages
|
||||||
|
WHERE conversation_id = ?
|
||||||
|
AND role = 'assistant'
|
||||||
|
AND run_status IN ('succeeded', 'failed', 'canceled')`,
|
||||||
|
)
|
||||||
|
.get(conversationId) as DbRow | undefined;
|
||||||
|
return row?.totalDurationMs == null ? undefined : Number(row.totalDurationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalRunDurationSql(alias?: string) {
|
||||||
|
const p = alias ? `${alias}.` : '';
|
||||||
|
return `CASE
|
||||||
|
WHEN ${p}started_at IS NOT NULL AND ${p}ended_at IS NOT NULL THEN
|
||||||
|
CASE
|
||||||
|
WHEN CAST(${p}ended_at AS INTEGER) >= CAST(${p}started_at AS INTEGER)
|
||||||
|
THEN CAST(${p}ended_at AS INTEGER) - CAST(${p}started_at AS INTEGER)
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
ELSE (
|
||||||
|
SELECT CASE
|
||||||
|
WHEN json_extract(usage_event.value, '$.durationMs') >= 0
|
||||||
|
THEN json_extract(usage_event.value, '$.durationMs')
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
FROM json_each(
|
||||||
|
CASE
|
||||||
|
WHEN json_valid(${p}events_json) AND json_type(${p}events_json) = 'array'
|
||||||
|
THEN ${p}events_json
|
||||||
|
ELSE '[]'
|
||||||
|
END
|
||||||
|
) AS usage_event
|
||||||
|
WHERE usage_event.type = 'object'
|
||||||
|
AND json_extract(usage_event.value, '$.kind') = 'usage'
|
||||||
|
AND json_type(usage_event.value, '$.durationMs') IN ('integer', 'real')
|
||||||
|
ORDER BY CAST(usage_event.key AS INTEGER) DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
END`;
|
||||||
|
}
|
||||||
|
|
||||||
function conversationRunSummaryFromRow(row: DbRow | undefined) {
|
function conversationRunSummaryFromRow(row: DbRow | undefined) {
|
||||||
if (!row || typeof row.runStatus !== 'string') return null;
|
if (!row || typeof row.runStatus !== 'string') return null;
|
||||||
const startedAt = row.startedAt == null ? undefined : Number(row.startedAt);
|
const startedAt = row.startedAt == null ? undefined : Number(row.startedAt);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
|
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { access, constants as fsConstants } from 'node:fs/promises';
|
import { access, constants as fsConstants } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
import type { Express } from 'express';
|
import type { Express } from 'express';
|
||||||
import type {
|
import type {
|
||||||
HostEditor,
|
HostEditor,
|
||||||
|
|
@ -159,6 +160,28 @@ function applicableForPlatform(entry: CatalogueEntry, platform: Platform): boole
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectHostOpenDir(
|
||||||
|
projectsRoot: string,
|
||||||
|
project: { id: string; metadata?: { baseDir?: unknown } | null },
|
||||||
|
resolveProjectDir: (
|
||||||
|
projectsRoot: string,
|
||||||
|
projectId: string,
|
||||||
|
metadata?: unknown,
|
||||||
|
opts?: { allowUnavailableSandboxImportedProject?: boolean },
|
||||||
|
) => string,
|
||||||
|
): string {
|
||||||
|
const importedBaseDir =
|
||||||
|
typeof project.metadata?.baseDir === 'string'
|
||||||
|
? path.normalize(project.metadata.baseDir)
|
||||||
|
: '';
|
||||||
|
if (importedBaseDir && path.isAbsolute(importedBaseDir)) {
|
||||||
|
return importedBaseDir;
|
||||||
|
}
|
||||||
|
return resolveProjectDir(projectsRoot, project.id, project.metadata, {
|
||||||
|
allowUnavailableSandboxImportedProject: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRoutesDeps) {
|
export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRoutesDeps) {
|
||||||
const { db } = ctx;
|
const { db } = ctx;
|
||||||
const { sendApiError } = ctx.http;
|
const { sendApiError } = ctx.http;
|
||||||
|
|
@ -209,7 +232,11 @@ export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRout
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
|
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
|
||||||
}
|
}
|
||||||
const resolvedDir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
|
const resolvedDir = projectHostOpenDir(
|
||||||
|
PROJECTS_DIR,
|
||||||
|
project,
|
||||||
|
resolveProjectDir,
|
||||||
|
);
|
||||||
const probe = await resolveEntry(entry);
|
const probe = await resolveEntry(entry);
|
||||||
if (!probe.available || !probe.launch) {
|
if (!probe.available || !probe.launch) {
|
||||||
return sendApiError(res, 409, 'EDITOR_NOT_AVAILABLE', `${entry.label} is not installed`);
|
return sendApiError(res, 409, 'EDITOR_NOT_AVAILABLE', `${entry.label} is not installed`);
|
||||||
|
|
|
||||||
|
|
@ -872,7 +872,13 @@ async function callLocalCli(provider, system, user, options) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = applyAgentLaunchEnv(
|
const env = applyAgentLaunchEnv(
|
||||||
spawnEnvForAgent(def.id, { ...process.env, ...(def.env || {}) }, configuredAgentEnv),
|
spawnEnvForAgent(
|
||||||
|
def.id,
|
||||||
|
{ ...process.env, ...(def.env || {}) },
|
||||||
|
configuredAgentEnv,
|
||||||
|
undefined,
|
||||||
|
{ resolvedBin: launch.selectedPath },
|
||||||
|
),
|
||||||
launch,
|
launch,
|
||||||
);
|
);
|
||||||
const invocation = createCommandInvocation({
|
const invocation = createCommandInvocation({
|
||||||
|
|
|
||||||
185
apps/daemon/src/run-tool-bundle.ts
Normal file
185
apps/daemon/src/run-tool-bundle.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
import type { McpAuthMode, McpServerConfig, McpTransport } from './mcp-config.js';
|
||||||
|
import type { RuntimeAgentDef } from './runtimes/types.js';
|
||||||
|
import { sanitizeMcpConfig, sanitizeMcpServer } from './mcp-config.js';
|
||||||
|
|
||||||
|
export interface RunToolBundle {
|
||||||
|
mcpServers: McpServerConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunToolBundleSummary {
|
||||||
|
mcpServers: Array<{
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
templateId?: string;
|
||||||
|
transport: McpTransport;
|
||||||
|
enabled: boolean;
|
||||||
|
authMode?: McpAuthMode;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalMcpSelection {
|
||||||
|
enabledServers: McpServerConfig[];
|
||||||
|
persistedTokenServerIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RunToolBundleParseResult =
|
||||||
|
| { ok: true; bundle: RunToolBundle }
|
||||||
|
| { ok: false; message: string };
|
||||||
|
|
||||||
|
export type RunToolBundleValidationResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; message: string };
|
||||||
|
|
||||||
|
export type RunToolBundleDeliveryTarget =
|
||||||
|
| 'managed-project'
|
||||||
|
| 'external-project'
|
||||||
|
| 'none';
|
||||||
|
|
||||||
|
export interface RunToolBundleValidationOptions {
|
||||||
|
deliveryTarget?: RunToolBundleDeliveryTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunToolBundleAgent = Pick<
|
||||||
|
RuntimeAgentDef,
|
||||||
|
'id' | 'name' | 'externalMcpInjection'
|
||||||
|
>;
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentLabel(agent: RunToolBundleAgent): string {
|
||||||
|
return agent.name ? `${agent.name} (${agent.id})` : agent.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRunToolBundleForRun(raw: unknown): RunToolBundle {
|
||||||
|
if (!isPlainObject(raw)) return { mcpServers: [] };
|
||||||
|
return {
|
||||||
|
mcpServers: sanitizeMcpConfig({ servers: raw.mcpServers }).servers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRunToolBundleForRequest(raw: unknown): RunToolBundleParseResult {
|
||||||
|
if (raw == null) return { ok: true, bundle: { mcpServers: [] } };
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
return { ok: false, message: 'toolBundle must be an object' };
|
||||||
|
}
|
||||||
|
if (raw.mcpServers == null) return { ok: true, bundle: { mcpServers: [] } };
|
||||||
|
if (!Array.isArray(raw.mcpServers)) {
|
||||||
|
return { ok: false, message: 'toolBundle.mcpServers must be an array' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const servers: McpServerConfig[] = [];
|
||||||
|
for (const [index, entry] of raw.mcpServers.entries()) {
|
||||||
|
const server = sanitizeMcpServer(entry);
|
||||||
|
if (!server) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `toolBundle.mcpServers[${index}] is invalid`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (seen.has(server.id)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `toolBundle.mcpServers[${index}] duplicates server id "${server.id}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
seen.add(server.id);
|
||||||
|
servers.push(server);
|
||||||
|
}
|
||||||
|
return { ok: true, bundle: { mcpServers: servers } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeRunToolBundle(bundle: RunToolBundle | null | undefined): RunToolBundleSummary {
|
||||||
|
const servers = Array.isArray(bundle?.mcpServers) ? bundle.mcpServers : [];
|
||||||
|
return {
|
||||||
|
mcpServers: servers.map((server) => ({
|
||||||
|
id: server.id,
|
||||||
|
...(server.label ? { label: server.label } : {}),
|
||||||
|
...(server.templateId ? { templateId: server.templateId } : {}),
|
||||||
|
transport: server.transport,
|
||||||
|
enabled: server.enabled,
|
||||||
|
...(server.authMode ? { authMode: server.authMode } : {}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRunToolBundleForAgent(
|
||||||
|
bundle: RunToolBundle | null | undefined,
|
||||||
|
agent: RunToolBundleAgent | null | undefined,
|
||||||
|
options: RunToolBundleValidationOptions = {},
|
||||||
|
): RunToolBundleValidationResult {
|
||||||
|
const servers = Array.isArray(bundle?.mcpServers) ? bundle.mcpServers : [];
|
||||||
|
const enabledServers = servers.filter((server) => server.enabled);
|
||||||
|
if (enabledServers.length === 0) return { ok: true };
|
||||||
|
if (!agent) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: 'toolBundle requires a supported agentId',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.externalMcpInjection === 'claude-mcp-json') {
|
||||||
|
if (options.deliveryTarget && options.deliveryTarget !== 'managed-project') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message:
|
||||||
|
`${agentLabel(agent)} receives run-scoped MCP tool bundles through project .mcp.json, ` +
|
||||||
|
'so toolBundle requires a daemon-managed project',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.externalMcpInjection === 'opencode-env-content') {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.externalMcpInjection === 'acp-merge') {
|
||||||
|
const unsupported = servers.findIndex(
|
||||||
|
(server) => server.enabled && server.transport !== 'stdio',
|
||||||
|
);
|
||||||
|
if (unsupported === -1) return { ok: true };
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message:
|
||||||
|
`toolBundle.mcpServers[${unsupported}] uses ${servers[unsupported]?.transport} transport, ` +
|
||||||
|
`but ${agentLabel(agent)} only supports stdio run-scoped MCP servers`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `${agentLabel(agent)} does not support run-scoped MCP tool bundles`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveExternalMcpServersForRun({
|
||||||
|
persistedServers,
|
||||||
|
runScopedServers,
|
||||||
|
sandboxMode,
|
||||||
|
}: {
|
||||||
|
persistedServers: McpServerConfig[];
|
||||||
|
runScopedServers: McpServerConfig[];
|
||||||
|
sandboxMode: boolean;
|
||||||
|
}): ExternalMcpSelection {
|
||||||
|
const runScopedIds = new Set(runScopedServers.map((server) => server.id));
|
||||||
|
const persistedForRun = sandboxMode ? [] : persistedServers;
|
||||||
|
const byId = new Map<string, McpServerConfig>();
|
||||||
|
|
||||||
|
for (const server of persistedForRun) byId.set(server.id, server);
|
||||||
|
for (const server of runScopedServers) byId.set(server.id, server);
|
||||||
|
|
||||||
|
const persistedTokenServerIds = new Set<string>();
|
||||||
|
for (const server of persistedForRun) {
|
||||||
|
if (!server.enabled) continue;
|
||||||
|
if (runScopedIds.has(server.id)) continue;
|
||||||
|
persistedTokenServerIds.add(server.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabledServers: Array.from(byId.values()).filter((server) => server.enabled),
|
||||||
|
persistedTokenServerIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,10 @@ import { randomUUID } from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { normalizeMediaExecutionPolicyForRun } from './media-policy.js';
|
import { normalizeMediaExecutionPolicyForRun } from './media-policy.js';
|
||||||
|
import {
|
||||||
|
normalizeRunToolBundleForRun,
|
||||||
|
summarizeRunToolBundle,
|
||||||
|
} from './run-tool-bundle.js';
|
||||||
|
|
||||||
export const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']);
|
export const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']);
|
||||||
|
|
||||||
|
|
@ -57,6 +61,7 @@ export function createChatRunService({
|
||||||
pluginId:
|
pluginId:
|
||||||
typeof meta.pluginId === 'string' && meta.pluginId ? meta.pluginId : null,
|
typeof meta.pluginId === 'string' && meta.pluginId ? meta.pluginId : null,
|
||||||
mediaExecution: normalizeMediaExecutionPolicyForRun(meta.mediaExecution),
|
mediaExecution: normalizeMediaExecutionPolicyForRun(meta.mediaExecution),
|
||||||
|
toolBundle: normalizeRunToolBundleForRun(meta.toolBundle),
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|
@ -149,6 +154,7 @@ export function createChatRunService({
|
||||||
errorCode: run.errorCode ?? null,
|
errorCode: run.errorCode ?? null,
|
||||||
eventsLogPath: run.eventsLogPath ?? null,
|
eventsLogPath: run.eventsLogPath ?? null,
|
||||||
mediaExecution: run.mediaExecution ?? normalizeMediaExecutionPolicyForRun(null),
|
mediaExecution: run.mediaExecution ?? normalizeMediaExecutionPolicyForRun(null),
|
||||||
|
toolBundle: summarizeRunToolBundle(run.toolBundle),
|
||||||
});
|
});
|
||||||
|
|
||||||
const finish = (run, status, code: number | null = null, signal: string | null = null) => {
|
const finish = (run, status, code: number | null = null, signal: string | null = null) => {
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,8 @@ async function probe(
|
||||||
...(def.env || {}),
|
...(def.env || {}),
|
||||||
},
|
},
|
||||||
configuredEnv,
|
configuredEnv,
|
||||||
|
undefined,
|
||||||
|
{ resolvedBin: launch.selectedPath },
|
||||||
),
|
),
|
||||||
launch,
|
launch,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import {
|
||||||
} from '../sandbox-mode.js';
|
} from '../sandbox-mode.js';
|
||||||
|
|
||||||
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
|
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
|
||||||
|
type SpawnEnvOptions = {
|
||||||
|
resolvedBin?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule(
|
const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule(
|
||||||
path.dirname(fileURLToPath(import.meta.url)),
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
|
@ -51,6 +54,7 @@ export function spawnEnvForAgent(
|
||||||
baseEnv: RuntimeEnvMap,
|
baseEnv: RuntimeEnvMap,
|
||||||
configuredEnv: unknown = {},
|
configuredEnv: unknown = {},
|
||||||
systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(),
|
systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(),
|
||||||
|
options: SpawnEnvOptions = {},
|
||||||
): NodeJS.ProcessEnv {
|
): NodeJS.ProcessEnv {
|
||||||
const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv);
|
const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv);
|
||||||
const env = mergeProxyAwareEnv(
|
const env = mergeProxyAwareEnv(
|
||||||
|
|
@ -75,7 +79,9 @@ export function spawnEnvForAgent(
|
||||||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||||
}
|
}
|
||||||
if (agentId === 'claude') {
|
if (agentId === 'claude') {
|
||||||
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
|
if (!isOpenClaudeExecutable(options.resolvedBin)) {
|
||||||
|
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
|
||||||
|
}
|
||||||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||||
}
|
}
|
||||||
if (agentId === 'codex') {
|
if (agentId === 'codex') {
|
||||||
|
|
@ -88,6 +94,15 @@ export function spawnEnvForAgent(
|
||||||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOpenClaudeExecutable(resolvedBin: string | null | undefined): boolean {
|
||||||
|
if (typeof resolvedBin !== 'string' || !resolvedBin.trim()) return false;
|
||||||
|
const base = path
|
||||||
|
.basename(resolvedBin.trim().replace(/\\/g, '/'))
|
||||||
|
.replace(/\.(exe|cmd|bat)$/i, '')
|
||||||
|
.toLowerCase();
|
||||||
|
return base === 'openclaude';
|
||||||
|
}
|
||||||
|
|
||||||
function sandboxRuntimeConfigForBaseEnv(
|
function sandboxRuntimeConfigForBaseEnv(
|
||||||
baseEnv: RuntimeEnvMap,
|
baseEnv: RuntimeEnvMap,
|
||||||
): SandboxRuntimeConfig | null {
|
): SandboxRuntimeConfig | null {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { accessSync, constants, existsSync, statSync } from 'node:fs';
|
import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs';
|
||||||
import { delimiter } from 'node:path';
|
import { delimiter } from 'node:path';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
|
|
@ -286,10 +286,7 @@ export function inspectAgentExecutableResolution(
|
||||||
// falling back to first-match (#978).
|
// falling back to first-match (#978).
|
||||||
let pathResolvedPath: string | null = null;
|
let pathResolvedPath: string | null = null;
|
||||||
if (!configuredOverridePath && def.minVersion) {
|
if (!configuredOverridePath && def.minVersion) {
|
||||||
const cached = versionAwareCache.get(def.id);
|
pathResolvedPath = cachedVersionAwarePath(def.id);
|
||||||
if (cached && existsSync(cached)) {
|
|
||||||
pathResolvedPath = cached;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!pathResolvedPath) {
|
if (!pathResolvedPath) {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
|
|
@ -324,13 +321,28 @@ export function inspectAgentExecutableResolution(
|
||||||
|
|
||||||
const VERSION_PROBE_TIMEOUT_MS = 1_500;
|
const VERSION_PROBE_TIMEOUT_MS = 1_500;
|
||||||
|
|
||||||
// agent.id → resolved path that passed the version gate. Populated by
|
interface VersionAwareExecutableIdentity {
|
||||||
// `chooseExecutableByMinVersion`; consulted by
|
realpath: string;
|
||||||
|
dev: number;
|
||||||
|
ino: number;
|
||||||
|
mode: number;
|
||||||
|
size: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
ctimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionAwareCacheEntry {
|
||||||
|
path: string;
|
||||||
|
identity: VersionAwareExecutableIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// agent.id → resolved path + file identity that passed the version gate.
|
||||||
|
// Populated by `chooseExecutableByMinVersion`; consulted by
|
||||||
// `inspectAgentExecutableResolution` so the sync chat-spawn path sees
|
// `inspectAgentExecutableResolution` so the sync chat-spawn path sees
|
||||||
// the same pick detection landed on. Only writes for the auto-pick path
|
// the same pick detection landed on. Only writes for the auto-pick path
|
||||||
// — an explicit `<AGENT>_BIN` override is intentionally NOT cached so
|
// — an explicit `<AGENT>_BIN` override is intentionally NOT cached so
|
||||||
// clearing the env reliably falls back to auto-pick (#1007 round-2 P2).
|
// clearing the env reliably falls back to auto-pick (#1007 round-2 P2).
|
||||||
const versionAwareCache = new Map<string, string>();
|
const versionAwareCache = new Map<string, VersionAwareCacheEntry>();
|
||||||
|
|
||||||
export function clearVersionAwareResolutionCache(agentId?: string): void {
|
export function clearVersionAwareResolutionCache(agentId?: string): void {
|
||||||
if (agentId === undefined) {
|
if (agentId === undefined) {
|
||||||
|
|
@ -340,6 +352,63 @@ export function clearVersionAwareResolutionCache(agentId?: string): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readVersionAwareExecutableIdentity(filePath: string): VersionAwareExecutableIdentity | null {
|
||||||
|
try {
|
||||||
|
const realpath = realpathSync(filePath);
|
||||||
|
const stat = statSync(realpath);
|
||||||
|
if (!stat.isFile()) return null;
|
||||||
|
return {
|
||||||
|
realpath,
|
||||||
|
dev: stat.dev,
|
||||||
|
ino: stat.ino,
|
||||||
|
mode: stat.mode,
|
||||||
|
size: stat.size,
|
||||||
|
mtimeMs: stat.mtimeMs,
|
||||||
|
ctimeMs: stat.ctimeMs,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionAwareExecutableIdentityEquals(
|
||||||
|
a: VersionAwareExecutableIdentity,
|
||||||
|
b: VersionAwareExecutableIdentity,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
a.realpath === b.realpath &&
|
||||||
|
a.dev === b.dev &&
|
||||||
|
a.ino === b.ino &&
|
||||||
|
a.mode === b.mode &&
|
||||||
|
a.size === b.size &&
|
||||||
|
a.mtimeMs === b.mtimeMs &&
|
||||||
|
a.ctimeMs === b.ctimeMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cachedVersionAwarePath(agentId: string): string | null {
|
||||||
|
const cached = versionAwareCache.get(agentId);
|
||||||
|
if (!cached) return null;
|
||||||
|
const currentIdentity = readVersionAwareExecutableIdentity(cached.path);
|
||||||
|
if (
|
||||||
|
!currentIdentity ||
|
||||||
|
!versionAwareExecutableIdentityEquals(cached.identity, currentIdentity)
|
||||||
|
) {
|
||||||
|
versionAwareCache.delete(agentId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return cached.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberVersionAwarePath(agentId: string, selectedPath: string): void {
|
||||||
|
const identity = readVersionAwareExecutableIdentity(selectedPath);
|
||||||
|
if (!identity) {
|
||||||
|
versionAwareCache.delete(agentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
versionAwareCache.set(agentId, { path: selectedPath, identity });
|
||||||
|
}
|
||||||
|
|
||||||
// Strict, anchored semver parse. Accepts a leading `v` and tolerates
|
// Strict, anchored semver parse. Accepts a leading `v` and tolerates
|
||||||
// trailing pre-release (`-rc.1`) / build metadata (`+build.5`) but
|
// trailing pre-release (`-rc.1`) / build metadata (`+build.5`) but
|
||||||
// only major.minor.patch participates in comparison. Returns `null`
|
// only major.minor.patch participates in comparison. Returns `null`
|
||||||
|
|
@ -396,8 +465,8 @@ export async function chooseExecutableByMinVersion(
|
||||||
// (line ~390 below) and via `clearVersionAwareResolutionCache()`, so
|
// (line ~390 below) and via `clearVersionAwareResolutionCache()`, so
|
||||||
// a missing or relocated cached pick still gets re-probed at the
|
// a missing or relocated cached pick still gets re-probed at the
|
||||||
// next launch.
|
// next launch.
|
||||||
const cached = versionAwareCache.get(def.id);
|
const cached = cachedVersionAwarePath(def.id);
|
||||||
if (cached && existsSync(cached)) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -433,7 +502,7 @@ export async function chooseExecutableByMinVersion(
|
||||||
for (const probe of probes) {
|
for (const probe of probes) {
|
||||||
const cmp = compareSemver(probe.version, def.minVersion);
|
const cmp = compareSemver(probe.version, def.minVersion);
|
||||||
if (cmp !== null && cmp >= 0) {
|
if (cmp !== null && cmp >= 0) {
|
||||||
versionAwareCache.set(def.id, probe.path);
|
rememberVersionAwarePath(def.id, probe.path);
|
||||||
return probe.path;
|
return probe.path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,11 @@ import {
|
||||||
readMcpConfig,
|
readMcpConfig,
|
||||||
writeMcpConfig,
|
writeMcpConfig,
|
||||||
} from './mcp-config.js';
|
} from './mcp-config.js';
|
||||||
|
import {
|
||||||
|
parseRunToolBundleForRequest,
|
||||||
|
resolveExternalMcpServersForRun,
|
||||||
|
validateRunToolBundleForAgent,
|
||||||
|
} from './run-tool-bundle.js';
|
||||||
import {
|
import {
|
||||||
beginAuth,
|
beginAuth,
|
||||||
exchangeCodeForToken,
|
exchangeCodeForToken,
|
||||||
|
|
@ -10776,8 +10781,8 @@ export async function startServer({
|
||||||
// doesn't exist yet). Without one we don't pass cwd to spawn — the
|
// doesn't exist yet). Without one we don't pass cwd to spawn — the
|
||||||
// agent then runs in whatever inherited dir, which still lets API
|
// agent then runs in whatever inherited dir, which still lets API
|
||||||
// mode work but loses file-tool addressability.
|
// mode work but loses file-tool addressability.
|
||||||
// For git-linked projects (metadata.baseDir), use that folder directly
|
// Project directory resolution lives in projects.ts so sandbox mode can
|
||||||
// so the agent writes back to the user's original source tree.
|
// consistently reject imported-folder metadata that has no managed copy.
|
||||||
let cwd = null;
|
let cwd = null;
|
||||||
let existingProjectFiles = [];
|
let existingProjectFiles = [];
|
||||||
if (typeof projectId === 'string' && projectId) {
|
if (typeof projectId === 'string' && projectId) {
|
||||||
|
|
@ -10902,57 +10907,71 @@ export async function startServer({
|
||||||
// values further down at .mcp.json write time — see the spawn block
|
// values further down at .mcp.json write time — see the spawn block
|
||||||
// below — instead of re-reading.
|
// below — instead of re-reading.
|
||||||
let externalMcpConfig = { servers: [] };
|
let externalMcpConfig = { servers: [] };
|
||||||
try {
|
if (!SANDBOX_RUNTIME.enabled) {
|
||||||
externalMcpConfig = await readMcpConfig(RUNTIME_DATA_DIR);
|
try {
|
||||||
} catch (err) {
|
externalMcpConfig = await readMcpConfig(RUNTIME_DATA_DIR);
|
||||||
console.warn(
|
} catch (err) {
|
||||||
'[mcp-config] read failed:',
|
console.warn(
|
||||||
err && err.message ? err.message : err,
|
'[mcp-config] read failed:',
|
||||||
);
|
err && err.message ? err.message : err,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const enabledExternalMcp = externalMcpConfig.servers.filter((s) => s.enabled);
|
const runScopedMcpServers = Array.isArray(run?.toolBundle?.mcpServers)
|
||||||
|
? run.toolBundle.mcpServers
|
||||||
|
: [];
|
||||||
|
const {
|
||||||
|
enabledServers: enabledExternalMcp,
|
||||||
|
persistedTokenServerIds,
|
||||||
|
} = resolveExternalMcpServersForRun({
|
||||||
|
persistedServers: externalMcpConfig.servers,
|
||||||
|
runScopedServers: runScopedMcpServers,
|
||||||
|
sandboxMode: SANDBOX_RUNTIME.enabled,
|
||||||
|
});
|
||||||
const oauthTokensForSpawn = {};
|
const oauthTokensForSpawn = {};
|
||||||
try {
|
if (persistedTokenServerIds.size > 0) {
|
||||||
const stored = await readAllTokens(RUNTIME_DATA_DIR);
|
try {
|
||||||
for (const [serverId, tok] of Object.entries(stored)) {
|
const stored = await readAllTokens(RUNTIME_DATA_DIR);
|
||||||
if (!enabledExternalMcp.find((s) => s.id === serverId)) continue;
|
for (const [serverId, tok] of Object.entries(stored)) {
|
||||||
// Default to the persisted access token; null it out if expired so
|
if (!persistedTokenServerIds.has(serverId)) continue;
|
||||||
// we never inject a stale `Authorization: Bearer …` header. The
|
// Default to the persisted access token; null it out if expired so
|
||||||
// model treats a server with a Bearer pinned as connected and
|
// we never inject a stale `Authorization: Bearer …` header. The
|
||||||
// discourages re-auth, which is the worst possible UX when the
|
// model treats a server with a Bearer pinned as connected and
|
||||||
// token is going to 401 every call.
|
// discourages re-auth, which is the worst possible UX when the
|
||||||
let access = isTokenExpired(tok) ? null : tok.accessToken;
|
// token is going to 401 every call.
|
||||||
if (isTokenExpired(tok) && tok.refreshToken) {
|
let access = isTokenExpired(tok) ? null : tok.accessToken;
|
||||||
try {
|
if (isTokenExpired(tok) && tok.refreshToken) {
|
||||||
const refreshed = await refreshAndPersistToken(
|
try {
|
||||||
RUNTIME_DATA_DIR,
|
const refreshed = await refreshAndPersistToken(
|
||||||
serverId,
|
RUNTIME_DATA_DIR,
|
||||||
tok,
|
serverId,
|
||||||
);
|
tok,
|
||||||
if (refreshed) access = refreshed.accessToken;
|
);
|
||||||
} catch (err) {
|
if (refreshed) access = refreshed.accessToken;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
'[mcp-oauth] refresh failed for',
|
||||||
|
serverId,
|
||||||
|
err && err.message ? err.message : err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (access) {
|
||||||
|
oauthTokensForSpawn[serverId] = access;
|
||||||
|
} else {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[mcp-oauth] refresh failed for',
|
'[mcp-oauth] skipping expired token for',
|
||||||
serverId,
|
serverId,
|
||||||
err && err.message ? err.message : err,
|
'— reconnect required',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (access) {
|
} catch (err) {
|
||||||
oauthTokensForSpawn[serverId] = access;
|
console.warn(
|
||||||
} else {
|
'[mcp-tokens] read failed:',
|
||||||
console.warn(
|
err && err.message ? err.message : err,
|
||||||
'[mcp-oauth] skipping expired token for',
|
);
|
||||||
serverId,
|
|
||||||
'— reconnect required',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.warn(
|
|
||||||
'[mcp-tokens] read failed:',
|
|
||||||
err && err.message ? err.message : err,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const connectedExternalMcp = enabledExternalMcp
|
const connectedExternalMcp = enabledExternalMcp
|
||||||
.filter((s) => typeof oauthTokensForSpawn[s.id] === 'string')
|
.filter((s) => typeof oauthTokensForSpawn[s.id] === 'string')
|
||||||
|
|
@ -11321,6 +11340,8 @@ export async function startServer({
|
||||||
...(def.env || {}),
|
...(def.env || {}),
|
||||||
},
|
},
|
||||||
configuredAgentEnv,
|
configuredAgentEnv,
|
||||||
|
undefined,
|
||||||
|
{ resolvedBin: agentLaunch.selectedPath },
|
||||||
),
|
),
|
||||||
agentLaunch,
|
agentLaunch,
|
||||||
)
|
)
|
||||||
|
|
@ -11703,6 +11724,8 @@ export async function startServer({
|
||||||
...(def.env || {}),
|
...(def.env || {}),
|
||||||
},
|
},
|
||||||
configuredAgentEnv,
|
configuredAgentEnv,
|
||||||
|
undefined,
|
||||||
|
{ resolvedBin: agentLaunch.selectedPath },
|
||||||
);
|
);
|
||||||
if (def.id === 'amr') {
|
if (def.id === 'amr') {
|
||||||
const loginStatus = readVelaLoginStatus(agentSpawnEnv, configuredAgentEnv);
|
const loginStatus = readVelaLoginStatus(agentSpawnEnv, configuredAgentEnv);
|
||||||
|
|
@ -12656,6 +12679,7 @@ export async function startServer({
|
||||||
stderrTail: agentStderrTail,
|
stderrTail: agentStderrTail,
|
||||||
stdoutTail: agentStdoutTail,
|
stdoutTail: agentStdoutTail,
|
||||||
env: spawnedAgentEnv,
|
env: spawnedAgentEnv,
|
||||||
|
resolvedBin: agentLaunch.selectedPath,
|
||||||
});
|
});
|
||||||
// A non-zero exit whose output reads as an auth / quota / upstream
|
// A non-zero exit whose output reads as an auth / quota / upstream
|
||||||
// problem (typical of Claude Code, codex, …) gets the specific code
|
// problem (typical of Claude Code, codex, …) gets the specific code
|
||||||
|
|
@ -13001,14 +13025,33 @@ export async function startServer({
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function runToolBundleDeliveryTargetForProject(projectId, metadata) {
|
||||||
|
if (typeof projectId !== 'string' || !projectId || !isSafeId(projectId)) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cwd = resolveProjectDir(PROJECTS_DIR, projectId, metadata, {
|
||||||
|
allowUnavailableSandboxImportedProject: true,
|
||||||
|
});
|
||||||
|
return isManagedProjectCwd(cwd, PROJECTS_DIR) ? 'managed-project' : 'external-project';
|
||||||
|
} catch {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.post('/api/runs', async (req, res) => {
|
app.post('/api/runs', async (req, res) => {
|
||||||
if (daemonShuttingDown) {
|
if (daemonShuttingDown) {
|
||||||
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
|
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
|
||||||
}
|
}
|
||||||
const mediaExecution = parseMediaExecutionPolicyInput(req.body?.mediaExecution);
|
const requestBody = req.body && typeof req.body === 'object' ? req.body : {};
|
||||||
|
const mediaExecution = parseMediaExecutionPolicyInput(requestBody.mediaExecution);
|
||||||
if (!mediaExecution.ok) {
|
if (!mediaExecution.ok) {
|
||||||
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
|
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
|
||||||
}
|
}
|
||||||
|
const toolBundle = parseRunToolBundleForRequest(requestBody.toolBundle);
|
||||||
|
if (!toolBundle.ok) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', toolBundle.message);
|
||||||
|
}
|
||||||
// Plan §3.A1 / spec §11.5: resolve any pluginId / appliedPluginSnapshotId
|
// Plan §3.A1 / spec §11.5: resolve any pluginId / appliedPluginSnapshotId
|
||||||
// before the run is created. The resolver returns null when the body
|
// before the run is created. The resolver returns null when the body
|
||||||
// does not mention a plugin (legacy runs unchanged), an error envelope
|
// does not mention a plugin (legacy runs unchanged), an error envelope
|
||||||
|
|
@ -13024,7 +13067,7 @@ export async function startServer({
|
||||||
// bundled scenario that is not installed leaves the run plugin-less,
|
// bundled scenario that is not installed leaves the run plugin-less,
|
||||||
// which matches the legacy path.
|
// which matches the legacy path.
|
||||||
let resolvedSnapshot = null;
|
let resolvedSnapshot = null;
|
||||||
if (typeof req.body?.projectId === 'string' && req.body.projectId) {
|
if (typeof requestBody.projectId === 'string' && requestBody.projectId) {
|
||||||
let registryView;
|
let registryView;
|
||||||
try {
|
try {
|
||||||
registryView = await loadPluginRegistryView();
|
registryView = await loadPluginRegistryView();
|
||||||
|
|
@ -13032,26 +13075,26 @@ export async function startServer({
|
||||||
return res.status(500).json({ error: String(err) });
|
return res.status(500).json({ error: String(err) });
|
||||||
}
|
}
|
||||||
const explicitPlugin =
|
const explicitPlugin =
|
||||||
req.body && (req.body.pluginId || req.body.appliedPluginSnapshotId);
|
requestBody.pluginId || requestBody.appliedPluginSnapshotId;
|
||||||
let runResolveBody = req.body;
|
let runResolveBody = requestBody;
|
||||||
if (!explicitPlugin) {
|
if (!explicitPlugin) {
|
||||||
const projectRow = getProject(db, req.body.projectId);
|
const projectRow = getProject(db, requestBody.projectId);
|
||||||
const hasPin =
|
const hasPin =
|
||||||
typeof projectRow?.appliedPluginSnapshotId === 'string'
|
typeof projectRow?.appliedPluginSnapshotId === 'string'
|
||||||
&& projectRow.appliedPluginSnapshotId.length > 0;
|
&& projectRow.appliedPluginSnapshotId.length > 0;
|
||||||
if (!hasPin) {
|
if (!hasPin) {
|
||||||
const fallbackPluginId = defaultScenarioPluginIdForProjectMetadata(projectRow?.metadata);
|
const fallbackPluginId = defaultScenarioPluginIdForProjectMetadata(projectRow?.metadata);
|
||||||
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
|
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
|
||||||
runResolveBody = { ...req.body, pluginId: fallbackPluginId };
|
runResolveBody = { ...requestBody, pluginId: fallbackPluginId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const resolved = resolvePluginSnapshot({
|
const resolved = resolvePluginSnapshot({
|
||||||
db,
|
db,
|
||||||
body: runResolveBody,
|
body: runResolveBody,
|
||||||
projectId: req.body.projectId,
|
projectId: requestBody.projectId,
|
||||||
conversationId: typeof req.body.conversationId === 'string'
|
conversationId: typeof requestBody.conversationId === 'string'
|
||||||
? req.body.conversationId
|
? requestBody.conversationId
|
||||||
: null,
|
: null,
|
||||||
registry: registryView,
|
registry: registryView,
|
||||||
connectorProbe: buildConnectorProbe(connectorService),
|
connectorProbe: buildConnectorProbe(connectorService),
|
||||||
|
|
@ -13059,7 +13102,7 @@ export async function startServer({
|
||||||
if (resolved && !resolved.ok) {
|
if (resolved && !resolved.ok) {
|
||||||
if (!explicitPlugin) {
|
if (!explicitPlugin) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[plugins] default-scenario fallback skipped for run on project ${req.body.projectId}: ${resolved.body?.error?.code ?? 'unknown'}`,
|
`[plugins] default-scenario fallback skipped for run on project ${requestBody.projectId}: ${resolved.body?.error?.code ?? 'unknown'}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return res.status(resolved.status).json(resolved.body);
|
return res.status(resolved.status).json(resolved.body);
|
||||||
|
|
@ -13068,7 +13111,11 @@ export async function startServer({
|
||||||
resolvedSnapshot = resolved;
|
resolvedSnapshot = resolved;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const meta = { ...(req.body || {}), mediaExecution: mediaExecution.policy };
|
const meta = {
|
||||||
|
...requestBody,
|
||||||
|
mediaExecution: mediaExecution.policy,
|
||||||
|
toolBundle: toolBundle.bundle,
|
||||||
|
};
|
||||||
if (resolvedSnapshot?.ok) {
|
if (resolvedSnapshot?.ok) {
|
||||||
meta.appliedPluginSnapshotId = resolvedSnapshot.snapshotId;
|
meta.appliedPluginSnapshotId = resolvedSnapshot.snapshotId;
|
||||||
if (!meta.pluginId) meta.pluginId = resolvedSnapshot.snapshot.pluginId;
|
if (!meta.pluginId) meta.pluginId = resolvedSnapshot.snapshot.pluginId;
|
||||||
|
|
@ -13080,6 +13127,53 @@ export async function startServer({
|
||||||
if (renderedQuery.length > 0) meta.message = renderedQuery;
|
if (renderedQuery.length > 0) meta.message = renderedQuery;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let runProject = null;
|
||||||
|
if (typeof meta.projectId === 'string' && meta.projectId) {
|
||||||
|
try {
|
||||||
|
runProject = getProject(db, meta.projectId);
|
||||||
|
assertSandboxProjectRootAvailable(runProject?.metadata);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SandboxImportedProjectError) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// MCP / SDK callers may omit agentId. Resolve it before any run-create
|
||||||
|
// side effects so unsupported run-scoped tool bundles can fail cleanly.
|
||||||
|
if (typeof meta.agentId !== 'string' || !meta.agentId) {
|
||||||
|
try {
|
||||||
|
const appCfg = await readAppConfig(RUNTIME_DATA_DIR);
|
||||||
|
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
|
||||||
|
? appCfg.agentId
|
||||||
|
: null;
|
||||||
|
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
|
||||||
|
const cfgAgentAvailable = cfgAgent
|
||||||
|
? agents.some((agent) => agent.id === cfgAgent && agent.available)
|
||||||
|
: false;
|
||||||
|
if (cfgAgent && cfgAgentAvailable) {
|
||||||
|
meta.agentId = cfgAgent;
|
||||||
|
} else {
|
||||||
|
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
|
||||||
|
if (firstAvailable) meta.agentId = firstAvailable;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[runs] agent id fallback failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const toolBundleSupport = validateRunToolBundleForAgent(
|
||||||
|
toolBundle.bundle,
|
||||||
|
typeof meta.agentId === 'string' ? getAgentDef(meta.agentId) : null,
|
||||||
|
{
|
||||||
|
deliveryTarget: runToolBundleDeliveryTargetForProject(
|
||||||
|
meta.projectId,
|
||||||
|
runProject?.metadata,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!toolBundleSupport.ok) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', toolBundleSupport.message);
|
||||||
|
}
|
||||||
// MCP / SDK callers POST /api/runs with just a projectId — no
|
// MCP / SDK callers POST /api/runs with just a projectId — no
|
||||||
// conversationId, no pre-created assistantMessageId — because they
|
// conversationId, no pre-created assistantMessageId — because they
|
||||||
// don't know about OD's chat-row lifecycle. The web flow
|
// don't know about OD's chat-row lifecycle. The web flow
|
||||||
|
|
@ -13139,30 +13233,6 @@ export async function startServer({
|
||||||
console.warn('[runs] mcp conversation fallback failed', err);
|
console.warn('[runs] mcp conversation fallback failed', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// MCP / SDK callers may omit agentId. Resolve it from the saved
|
|
||||||
// app-config agent (the user's configured default) or the first
|
|
||||||
// available CLI so the run does not immediately fail with
|
|
||||||
// "unknown agent: undefined" inside startChatRun.
|
|
||||||
if (typeof meta.agentId !== 'string' || !meta.agentId) {
|
|
||||||
try {
|
|
||||||
const appCfg = await readAppConfig(RUNTIME_DATA_DIR);
|
|
||||||
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
|
|
||||||
? appCfg.agentId
|
|
||||||
: null;
|
|
||||||
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
|
|
||||||
const cfgAgentAvailable = cfgAgent
|
|
||||||
? agents.some((agent) => agent.id === cfgAgent && agent.available)
|
|
||||||
: false;
|
|
||||||
if (cfgAgent && cfgAgentAvailable) {
|
|
||||||
meta.agentId = cfgAgent;
|
|
||||||
} else {
|
|
||||||
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
|
|
||||||
if (firstAvailable) meta.agentId = firstAvailable;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[runs] agent id fallback failed', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const run = design.runs.create(meta);
|
const run = design.runs.create(meta);
|
||||||
try {
|
try {
|
||||||
pinAssistantMessageOnRunCreate(db, run);
|
pinAssistantMessageOnRunCreate(db, run);
|
||||||
|
|
@ -13577,11 +13647,45 @@ export async function startServer({
|
||||||
if (daemonShuttingDown) {
|
if (daemonShuttingDown) {
|
||||||
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
|
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
|
||||||
}
|
}
|
||||||
const mediaExecution = parseMediaExecutionPolicyInput(req.body?.mediaExecution);
|
const requestBody = req.body && typeof req.body === 'object' ? req.body : {};
|
||||||
|
const mediaExecution = parseMediaExecutionPolicyInput(requestBody.mediaExecution);
|
||||||
if (!mediaExecution.ok) {
|
if (!mediaExecution.ok) {
|
||||||
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
|
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
|
||||||
}
|
}
|
||||||
const meta = { ...(req.body || {}), mediaExecution: mediaExecution.policy };
|
const toolBundle = parseRunToolBundleForRequest(requestBody.toolBundle);
|
||||||
|
if (!toolBundle.ok) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', toolBundle.message);
|
||||||
|
}
|
||||||
|
let chatProject = null;
|
||||||
|
if (typeof requestBody.projectId === 'string' && requestBody.projectId) {
|
||||||
|
try {
|
||||||
|
chatProject = getProject(db, requestBody.projectId);
|
||||||
|
assertSandboxProjectRootAvailable(chatProject?.metadata);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SandboxImportedProjectError) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const toolBundleSupport = validateRunToolBundleForAgent(
|
||||||
|
toolBundle.bundle,
|
||||||
|
typeof requestBody.agentId === 'string' ? getAgentDef(requestBody.agentId) : null,
|
||||||
|
{
|
||||||
|
deliveryTarget: runToolBundleDeliveryTargetForProject(
|
||||||
|
requestBody.projectId,
|
||||||
|
chatProject?.metadata,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!toolBundleSupport.ok) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', toolBundleSupport.message);
|
||||||
|
}
|
||||||
|
const meta = {
|
||||||
|
...requestBody,
|
||||||
|
mediaExecution: mediaExecution.policy,
|
||||||
|
toolBundle: toolBundle.bundle,
|
||||||
|
};
|
||||||
const run = design.runs.create(meta);
|
const run = design.runs.create(meta);
|
||||||
design.runs.stream(run, req, res);
|
design.runs.stream(run, req, res);
|
||||||
design.runs.start(run, () => startChatRun(meta, run));
|
design.runs.start(run, () => startChatRun(meta, run));
|
||||||
|
|
@ -13658,6 +13762,7 @@ export async function startServer({
|
||||||
if (routine.target.mode === 'reuse') {
|
if (routine.target.mode === 'reuse') {
|
||||||
const project = getProject(db, routine.target.projectId);
|
const project = getProject(db, routine.target.projectId);
|
||||||
if (!project) throw new Error(`Routine target project ${routine.target.projectId} not found`);
|
if (!project) throw new Error(`Routine target project ${routine.target.projectId} not found`);
|
||||||
|
assertSandboxProjectRootAvailable(project.metadata);
|
||||||
projectId = project.id;
|
projectId = project.id;
|
||||||
projectName = project.name;
|
projectName = project.name;
|
||||||
previousProjectSnapshotId = project.appliedPluginSnapshotId ?? null;
|
previousProjectSnapshotId = project.appliedPluginSnapshotId ?? null;
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,42 @@ async function withFakeAgent<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withOnlyFakeAgent<T>(
|
||||||
|
binName: string,
|
||||||
|
script: string,
|
||||||
|
run: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-bin-'));
|
||||||
|
const oldPath = process.env.PATH;
|
||||||
|
const oldAgentHome = process.env.OD_AGENT_HOME;
|
||||||
|
const oldClaudeBin = process.env.CLAUDE_BIN;
|
||||||
|
try {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const runner = path.join(dir, `${binName}-test-runner.cjs`);
|
||||||
|
await fsp.writeFile(runner, script);
|
||||||
|
await fsp.writeFile(
|
||||||
|
path.join(dir, `${binName}.cmd`),
|
||||||
|
`@echo off\r\nnode "${runner}" %*\r\n`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const bin = path.join(dir, binName);
|
||||||
|
await fsp.writeFile(bin, `#!/usr/bin/env node\n${script}`);
|
||||||
|
await fsp.chmod(bin, 0o755);
|
||||||
|
}
|
||||||
|
process.env.PATH = dir;
|
||||||
|
process.env.OD_AGENT_HOME = dir;
|
||||||
|
delete process.env.CLAUDE_BIN;
|
||||||
|
return await run();
|
||||||
|
} finally {
|
||||||
|
process.env.PATH = oldPath;
|
||||||
|
if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME;
|
||||||
|
else process.env.OD_AGENT_HOME = oldAgentHome;
|
||||||
|
if (oldClaudeBin === undefined) delete process.env.CLAUDE_BIN;
|
||||||
|
else process.env.CLAUDE_BIN = oldClaudeBin;
|
||||||
|
await fsp.rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function withFakeCodex<T>(script: string, run: () => Promise<T>): Promise<T> {
|
async function withFakeCodex<T>(script: string, run: () => Promise<T>): Promise<T> {
|
||||||
return withFakeAgent('codex', script, run);
|
return withFakeAgent('codex', script, run);
|
||||||
}
|
}
|
||||||
|
|
@ -94,6 +130,10 @@ async function withFakeClaude<T>(script: string, run: () => Promise<T>): Promise
|
||||||
return withFakeAgent('claude', script, run);
|
return withFakeAgent('claude', script, run);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withOnlyFakeOpenClaude<T>(script: string, run: () => Promise<T>): Promise<T> {
|
||||||
|
return withOnlyFakeAgent('openclaude', script, run);
|
||||||
|
}
|
||||||
|
|
||||||
async function withFakeOpenCode<T>(script: string, run: () => Promise<T>): Promise<T> {
|
async function withFakeOpenCode<T>(script: string, run: () => Promise<T>): Promise<T> {
|
||||||
return withFakeAgent('opencode', script, run);
|
return withFakeAgent('opencode', script, run);
|
||||||
}
|
}
|
||||||
|
|
@ -2199,6 +2239,58 @@ process.stdin.on('end', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves ANTHROPIC_API_KEY when Claude adapter launches the OpenClaude fallback', async () => {
|
||||||
|
const envFile = path.join(os.tmpdir(), `od-openclaude-env-${Date.now()}-${Math.random()}.json`);
|
||||||
|
const previousKey = process.env.ANTHROPIC_API_KEY;
|
||||||
|
try {
|
||||||
|
process.env.ANTHROPIC_API_KEY = 'sk-openclaude-test';
|
||||||
|
await withOnlyFakeOpenClaude(
|
||||||
|
`
|
||||||
|
const fs = require('node:fs');
|
||||||
|
fs.writeFileSync(${JSON.stringify(envFile)}, JSON.stringify({
|
||||||
|
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || null,
|
||||||
|
}));
|
||||||
|
let input = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', (chunk) => { input += chunk; });
|
||||||
|
process.stdin.on('end', () => {
|
||||||
|
try {
|
||||||
|
JSON.parse(input.trim());
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
type: 'assistant',
|
||||||
|
message: {
|
||||||
|
id: 'msg_1',
|
||||||
|
content: [{ type: 'text', text: 'ok' }],
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
async () => {
|
||||||
|
const result = await testAgentConnection({ agentId: 'claude' });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
kind: 'success',
|
||||||
|
agentName: 'Claude Code',
|
||||||
|
});
|
||||||
|
await expect(fsp.readFile(envFile, 'utf8')).resolves.toBe(
|
||||||
|
JSON.stringify({ ANTHROPIC_API_KEY: 'sk-openclaude-test' }),
|
||||||
|
);
|
||||||
|
expect(result.diagnostics?.binaryPath ?? '').toMatch(/openclaude/i);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (previousKey === undefined) delete process.env.ANTHROPIC_API_KEY;
|
||||||
|
else process.env.ANTHROPIC_API_KEY = previousKey;
|
||||||
|
await fsp.rm(envFile, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('returns Claude /login guidance when the spawned CLI cannot authenticate', async () => {
|
it('returns Claude /login guidance when the spawned CLI cannot authenticate', async () => {
|
||||||
await withFakeClaude(
|
await withFakeClaude(
|
||||||
`console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`,
|
`console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type http from 'node:http';
|
import type http from 'node:http';
|
||||||
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
|
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
|
||||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
import { chmod, mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
@ -56,26 +56,6 @@ describe('POST /api/import/folder', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForRunStatus(
|
|
||||||
runId: string,
|
|
||||||
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
|
|
||||||
let lastStatus = 'unknown';
|
|
||||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
||||||
const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`);
|
|
||||||
const statusBody = (await statusResponse.json()) as {
|
|
||||||
status: string;
|
|
||||||
error?: string | null;
|
|
||||||
errorCode?: string | null;
|
|
||||||
};
|
|
||||||
lastStatus = statusBody.status;
|
|
||||||
if (statusBody.status !== 'queued' && statusBody.status !== 'running') {
|
|
||||||
return statusBody;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
||||||
}
|
|
||||||
throw new Error(`run did not reach a terminal status; last status: ${lastStatus}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('creates a project rooted at the submitted folder', async () => {
|
it('creates a project rooted at the submitted folder', async () => {
|
||||||
const folder = makeFolder();
|
const folder = makeFolder();
|
||||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||||
|
|
@ -105,7 +85,7 @@ describe('POST /api/import/folder', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails sandbox runs for imported folders instead of using an empty managed project', async () => {
|
it('rejects sandbox runs for imported folders before creating a run', async () => {
|
||||||
const folder = makeFolder();
|
const folder = makeFolder();
|
||||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||||
|
|
||||||
|
|
@ -123,15 +103,78 @@ describe('POST /api/import/folder', () => {
|
||||||
message: 'Inspect the imported project.',
|
message: 'Inspect the imported project.',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(runResp.status).toBe(202);
|
expect(runResp.status).toBe(400);
|
||||||
const { runId } = (await runResp.json()) as { runId: string };
|
const body = (await runResp.json()) as { error?: { message?: string } };
|
||||||
const status = await waitForRunStatus(runId);
|
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||||
expect(status.status).toBe('failed');
|
|
||||||
expect(status.errorCode).toBe('BAD_REQUEST');
|
|
||||||
expect(status.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects sandbox chat runs for imported folders before creating a run', async () => {
|
||||||
|
const folder = makeFolder();
|
||||||
|
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||||
|
|
||||||
|
const importResp = await importFolder({ baseDir: folder });
|
||||||
|
expect(importResp.status).toBe(200);
|
||||||
|
const { project } = (await importResp.json()) as { project: { id: string } };
|
||||||
|
|
||||||
|
await withSandboxMode(async () => {
|
||||||
|
const chatResp = await fetch(`${baseUrl}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'claude',
|
||||||
|
projectId: project.id,
|
||||||
|
message: 'Inspect the imported project.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(chatResp.status).toBe(400);
|
||||||
|
const body = (await chatResp.json()) as { error?: { message?: string } };
|
||||||
|
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||||
|
|
||||||
|
const runsResp = await fetch(`${baseUrl}/api/runs?projectId=${encodeURIComponent(project.id)}`);
|
||||||
|
expect(runsResp.status).toBe(200);
|
||||||
|
const runsBody = (await runsResp.json()) as { runs: unknown[] };
|
||||||
|
expect(runsBody.runs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens imported-folder projects through host editor routes in sandbox mode', async () => {
|
||||||
|
const folder = makeFolder();
|
||||||
|
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||||
|
const binDir = makeFolder();
|
||||||
|
const cursorBin = path.join(
|
||||||
|
binDir,
|
||||||
|
process.platform === 'win32' ? 'cursor.cmd' : 'cursor',
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
cursorBin,
|
||||||
|
process.platform === 'win32' ? '@echo off\r\nexit /b 0\r\n' : '#!/bin/sh\nexit 0\n',
|
||||||
|
);
|
||||||
|
await chmod(cursorBin, 0o755);
|
||||||
|
|
||||||
|
const importResp = await importFolder({ baseDir: folder });
|
||||||
|
expect(importResp.status).toBe(200);
|
||||||
|
const { project } = (await importResp.json()) as { project: { id: string } };
|
||||||
|
|
||||||
|
const previousPath = process.env.PATH;
|
||||||
|
process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`;
|
||||||
|
try {
|
||||||
|
await withSandboxMode(async () => {
|
||||||
|
const resp = await fetch(`${baseUrl}/api/projects/${project.id}/open-in`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ editorId: 'cursor' }),
|
||||||
|
});
|
||||||
|
expect(resp.status).toBe(200);
|
||||||
|
const body = (await resp.json()) as { path?: string };
|
||||||
|
expect(body.path).toBe(await realpath(folder));
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (previousPath == null) delete process.env.PATH;
|
||||||
|
else process.env.PATH = previousPath;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('still opens an imported-folder project record in sandbox mode', async () => {
|
it('still opens an imported-folder project record in sandbox mode', async () => {
|
||||||
const folder = makeFolder();
|
const folder = makeFolder();
|
||||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,14 @@ process.exit(0);
|
||||||
async function waitForRunStatus(
|
async function waitForRunStatus(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
): Promise<{ status: string }> {
|
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
|
||||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||||
const r = await fetch(`${baseUrl}/api/runs/${runId}`);
|
const r = await fetch(`${baseUrl}/api/runs/${runId}`);
|
||||||
const body = (await r.json()) as { status: string };
|
const body = (await r.json()) as {
|
||||||
|
status: string;
|
||||||
|
error?: string | null;
|
||||||
|
errorCode?: string | null;
|
||||||
|
};
|
||||||
if (body.status !== 'queued' && body.status !== 'running') return body;
|
if (body.status !== 'queued' && body.status !== 'running') return body;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +86,7 @@ describe('spawn writes external MCP config for Claude Code', () => {
|
||||||
let server: http.Server;
|
let server: http.Server;
|
||||||
let baseUrl: string;
|
let baseUrl: string;
|
||||||
const projectsToClean: string[] = [];
|
const projectsToClean: string[] = [];
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const started = (await startServer({ port: 0, returnServer: true })) as {
|
const started = (await startServer({ port: 0, returnServer: true })) as {
|
||||||
|
|
@ -106,9 +111,12 @@ describe('spawn writes external MCP config for Claude Code', () => {
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ servers: [] }),
|
body: JSON.stringify({ servers: [] }),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createProject(): Promise<{ id: string; dir: string }> {
|
async function createProject(): Promise<{ id: string; dir: string; conversationId: string }> {
|
||||||
const id = `mcp-spawn-${randomUUID()}`;
|
const id = `mcp-spawn-${randomUUID()}`;
|
||||||
const r = await fetch(`${baseUrl}/api/projects`, {
|
const r = await fetch(`${baseUrl}/api/projects`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -116,6 +124,7 @@ describe('spawn writes external MCP config for Claude Code', () => {
|
||||||
body: JSON.stringify({ id, name: id }),
|
body: JSON.stringify({ id, name: id }),
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
|
const body = (await r.json()) as { conversationId: string };
|
||||||
projectsToClean.push(id);
|
projectsToClean.push(id);
|
||||||
// The daemon owns its data dir; we discover the on-disk project path by
|
// The daemon owns its data dir; we discover the on-disk project path by
|
||||||
// having the daemon return the upload root, then composing path manually.
|
// having the daemon return the upload root, then composing path manually.
|
||||||
|
|
@ -123,7 +132,46 @@ describe('spawn writes external MCP config for Claude Code', () => {
|
||||||
const projectsBase = process.env.OD_DATA_DIR
|
const projectsBase = process.env.OD_DATA_DIR
|
||||||
? join(process.env.OD_DATA_DIR, 'projects')
|
? join(process.env.OD_DATA_DIR, 'projects')
|
||||||
: join(process.cwd(), '.od', 'projects');
|
: join(process.cwd(), '.od', 'projects');
|
||||||
return { id, dir: join(projectsBase, id) };
|
return { id, dir: join(projectsBase, id), conversationId: body.conversationId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importFolderProject(): Promise<{
|
||||||
|
id: string;
|
||||||
|
dir: string;
|
||||||
|
externalDir: string;
|
||||||
|
conversationId: string;
|
||||||
|
}> {
|
||||||
|
const externalDir = await fsp.mkdtemp(join(tmpdir(), 'od-mcp-import-'));
|
||||||
|
tempDirs.push(externalDir);
|
||||||
|
await fsp.writeFile(join(externalDir, 'index.html'), '<!doctype html>');
|
||||||
|
const r = await fetch(`${baseUrl}/api/import/folder`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ baseDir: externalDir }),
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const body = (await r.json()) as { project: { id: string }; conversationId: string };
|
||||||
|
projectsToClean.push(body.project.id);
|
||||||
|
const projectsBase = process.env.OD_DATA_DIR
|
||||||
|
? join(process.env.OD_DATA_DIR, 'projects')
|
||||||
|
: join(process.cwd(), '.od', 'projects');
|
||||||
|
return {
|
||||||
|
id: body.project.id,
|
||||||
|
dir: join(projectsBase, body.project.id),
|
||||||
|
externalDir,
|
||||||
|
conversationId: body.conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
|
||||||
|
const previous = process.env.OD_SANDBOX_MODE;
|
||||||
|
process.env.OD_SANDBOX_MODE = '1';
|
||||||
|
try {
|
||||||
|
return await run();
|
||||||
|
} finally {
|
||||||
|
if (previous == null) delete process.env.OD_SANDBOX_MODE;
|
||||||
|
else process.env.OD_SANDBOX_MODE = previous;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('writes .mcp.json into the per-project dir, then removes it when servers are cleared', async () => {
|
it('writes .mcp.json into the per-project dir, then removes it when servers are cleared', async () => {
|
||||||
|
|
@ -197,6 +245,347 @@ describe('spawn writes external MCP config for Claude Code', () => {
|
||||||
});
|
});
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
|
it('fails sandbox runs for imported-folder projects before writing MCP config', async () => {
|
||||||
|
await withFakeClaude(async () => {
|
||||||
|
const putRes = await fetch(`${baseUrl}/api/mcp/servers`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
id: 'sandbox-run',
|
||||||
|
transport: 'sse',
|
||||||
|
enabled: true,
|
||||||
|
url: 'https://mcp.example.test',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(putRes.ok).toBe(true);
|
||||||
|
|
||||||
|
const { id, dir, externalDir, conversationId } = await importFolderProject();
|
||||||
|
|
||||||
|
await withSandboxMode(async () => {
|
||||||
|
const chatRes = await fetch(`${baseUrl}/api/runs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'claude',
|
||||||
|
projectId: id,
|
||||||
|
message: 'hello sandbox mcp',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(chatRes.status).toBe(400);
|
||||||
|
const body = (await chatRes.json()) as { error?: { message?: string } };
|
||||||
|
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
const managedTarget = join(dir, '.mcp.json');
|
||||||
|
expect(existsSync(managedTarget)).toBe(false);
|
||||||
|
expect(existsSync(join(externalDir, '.mcp.json'))).toBe(false);
|
||||||
|
const messagesRes = await fetch(
|
||||||
|
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
|
||||||
|
);
|
||||||
|
expect(messagesRes.ok).toBe(true);
|
||||||
|
const messagesBody = (await messagesRes.json()) as {
|
||||||
|
messages: Array<{ role: string; content: string }>;
|
||||||
|
};
|
||||||
|
expect(messagesBody.messages.some((msg) => msg.content === 'hello sandbox mcp')).toBe(false);
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('rejects sandbox routine reuse of imported-folder projects before creating run state', async () => {
|
||||||
|
const { id } = await importFolderProject();
|
||||||
|
const conversationsBeforeRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`);
|
||||||
|
expect(conversationsBeforeRes.ok).toBe(true);
|
||||||
|
const conversationsBeforeBody = (await conversationsBeforeRes.json()) as {
|
||||||
|
conversations: Array<{ id: string }>;
|
||||||
|
};
|
||||||
|
const conversationIdsBefore = conversationsBeforeBody.conversations.map((conversation) => conversation.id);
|
||||||
|
|
||||||
|
let routineId: string | null = null;
|
||||||
|
try {
|
||||||
|
const createRoutineRes = await fetch(`${baseUrl}/api/routines`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'Sandbox imported folder routine',
|
||||||
|
prompt: 'try to run inside an imported folder',
|
||||||
|
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
||||||
|
target: { mode: 'reuse', projectId: id },
|
||||||
|
agentId: 'claude',
|
||||||
|
enabled: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(createRoutineRes.status).toBe(201);
|
||||||
|
const createRoutineBody = (await createRoutineRes.json()) as {
|
||||||
|
routine: { id: string };
|
||||||
|
};
|
||||||
|
routineId = createRoutineBody.routine.id;
|
||||||
|
|
||||||
|
await withSandboxMode(async () => {
|
||||||
|
const runRoutineRes = await fetch(`${baseUrl}/api/routines/${routineId}/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
expect(runRoutineRes.status).toBe(500);
|
||||||
|
const runRoutineBody = (await runRoutineRes.json()) as { error?: string };
|
||||||
|
expect(runRoutineBody.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
const routineRunsRes = await fetch(`${baseUrl}/api/routines/${routineId}/runs?limit=10`);
|
||||||
|
expect(routineRunsRes.ok).toBe(true);
|
||||||
|
const routineRunsBody = (await routineRunsRes.json()) as { runs: unknown[] };
|
||||||
|
expect(routineRunsBody.runs).toHaveLength(0);
|
||||||
|
|
||||||
|
const runsRes = await fetch(`${baseUrl}/api/runs?projectId=${encodeURIComponent(id)}`);
|
||||||
|
expect(runsRes.ok).toBe(true);
|
||||||
|
const runsBody = (await runsRes.json()) as { runs: unknown[] };
|
||||||
|
expect(runsBody.runs).toHaveLength(0);
|
||||||
|
|
||||||
|
const conversationsAfterRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`);
|
||||||
|
expect(conversationsAfterRes.ok).toBe(true);
|
||||||
|
const conversationsAfterBody = (await conversationsAfterRes.json()) as {
|
||||||
|
conversations: Array<{ id: string }>;
|
||||||
|
};
|
||||||
|
expect(conversationsAfterBody.conversations.map((conversation) => conversation.id)).toEqual(
|
||||||
|
conversationIdsBefore,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (routineId) {
|
||||||
|
await fetch(`${baseUrl}/api/routines/${routineId}`, { method: 'DELETE' }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('injects run-scoped MCP servers without saving them to the persistent registry', async () => {
|
||||||
|
await withFakeClaude(async () => {
|
||||||
|
const { id, dir } = await createProject();
|
||||||
|
|
||||||
|
const chatRes = await fetch(`${baseUrl}/api/runs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'claude',
|
||||||
|
projectId: id,
|
||||||
|
message: 'hello run-scoped mcp',
|
||||||
|
toolBundle: {
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'run-local',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: ['run-tool.js'],
|
||||||
|
env: { RUN_ONLY: '1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'run-remote',
|
||||||
|
transport: 'http',
|
||||||
|
enabled: true,
|
||||||
|
authMode: 'none',
|
||||||
|
url: 'https://example.test/mcp',
|
||||||
|
headers: { 'X-Run': 'ok' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(chatRes.status).toBe(202);
|
||||||
|
const { runId } = (await chatRes.json()) as { runId: string };
|
||||||
|
const status = await waitForRunStatus(baseUrl, runId) as {
|
||||||
|
status: string;
|
||||||
|
toolBundle?: { mcpServers?: Array<{ id: string }> };
|
||||||
|
};
|
||||||
|
expect(status.status).toBe('succeeded');
|
||||||
|
expect(status.toolBundle?.mcpServers?.map((server) => server.id)).toEqual([
|
||||||
|
'run-local',
|
||||||
|
'run-remote',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const target = join(dir, '.mcp.json');
|
||||||
|
expect(existsSync(target)).toBe(true);
|
||||||
|
const written = JSON.parse(await fsp.readFile(target, 'utf8'));
|
||||||
|
expect(written.mcpServers.run_local).toBeUndefined();
|
||||||
|
expect(written.mcpServers['run-local']).toMatchObject({
|
||||||
|
command: 'node',
|
||||||
|
args: ['run-tool.js'],
|
||||||
|
env: { RUN_ONLY: '1' },
|
||||||
|
});
|
||||||
|
expect(written.mcpServers['run-remote']).toMatchObject({
|
||||||
|
type: 'http',
|
||||||
|
url: 'https://example.test/mcp',
|
||||||
|
headers: { 'X-Run': 'ok' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistedRes = await fetch(`${baseUrl}/api/mcp/servers`);
|
||||||
|
expect(persistedRes.ok).toBe(true);
|
||||||
|
const persisted = (await persistedRes.json()) as { servers: unknown[] };
|
||||||
|
expect(persisted.servers).toEqual([]);
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('rejects Claude run-scoped MCP bundles for imported-folder projects', async () => {
|
||||||
|
const { id, dir, externalDir, conversationId } = await importFolderProject();
|
||||||
|
|
||||||
|
const runsRes = await fetch(`${baseUrl}/api/runs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'claude',
|
||||||
|
projectId: id,
|
||||||
|
message: 'imported run-scoped tools',
|
||||||
|
toolBundle: {
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'run-local',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(runsRes.status).toBe(400);
|
||||||
|
const runsBody = (await runsRes.json()) as { error?: { message?: string } };
|
||||||
|
expect(runsBody.error?.message).toContain('toolBundle requires a daemon-managed project');
|
||||||
|
|
||||||
|
const chatRes = await fetch(`${baseUrl}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'claude',
|
||||||
|
projectId: id,
|
||||||
|
message: 'imported chat-scoped tools',
|
||||||
|
toolBundle: {
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'run-local-chat',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(chatRes.status).toBe(400);
|
||||||
|
const chatBody = (await chatRes.json()) as { error?: { message?: string } };
|
||||||
|
expect(chatBody.error?.message).toContain('toolBundle requires a daemon-managed project');
|
||||||
|
|
||||||
|
expect(existsSync(join(dir, '.mcp.json'))).toBe(false);
|
||||||
|
expect(existsSync(join(externalDir, '.mcp.json'))).toBe(false);
|
||||||
|
const messagesRes = await fetch(
|
||||||
|
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
|
||||||
|
);
|
||||||
|
expect(messagesRes.ok).toBe(true);
|
||||||
|
const messagesBody = (await messagesRes.json()) as {
|
||||||
|
messages: Array<{ content: string }>;
|
||||||
|
};
|
||||||
|
expect(messagesBody.messages.some((msg) => msg.content === 'imported run-scoped tools')).toBe(false);
|
||||||
|
expect(messagesBody.messages.some((msg) => msg.content === 'imported chat-scoped tools')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed run-scoped MCP bundles before creating runs', async () => {
|
||||||
|
const { id } = await createProject();
|
||||||
|
|
||||||
|
const invalidRunsRes = await fetch(`${baseUrl}/api/runs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'claude',
|
||||||
|
projectId: id,
|
||||||
|
message: 'bad tools',
|
||||||
|
toolBundle: {
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'missing-command',
|
||||||
|
transport: 'stdio',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(invalidRunsRes.status).toBe(400);
|
||||||
|
const runsBody = (await invalidRunsRes.json()) as { error?: { message?: string } };
|
||||||
|
expect(runsBody.error?.message).toContain('toolBundle.mcpServers[0] is invalid');
|
||||||
|
|
||||||
|
const invalidChatRes = await fetch(`${baseUrl}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'claude',
|
||||||
|
projectId: id,
|
||||||
|
message: 'bad tools',
|
||||||
|
toolBundle: 'bad',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(invalidChatRes.status).toBe(400);
|
||||||
|
const chatBody = (await invalidChatRes.json()) as { error?: { message?: string } };
|
||||||
|
expect(chatBody.error?.message).toContain('toolBundle must be an object');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects run-scoped MCP bundles the selected runtime cannot receive', async () => {
|
||||||
|
const { id, conversationId } = await createProject();
|
||||||
|
|
||||||
|
const unsupportedRuntimeRes = await fetch(`${baseUrl}/api/runs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'codex',
|
||||||
|
projectId: id,
|
||||||
|
message: 'bad tools',
|
||||||
|
toolBundle: {
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'run-local',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(unsupportedRuntimeRes.status).toBe(400);
|
||||||
|
const unsupportedRuntimeBody = (await unsupportedRuntimeRes.json()) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
};
|
||||||
|
expect(unsupportedRuntimeBody.error?.message).toContain(
|
||||||
|
'Codex CLI (codex) does not support run-scoped MCP tool bundles',
|
||||||
|
);
|
||||||
|
const messagesRes = await fetch(
|
||||||
|
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
|
||||||
|
);
|
||||||
|
expect(messagesRes.ok).toBe(true);
|
||||||
|
const messagesBody = (await messagesRes.json()) as {
|
||||||
|
messages: Array<{ role: string; content: string }>;
|
||||||
|
};
|
||||||
|
expect(messagesBody.messages.some((msg) => msg.content === 'bad tools')).toBe(false);
|
||||||
|
|
||||||
|
const unsupportedTransportRes = await fetch(`${baseUrl}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agentId: 'hermes',
|
||||||
|
projectId: id,
|
||||||
|
message: 'bad remote tools',
|
||||||
|
toolBundle: {
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'run-remote',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://example.test/mcp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(unsupportedTransportRes.status).toBe(400);
|
||||||
|
const unsupportedTransportBody = (await unsupportedTransportRes.json()) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
};
|
||||||
|
expect(unsupportedTransportBody.error?.message).toContain(
|
||||||
|
'Hermes (hermes) only supports stdio run-scoped MCP servers',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not write .mcp.json for ACP agents (Hermes wires via session args)', async () => {
|
it('does not write .mcp.json for ACP agents (Hermes wires via session args)', async () => {
|
||||||
// ACP agents (Hermes/Kimi) consume the `mcpServers` array via the ACP
|
// ACP agents (Hermes/Kimi) consume the `mcpServers` array via the ACP
|
||||||
// session/new params instead of `.mcp.json`. The `.mcp.json` write path
|
// session/new params instead of `.mcp.json`. The `.mcp.json` write path
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,95 @@ test('conversation latest run follows assistant message position', () => {
|
||||||
assert.equal(getConversation(db, conversationId)?.latestRun?.status, 'running');
|
assert.equal(getConversation(db, conversationId)?.latestRun?.status, 'running');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('conversation summaries expose cumulative completed run duration', () => {
|
||||||
|
const db = createDb();
|
||||||
|
insertProject(db, {
|
||||||
|
id: 'project-duration',
|
||||||
|
name: 'project-duration',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
});
|
||||||
|
insertConversation(db, {
|
||||||
|
id: 'project-duration-conversation',
|
||||||
|
projectId: 'project-duration',
|
||||||
|
title: 'Duration test',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 4,
|
||||||
|
});
|
||||||
|
upsertMessage(db, 'project-duration-conversation', {
|
||||||
|
id: 'project-duration-first',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'first done',
|
||||||
|
runId: 'project-duration-first-run',
|
||||||
|
runStatus: 'succeeded',
|
||||||
|
startedAt: 10_000,
|
||||||
|
endedAt: 40_000,
|
||||||
|
});
|
||||||
|
upsertMessage(db, 'project-duration-conversation', {
|
||||||
|
id: 'project-duration-running',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'still running',
|
||||||
|
runId: 'project-duration-running-run',
|
||||||
|
runStatus: 'running',
|
||||||
|
startedAt: 45_000,
|
||||||
|
});
|
||||||
|
upsertMessage(db, 'project-duration-conversation', {
|
||||||
|
id: 'project-duration-second',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'second done',
|
||||||
|
runId: 'project-duration-second-run',
|
||||||
|
runStatus: 'failed',
|
||||||
|
startedAt: 50_000,
|
||||||
|
endedAt: 125_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listed = listConversations(db, 'project-duration')[0] as { totalDurationMs?: number };
|
||||||
|
const fetched = getConversation(db, 'project-duration-conversation') as { totalDurationMs?: number } | null;
|
||||||
|
|
||||||
|
assert.equal(listed.totalDurationMs, 105_000);
|
||||||
|
assert.equal(fetched?.totalDurationMs, 105_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('conversation summaries include usage-only terminal run durations', () => {
|
||||||
|
const db = createDb();
|
||||||
|
insertProject(db, {
|
||||||
|
id: 'project-usage-duration',
|
||||||
|
name: 'project-usage-duration',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
});
|
||||||
|
insertConversation(db, {
|
||||||
|
id: 'project-usage-duration-conversation',
|
||||||
|
projectId: 'project-usage-duration',
|
||||||
|
title: 'Usage duration test',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 4,
|
||||||
|
});
|
||||||
|
upsertMessage(db, 'project-usage-duration-conversation', {
|
||||||
|
id: 'project-usage-duration-imported',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'imported done',
|
||||||
|
runId: 'project-usage-duration-imported-run',
|
||||||
|
runStatus: 'succeeded',
|
||||||
|
events: [{ kind: 'usage', durationMs: 22_000 }],
|
||||||
|
});
|
||||||
|
upsertMessage(db, 'project-usage-duration-conversation', {
|
||||||
|
id: 'project-usage-duration-timestamped',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'timestamped done',
|
||||||
|
runId: 'project-usage-duration-timestamped-run',
|
||||||
|
runStatus: 'succeeded',
|
||||||
|
startedAt: 30_000,
|
||||||
|
endedAt: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listed = listConversations(db, 'project-usage-duration')[0] as { totalDurationMs?: number };
|
||||||
|
const fetched = getConversation(db, 'project-usage-duration-conversation') as { totalDurationMs?: number } | null;
|
||||||
|
|
||||||
|
assert.equal(listed.totalDurationMs, 52_000);
|
||||||
|
assert.equal(fetched?.totalDurationMs, 52_000);
|
||||||
|
});
|
||||||
|
|
||||||
test('conversation listing batches latest run summaries for large projects', () => {
|
test('conversation listing batches latest run summaries for large projects', () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
insertProject(db, {
|
insertProject(db, {
|
||||||
|
|
|
||||||
231
apps/daemon/tests/run-tool-bundle.test.ts
Normal file
231
apps/daemon/tests/run-tool-bundle.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeRunToolBundleForRun,
|
||||||
|
parseRunToolBundleForRequest,
|
||||||
|
resolveExternalMcpServersForRun,
|
||||||
|
summarizeRunToolBundle,
|
||||||
|
validateRunToolBundleForAgent,
|
||||||
|
} from '../src/run-tool-bundle.js';
|
||||||
|
|
||||||
|
describe('run-scoped tool bundles', () => {
|
||||||
|
it('sanitizes MCP servers onto the run and redacts spawn-only details in summaries', () => {
|
||||||
|
const bundle = normalizeRunToolBundleForRun({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'local-tools',
|
||||||
|
label: 'Local tools',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js', '--token=secret'],
|
||||||
|
env: { API_TOKEN: 'secret' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'remote-tools',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://example.test/mcp',
|
||||||
|
headers: { Authorization: 'Bearer secret' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '../bad',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bundle.mcpServers).toHaveLength(2);
|
||||||
|
expect(bundle.mcpServers[0]).toMatchObject({
|
||||||
|
id: 'local-tools',
|
||||||
|
command: 'node',
|
||||||
|
env: { API_TOKEN: 'secret' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = summarizeRunToolBundle(bundle);
|
||||||
|
expect(summary).toEqual({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'local-tools',
|
||||||
|
label: 'Local tools',
|
||||||
|
transport: 'stdio',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'remote-tools',
|
||||||
|
transport: 'http',
|
||||||
|
enabled: true,
|
||||||
|
authMode: 'oauth',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(summary)).not.toContain('secret');
|
||||||
|
expect(JSON.stringify(summary)).not.toContain('server.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses only run-scoped MCP servers in sandbox mode', () => {
|
||||||
|
const persistedServers = normalizeRunToolBundleForRun({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'persisted',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://persisted.example.test/mcp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).mcpServers;
|
||||||
|
const runScopedServers = normalizeRunToolBundleForRun({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'run-only',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: ['run-tool.js'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).mcpServers;
|
||||||
|
|
||||||
|
const selection = resolveExternalMcpServersForRun({
|
||||||
|
persistedServers,
|
||||||
|
runScopedServers,
|
||||||
|
sandboxMode: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selection.enabledServers.map((server) => server.id)).toEqual(['run-only']);
|
||||||
|
expect([...selection.persistedTokenServerIds]).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed run-scoped MCP server entries for request payloads', () => {
|
||||||
|
expect(parseRunToolBundleForRequest('bad')).toEqual({
|
||||||
|
ok: false,
|
||||||
|
message: 'toolBundle must be an object',
|
||||||
|
});
|
||||||
|
expect(parseRunToolBundleForRequest({ mcpServers: 'bad' })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
message: 'toolBundle.mcpServers must be an array',
|
||||||
|
});
|
||||||
|
expect(parseRunToolBundleForRequest({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'missing-command',
|
||||||
|
transport: 'stdio',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})).toEqual({
|
||||||
|
ok: false,
|
||||||
|
message: 'toolBundle.mcpServers[0] is invalid',
|
||||||
|
});
|
||||||
|
expect(parseRunToolBundleForRequest({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'dup',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dup',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://example.test/mcp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})).toEqual({
|
||||||
|
ok: false,
|
||||||
|
message: 'toolBundle.mcpServers[1] duplicates server id "dup"',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a run-scoped server override persisted config without inheriting persisted tokens', () => {
|
||||||
|
const persistedServers = normalizeRunToolBundleForRun({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'shared',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://persisted.example.test/mcp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'persisted-only',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://persisted-only.example.test/mcp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).mcpServers;
|
||||||
|
const runScopedServers = normalizeRunToolBundleForRun({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'shared',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://run.example.test/mcp',
|
||||||
|
headers: { Authorization: 'Bearer run-token' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).mcpServers;
|
||||||
|
|
||||||
|
const selection = resolveExternalMcpServersForRun({
|
||||||
|
persistedServers,
|
||||||
|
runScopedServers,
|
||||||
|
sandboxMode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selection.enabledServers).toHaveLength(2);
|
||||||
|
expect(selection.enabledServers.find((server) => server.id === 'shared')).toMatchObject({
|
||||||
|
url: 'https://run.example.test/mcp',
|
||||||
|
});
|
||||||
|
expect([...selection.persistedTokenServerIds]).toEqual(['persisted-only']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects bundles for runtimes that cannot receive the requested servers', () => {
|
||||||
|
const stdioOnly = normalizeRunToolBundleForRun({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'local',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const remote = normalizeRunToolBundleForRun({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'remote',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://example.test/mcp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validateRunToolBundleForAgent(stdioOnly, {
|
||||||
|
id: 'codex',
|
||||||
|
name: 'Codex CLI',
|
||||||
|
})).toEqual({
|
||||||
|
ok: false,
|
||||||
|
message: 'Codex CLI (codex) does not support run-scoped MCP tool bundles',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validateRunToolBundleForAgent(remote, {
|
||||||
|
id: 'hermes',
|
||||||
|
name: 'Hermes',
|
||||||
|
externalMcpInjection: 'acp-merge',
|
||||||
|
})).toEqual({
|
||||||
|
ok: false,
|
||||||
|
message:
|
||||||
|
'toolBundle.mcpServers[0] uses http transport, but Hermes (hermes) only supports stdio run-scoped MCP servers',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validateRunToolBundleForAgent(remote, {
|
||||||
|
id: 'claude',
|
||||||
|
name: 'Claude Code',
|
||||||
|
externalMcpInjection: 'claude-mcp-json',
|
||||||
|
})).toEqual({ ok: true });
|
||||||
|
|
||||||
|
expect(validateRunToolBundleForAgent(remote, {
|
||||||
|
id: 'claude',
|
||||||
|
name: 'Claude Code',
|
||||||
|
externalMcpInjection: 'claude-mcp-json',
|
||||||
|
}, {
|
||||||
|
deliveryTarget: 'external-project',
|
||||||
|
})).toEqual({
|
||||||
|
ok: false,
|
||||||
|
message:
|
||||||
|
'Claude Code (claude) receives run-scoped MCP tool bundles through project .mcp.json, ' +
|
||||||
|
'so toolBundle requires a daemon-managed project',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -80,6 +80,45 @@ describe('chat run service shutdown', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores a run-scoped tool bundle and returns a redacted status summary', () => {
|
||||||
|
const runs = createRuns();
|
||||||
|
const run = runs.create({
|
||||||
|
projectId: 'project-1',
|
||||||
|
conversationId: 'conv-a',
|
||||||
|
toolBundle: {
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'run-tools',
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js', '--token=secret'],
|
||||||
|
env: { API_TOKEN: 'secret' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
expect(run.toolBundle.mcpServers).toHaveLength(1);
|
||||||
|
expect(run.toolBundle.mcpServers[0]).toMatchObject({
|
||||||
|
id: 'run-tools',
|
||||||
|
command: 'node',
|
||||||
|
env: { API_TOKEN: 'secret' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = runs.statusBody(run);
|
||||||
|
expect(status.toolBundle).toEqual({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'run-tools',
|
||||||
|
transport: 'stdio',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(status)).not.toContain('secret');
|
||||||
|
expect(JSON.stringify(status)).not.toContain('server.js');
|
||||||
|
});
|
||||||
|
|
||||||
it('cancels active runs and terminates their child process during daemon shutdown', async () => {
|
it('cancels active runs and terminates their child process during daemon shutdown', async () => {
|
||||||
const runs = createRuns();
|
const runs = createRuns();
|
||||||
const child = new FakeChildProcess({ closeOn: 'SIGTERM' });
|
const child = new FakeChildProcess({ closeOn: 'SIGTERM' });
|
||||||
|
|
|
||||||
|
|
@ -957,6 +957,22 @@ test('spawnEnvForAgent strips ANTHROPIC_API_KEY case-insensitively for the claud
|
||||||
assert.equal(env.PATH, '/usr/bin');
|
assert.equal(env.PATH, '/usr/bin');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY when claude resolves to OpenClaude fallback', () => {
|
||||||
|
const env = spawnEnvForAgent(
|
||||||
|
'claude',
|
||||||
|
{
|
||||||
|
ANTHROPIC_API_KEY: 'sk-openclaude',
|
||||||
|
PATH: '/usr/bin',
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{ resolvedBin: '/tools/openclaude' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(env.ANTHROPIC_API_KEY, 'sk-openclaude');
|
||||||
|
assert.equal(env.PATH, '/usr/bin');
|
||||||
|
});
|
||||||
|
|
||||||
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY for non-claude adapters', () => {
|
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY for non-claude adapters', () => {
|
||||||
for (const agentId of ['codex', 'gemini', 'opencode', 'devin']) {
|
for (const agentId of ['codex', 'gemini', 'opencode', 'devin']) {
|
||||||
const env = spawnEnvForAgent(agentId, {
|
const env = spawnEnvForAgent(agentId, {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
chmodSync,
|
chmodSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
|
|
@ -373,6 +374,47 @@ describe('chooseExecutableByMinVersion (#978: skip stale binaries that fail the
|
||||||
rmSync(newDir, { recursive: true, force: true });
|
rmSync(newDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fsTest('cached pick replaced in place with an older binary is re-probed instead of returned stale', async () => {
|
||||||
|
const cachedDir = mkdtempSync(join(tmpdir(), 'od-cache-replace-cached-'));
|
||||||
|
const fallbackDir = mkdtempSync(join(tmpdir(), 'od-cache-replace-fallback-'));
|
||||||
|
try {
|
||||||
|
const cachedGemini = join(cachedDir, 'gemini');
|
||||||
|
const fallbackGemini = join(fallbackDir, 'gemini');
|
||||||
|
writeFileSync(cachedGemini, '0.40.1\n');
|
||||||
|
writeFileSync(fallbackGemini, '0.41.0\n');
|
||||||
|
chmodSync(cachedGemini, 0o755);
|
||||||
|
chmodSync(fallbackGemini, 0o755);
|
||||||
|
process.env.OD_AGENT_HOME = cachedDir;
|
||||||
|
process.env.PATH = `${cachedDir}${delimiter}${fallbackDir}`;
|
||||||
|
|
||||||
|
const def = minimalAgentDef({ id: 'gemini', bin: 'gemini', minVersion: '0.30.0' });
|
||||||
|
let probes = 0;
|
||||||
|
const runVersion = async (p: string) => {
|
||||||
|
probes += 1;
|
||||||
|
return readFileSync(p, 'utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstChosen = await chooseExecutableByMinVersion(def, {}, { runVersion });
|
||||||
|
expect(firstChosen).toBe(cachedGemini);
|
||||||
|
const probesAfterCold = probes;
|
||||||
|
expect(probesAfterCold).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// Simulate a package-manager rewrite at the same visible path:
|
||||||
|
// the path still exists, but it now points at an older build that
|
||||||
|
// should not keep bypassing the min-version gate until daemon restart.
|
||||||
|
rmSync(cachedGemini);
|
||||||
|
writeFileSync(cachedGemini, '0.1.12 downgraded in-place\n');
|
||||||
|
chmodSync(cachedGemini, 0o755);
|
||||||
|
|
||||||
|
const secondChosen = await chooseExecutableByMinVersion(def, {}, { runVersion });
|
||||||
|
expect(secondChosen).toBe(fallbackGemini);
|
||||||
|
expect(probes).toBeGreaterThan(probesAfterCold);
|
||||||
|
} finally {
|
||||||
|
rmSync(cachedDir, { recursive: true, force: true });
|
||||||
|
rmSync(fallbackDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('inspectAgentExecutableResolution + minVersion cache wiring (#978)', () => {
|
describe('inspectAgentExecutableResolution + minVersion cache wiring (#978)', () => {
|
||||||
|
|
|
||||||
|
|
@ -2658,6 +2658,11 @@ function ToolsPluginsPanel({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="composer-tools-row-main"
|
className="composer-tools-row-main"
|
||||||
|
// Match the @-mention popover: prevent the textarea from
|
||||||
|
// losing focus before the click handler runs so
|
||||||
|
// selectionStart isn't reset to 0 and the inserted token
|
||||||
|
// lands at the user's actual cursor position (#3195).
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setPendingId(p.id);
|
setPendingId(p.id);
|
||||||
try {
|
try {
|
||||||
|
|
@ -2749,6 +2754,10 @@ function ToolsMcpPanel({
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className="composer-tools-row"
|
className="composer-tools-row"
|
||||||
|
// Match the @-mention popover: prevent the textarea from
|
||||||
|
// losing focus before the click handler runs so
|
||||||
|
// selectionStart isn't reset to 0 (#3195).
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => onInsert(s.id)}
|
onClick={() => onInsert(s.id)}
|
||||||
title={`Insert a hint that nudges the model to use ${s.label || s.id}`}
|
title={`Insert a hint that nudges the model to use ${s.label || s.id}`}
|
||||||
>
|
>
|
||||||
|
|
@ -2839,6 +2848,10 @@ function ToolsSkillsPanel({
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className={`composer-tools-row${active ? ' active' : ''}`}
|
className={`composer-tools-row${active ? ' active' : ''}`}
|
||||||
|
// Match the @-mention popover: prevent the textarea from
|
||||||
|
// losing focus before the click handler runs so
|
||||||
|
// selectionStart isn't reset to 0 (#3195).
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setPendingId(skill.id);
|
setPendingId(skill.id);
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2014,6 +2014,16 @@ export function conversationMetaLabel(
|
||||||
t: TranslateFn,
|
t: TranslateFn,
|
||||||
): string {
|
): string {
|
||||||
const latestRun = conversation.latestRun;
|
const latestRun = conversation.latestRun;
|
||||||
|
if (
|
||||||
|
latestRun &&
|
||||||
|
(latestRun.status === 'succeeded' ||
|
||||||
|
latestRun.status === 'failed' ||
|
||||||
|
latestRun.status === 'canceled') &&
|
||||||
|
typeof conversation.totalDurationMs === 'number' &&
|
||||||
|
Number.isFinite(conversation.totalDurationMs)
|
||||||
|
) {
|
||||||
|
return formatDurationShort(conversation.totalDurationMs);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
latestRun &&
|
latestRun &&
|
||||||
(latestRun.status === 'succeeded' ||
|
(latestRun.status === 'succeeded' ||
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,14 @@ export type ManualEditPendingStyleSave = {
|
||||||
};
|
};
|
||||||
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
|
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
|
||||||
type PreviewCanvasSize = { width: number; height: number };
|
type PreviewCanvasSize = { width: number; height: number };
|
||||||
|
type CommentPreviewCanvasOptions = {
|
||||||
|
boardMode: boolean;
|
||||||
|
sidePanelCollapsed: boolean;
|
||||||
|
viewport?: PreviewViewportId;
|
||||||
|
};
|
||||||
|
type PreviewScaleOptions = {
|
||||||
|
canvasPadding?: number;
|
||||||
|
};
|
||||||
type PreviewViewportPreset = {
|
type PreviewViewportPreset = {
|
||||||
id: PreviewViewportId;
|
id: PreviewViewportId;
|
||||||
width: number | null;
|
width: number | null;
|
||||||
|
|
@ -214,6 +222,18 @@ const PREVIEW_VIEWPORT_PRESETS: PreviewViewportPreset[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const EXPORT_READY_NUDGE_STORAGE_PREFIX = 'open-design:export-ready-nudge:';
|
const EXPORT_READY_NUDGE_STORAGE_PREFIX = 'open-design:export-ready-nudge:';
|
||||||
|
const COMMENT_SIDE_DOCK_WIDTH = 320;
|
||||||
|
const COMMENT_SIDE_DOCK_RAIL_WIDTH = 42;
|
||||||
|
const COMMENT_SIDE_DOCK_GAP = 12;
|
||||||
|
const COMMENT_SIDE_DOCK_PADDING = 8;
|
||||||
|
const COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING = 24;
|
||||||
|
const COMMENT_SIDE_DOCK_MIN_CANVAS_WIDTH = 280;
|
||||||
|
const COMMENT_SIDE_DOCK_STACKED_PANEL_HEIGHT = 220;
|
||||||
|
const COMMENT_SIDE_DOCK_STACKED_RAIL_HEIGHT = 48;
|
||||||
|
const COMMENT_SIDE_DOCK_STACKED_HEIGHT_DEDUCTION =
|
||||||
|
(COMMENT_SIDE_DOCK_PADDING * 2) + COMMENT_SIDE_DOCK_GAP + COMMENT_SIDE_DOCK_STACKED_PANEL_HEIGHT;
|
||||||
|
const COMMENT_SIDE_DOCK_STACKED_COLLAPSED_HEIGHT_DEDUCTION =
|
||||||
|
(COMMENT_SIDE_DOCK_PADDING * 2) + COMMENT_SIDE_DOCK_GAP + COMMENT_SIDE_DOCK_STACKED_RAIL_HEIGHT;
|
||||||
|
|
||||||
// The five basic style facets the inspect panel exposes. Kept narrow on
|
// The five basic style facets the inspect panel exposes. Kept narrow on
|
||||||
// purpose — open-slide's design tokens panel only edits global tokens, so
|
// purpose — open-slide's design tokens panel only edits global tokens, so
|
||||||
|
|
@ -500,10 +520,11 @@ function previewViewportStyle(
|
||||||
viewport: PreviewViewportId,
|
viewport: PreviewViewportId,
|
||||||
previewScale = 1,
|
previewScale = 1,
|
||||||
canvasSize?: PreviewCanvasSize,
|
canvasSize?: PreviewCanvasSize,
|
||||||
|
options?: PreviewScaleOptions,
|
||||||
): CSSProperties & Record<string, string | number> {
|
): CSSProperties & Record<string, string | number> {
|
||||||
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport) ?? PREVIEW_VIEWPORT_PRESETS[0]!;
|
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport) ?? PREVIEW_VIEWPORT_PRESETS[0]!;
|
||||||
if (!preset.width) return {};
|
if (!preset.width) return {};
|
||||||
const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize);
|
const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize, options);
|
||||||
return {
|
return {
|
||||||
'--preview-viewport-width': `${preset.width}px`,
|
'--preview-viewport-width': `${preset.width}px`,
|
||||||
'--preview-viewport-height': `${preset.height}px`,
|
'--preview-viewport-height': `${preset.height}px`,
|
||||||
|
|
@ -512,15 +533,54 @@ function previewViewportStyle(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function commentPreviewCanvasSize(
|
||||||
|
canvasSize: PreviewCanvasSize | undefined,
|
||||||
|
options: CommentPreviewCanvasOptions,
|
||||||
|
): PreviewCanvasSize | undefined {
|
||||||
|
if (!canvasSize || !options.boardMode) return canvasSize;
|
||||||
|
const dockPadding = options.viewport && options.viewport !== 'desktop'
|
||||||
|
? COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING
|
||||||
|
: COMMENT_SIDE_DOCK_PADDING;
|
||||||
|
const sideDockWidth = options.sidePanelCollapsed ? COMMENT_SIDE_DOCK_RAIL_WIDTH : COMMENT_SIDE_DOCK_WIDTH;
|
||||||
|
const dockedWidth = canvasSize.width - (dockPadding * 2) - COMMENT_SIDE_DOCK_GAP - sideDockWidth;
|
||||||
|
if (usesStackedCommentSideDock(canvasSize, options)) {
|
||||||
|
const stackedHeightDeduction = options.sidePanelCollapsed
|
||||||
|
? COMMENT_SIDE_DOCK_STACKED_COLLAPSED_HEIGHT_DEDUCTION
|
||||||
|
: COMMENT_SIDE_DOCK_STACKED_HEIGHT_DEDUCTION;
|
||||||
|
return {
|
||||||
|
width: Math.max(1, canvasSize.width - (COMMENT_SIDE_DOCK_PADDING * 2)),
|
||||||
|
height: Math.max(1, canvasSize.height - stackedHeightDeduction),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
width: Math.max(1, dockedWidth),
|
||||||
|
height: Math.max(1, canvasSize.height - (dockPadding * 2)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function usesStackedCommentSideDock(
|
||||||
|
canvasSize: PreviewCanvasSize | undefined,
|
||||||
|
options: CommentPreviewCanvasOptions,
|
||||||
|
) {
|
||||||
|
if (!canvasSize || !options.boardMode) return false;
|
||||||
|
const dockPadding = options.viewport && options.viewport !== 'desktop'
|
||||||
|
? COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING
|
||||||
|
: COMMENT_SIDE_DOCK_PADDING;
|
||||||
|
const sideDockWidth = options.sidePanelCollapsed ? COMMENT_SIDE_DOCK_RAIL_WIDTH : COMMENT_SIDE_DOCK_WIDTH;
|
||||||
|
const dockedWidth = canvasSize.width - (dockPadding * 2) - COMMENT_SIDE_DOCK_GAP - sideDockWidth;
|
||||||
|
return dockedWidth < COMMENT_SIDE_DOCK_MIN_CANVAS_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
export function effectivePreviewScale(
|
export function effectivePreviewScale(
|
||||||
viewport: PreviewViewportId,
|
viewport: PreviewViewportId,
|
||||||
previewScale: number,
|
previewScale: number,
|
||||||
canvasSize?: PreviewCanvasSize,
|
canvasSize?: PreviewCanvasSize,
|
||||||
|
options?: PreviewScaleOptions,
|
||||||
) {
|
) {
|
||||||
if (viewport === 'desktop') return previewScale;
|
if (viewport === 'desktop') return previewScale;
|
||||||
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport);
|
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport);
|
||||||
if (!preset?.width || !preset.height || !canvasSize?.width || !canvasSize.height) return previewScale;
|
if (!preset?.width || !preset.height || !canvasSize?.width || !canvasSize.height) return previewScale;
|
||||||
const canvasPadding = 48;
|
const canvasPadding = options?.canvasPadding ?? 48;
|
||||||
const availableWidth = Math.max(1, canvasSize.width - canvasPadding);
|
const availableWidth = Math.max(1, canvasSize.width - canvasPadding);
|
||||||
const availableHeight = Math.max(1, canvasSize.height - canvasPadding);
|
const availableHeight = Math.max(1, canvasSize.height - canvasPadding);
|
||||||
const fitScale = Math.min(1, availableWidth / preset.width, availableHeight / preset.height);
|
const fitScale = Math.min(1, availableWidth / preset.width, availableHeight / preset.height);
|
||||||
|
|
@ -2086,7 +2146,6 @@ export function CommentSidePanel({
|
||||||
activeCommentId,
|
activeCommentId,
|
||||||
collapsed,
|
collapsed,
|
||||||
onCollapsedChange,
|
onCollapsedChange,
|
||||||
onClose,
|
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
onSelectAll,
|
onSelectAll,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
|
|
@ -2102,7 +2161,6 @@ export function CommentSidePanel({
|
||||||
activeCommentId: string | null;
|
activeCommentId: string | null;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onCollapsedChange: (collapsed: boolean) => void;
|
onCollapsedChange: (collapsed: boolean) => void;
|
||||||
onClose: () => void;
|
|
||||||
onToggleSelect: (commentId: string) => void;
|
onToggleSelect: (commentId: string) => void;
|
||||||
onSelectAll: () => void;
|
onSelectAll: () => void;
|
||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
|
|
@ -2119,21 +2177,48 @@ export function CommentSidePanel({
|
||||||
const selectedCount = visibleSelectedIds.size;
|
const selectedCount = visibleSelectedIds.size;
|
||||||
const allSelected = comments.length > 0 && selectedCount === comments.length;
|
const allSelected = comments.length > 0 && selectedCount === comments.length;
|
||||||
const commentsLabel = t('chat.tabComments');
|
const commentsLabel = t('chat.tabComments');
|
||||||
|
const collapsedRailRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const expandedToggleRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const pendingToggleFocusRef = useRef<'collapsed' | 'expanded' | null>(null);
|
||||||
|
const panelId = useId();
|
||||||
const canCreateComment = Boolean(onCreateComment) && newCommentDraft.trim().length > 0 && !sending;
|
const canCreateComment = Boolean(onCreateComment) && newCommentDraft.trim().length > 0 && !sending;
|
||||||
const submitNewComment = async () => {
|
const submitNewComment = async () => {
|
||||||
if (!onCreateComment || !newCommentDraft.trim()) return;
|
if (!onCreateComment || !newCommentDraft.trim()) return;
|
||||||
const saved = await onCreateComment(newCommentDraft.trim());
|
const saved = await onCreateComment(newCommentDraft.trim());
|
||||||
if (saved) setNewCommentDraft('');
|
if (saved) setNewCommentDraft('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const target =
|
||||||
|
pendingToggleFocusRef.current === 'collapsed'
|
||||||
|
? collapsedRailRef.current
|
||||||
|
: pendingToggleFocusRef.current === 'expanded'
|
||||||
|
? expandedToggleRef.current
|
||||||
|
: null;
|
||||||
|
if (!target) return;
|
||||||
|
pendingToggleFocusRef.current = null;
|
||||||
|
target.focus();
|
||||||
|
}, [collapsed]);
|
||||||
|
|
||||||
|
const handleCollapsedChange = (
|
||||||
|
nextCollapsed: boolean,
|
||||||
|
nextFocusTarget: 'collapsed' | 'expanded',
|
||||||
|
) => {
|
||||||
|
pendingToggleFocusRef.current = nextFocusTarget;
|
||||||
|
onCollapsedChange(nextCollapsed);
|
||||||
|
};
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
ref={collapsedRailRef}
|
||||||
type="button"
|
type="button"
|
||||||
className="comment-side-rail"
|
className="comment-side-rail"
|
||||||
data-testid="comment-side-collapsed-rail"
|
data-testid="comment-side-collapsed-rail"
|
||||||
aria-label={t('preview.showSidebar', { label: commentsLabel })}
|
aria-label={t('preview.showSidebar', { label: commentsLabel })}
|
||||||
|
aria-expanded={false}
|
||||||
title={t('preview.showSidebar', { label: commentsLabel })}
|
title={t('preview.showSidebar', { label: commentsLabel })}
|
||||||
onClick={() => onCollapsedChange(false)}
|
onClick={() => handleCollapsedChange(false, 'expanded')}
|
||||||
>
|
>
|
||||||
<RemixIcon name="message-3-line" size={15} />
|
<RemixIcon name="message-3-line" size={15} />
|
||||||
<span>{commentsLabel}</span>
|
<span>{commentsLabel}</span>
|
||||||
|
|
@ -2143,7 +2228,7 @@ export function CommentSidePanel({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
|
<aside id={panelId} className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
|
||||||
<div className="comment-side-header">
|
<div className="comment-side-header">
|
||||||
<div className="comment-side-title">
|
<div className="comment-side-title">
|
||||||
<RemixIcon name="message-3-line" size={15} />
|
<RemixIcon name="message-3-line" size={15} />
|
||||||
|
|
@ -2160,15 +2245,18 @@ export function CommentSidePanel({
|
||||||
{t('chat.comments.selectAll')}
|
{t('chat.comments.selectAll')}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
ref={expandedToggleRef}
|
||||||
className="comment-side-close"
|
type="button"
|
||||||
aria-label={t('common.close')}
|
className="comment-side-collapse"
|
||||||
title={t('common.close')}
|
aria-label={t('preview.hideSidebar', { label: commentsLabel })}
|
||||||
onClick={onClose}
|
aria-controls={panelId}
|
||||||
>
|
aria-expanded={true}
|
||||||
<Icon name="close" size={12} />
|
title={t('preview.hideSidebar', { label: commentsLabel })}
|
||||||
</button>
|
onClick={() => handleCollapsedChange(true, 'collapsed')}
|
||||||
|
>
|
||||||
|
<Icon name="chevron-right" size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="comment-side-list">
|
<div className="comment-side-list">
|
||||||
|
|
@ -2299,6 +2387,62 @@ export function CommentSidePanel({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CommentSideDock({
|
||||||
|
comments,
|
||||||
|
selectedIds,
|
||||||
|
activeCommentId,
|
||||||
|
collapsed,
|
||||||
|
onCollapsedChange,
|
||||||
|
onToggleSelect,
|
||||||
|
onSelectAll,
|
||||||
|
onClearSelection,
|
||||||
|
onReply,
|
||||||
|
onSendSelected,
|
||||||
|
onCreateComment,
|
||||||
|
sending,
|
||||||
|
t,
|
||||||
|
composer,
|
||||||
|
}: {
|
||||||
|
comments: PreviewComment[];
|
||||||
|
selectedIds: Set<string>;
|
||||||
|
activeCommentId: string | null;
|
||||||
|
collapsed: boolean;
|
||||||
|
onCollapsedChange: (collapsed: boolean) => void;
|
||||||
|
onToggleSelect: (commentId: string) => void;
|
||||||
|
onSelectAll: () => void;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
onReply: (comment: PreviewComment) => void;
|
||||||
|
onSendSelected: () => void | Promise<void>;
|
||||||
|
onCreateComment?: (note: string) => boolean | Promise<boolean>;
|
||||||
|
sending: boolean;
|
||||||
|
t: TranslateFn;
|
||||||
|
composer?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`comment-side-dock${collapsed ? ' collapsed' : ''}`}
|
||||||
|
data-testid="comment-side-dock"
|
||||||
|
>
|
||||||
|
<CommentSidePanel
|
||||||
|
comments={comments}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
activeCommentId={activeCommentId}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onCollapsedChange={onCollapsedChange}
|
||||||
|
onToggleSelect={onToggleSelect}
|
||||||
|
onSelectAll={onSelectAll}
|
||||||
|
onClearSelection={onClearSelection}
|
||||||
|
onReply={onReply}
|
||||||
|
onSendSelected={onSendSelected}
|
||||||
|
onCreateComment={onCreateComment}
|
||||||
|
sending={sending}
|
||||||
|
t={t}
|
||||||
|
composer={composer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Maps a CSS computed value (e.g. "rgb(40, 50, 60)" or "16px") to a form
|
// Maps a CSS computed value (e.g. "rgb(40, 50, 60)" or "16px") to a form
|
||||||
// input value. Browsers return colors as rgb()/rgba(); HTML <input type=color>
|
// input value. Browsers return colors as rgb()/rgba(); HTML <input type=color>
|
||||||
// only accepts "#rrggbb". Lengths come back as "12px" or "0px"; we strip
|
// only accepts "#rrggbb". Lengths come back as "12px" or "0px"; we strip
|
||||||
|
|
@ -4309,6 +4453,17 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
|
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
|
||||||
const previewStateKey = `${projectId}:${file.name}`;
|
const previewStateKey = `${projectId}:${file.name}`;
|
||||||
const previewScale = zoom / 100;
|
const previewScale = zoom / 100;
|
||||||
|
const localCommentSideDockActive = commentPanelOpen && !commentPortalHost;
|
||||||
|
const boardPreviewCanvasSize = commentPreviewCanvasSize(previewBodySize, {
|
||||||
|
boardMode: localCommentSideDockActive,
|
||||||
|
sidePanelCollapsed: commentSidePanelCollapsed,
|
||||||
|
viewport: previewViewport,
|
||||||
|
});
|
||||||
|
const boardSideDockStacked = usesStackedCommentSideDock(previewBodySize, {
|
||||||
|
boardMode: localCommentSideDockActive,
|
||||||
|
sidePanelCollapsed: commentSidePanelCollapsed,
|
||||||
|
viewport: previewViewport,
|
||||||
|
});
|
||||||
|
|
||||||
function deploymentMapForCurrentFile(items: WebDeploymentInfo[]) {
|
function deploymentMapForCurrentFile(items: WebDeploymentInfo[]) {
|
||||||
const next: Partial<Record<WebDeployProviderId, WebDeploymentInfo>> = {};
|
const next: Partial<Record<WebDeployProviderId, WebDeploymentInfo>> = {};
|
||||||
|
|
@ -4432,8 +4587,18 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
const [slideState, setSlideState] = useState<SlideState | null>(
|
const [slideState, setSlideState] = useState<SlideState | null>(
|
||||||
() => htmlPreviewSlideState.get(previewStateKey) ?? null,
|
() => htmlPreviewSlideState.get(previewStateKey) ?? null,
|
||||||
);
|
);
|
||||||
const overlayPreviewTransform = previewOverlayTransform(previewViewport, previewScale, previewBodySize);
|
const boardPreviewScaleOptions = localCommentSideDockActive ? { canvasPadding: 0 } : undefined;
|
||||||
const overlayPreviewScale = overlayPreviewTransform.scale;
|
const overlayPreviewScale = effectivePreviewScale(
|
||||||
|
previewViewport,
|
||||||
|
previewScale,
|
||||||
|
boardPreviewCanvasSize,
|
||||||
|
boardPreviewScaleOptions,
|
||||||
|
);
|
||||||
|
const overlayPreviewTransform: PreviewOverlayTransform = {
|
||||||
|
scale: overlayPreviewScale,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
};
|
||||||
const shareRef = useRef<HTMLDivElement | null>(null);
|
const shareRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
|
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -6479,6 +6644,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
};
|
};
|
||||||
const boardAvailable = mode === 'preview' && source !== null;
|
const boardAvailable = mode === 'preview' && source !== null;
|
||||||
const showPreviewToolbarControls = mode === 'preview';
|
const showPreviewToolbarControls = mode === 'preview';
|
||||||
|
const commentPreviewLayoutClass = [
|
||||||
|
'comment-preview-layer',
|
||||||
|
localCommentSideDockActive ? 'comment-preview-layer-with-side-dock' : '',
|
||||||
|
localCommentSideDockActive && commentSidePanelCollapsed ? 'comment-preview-layer-dock-collapsed' : '',
|
||||||
|
boardSideDockStacked ? 'comment-preview-layer-side-dock-stacked' : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
const manualEditPanel = manualEditMode ? (
|
const manualEditPanel = manualEditMode ? (
|
||||||
<ManualEditPanel
|
<ManualEditPanel
|
||||||
targets={manualEditTargets}
|
targets={manualEditTargets}
|
||||||
|
|
@ -6588,19 +6759,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
const commentSidePanel = commentPanelOpen ? (
|
const commentSidePanel = commentPanelOpen ? (
|
||||||
<CommentSidePanel
|
<CommentSideDock
|
||||||
comments={visibleSideComments}
|
comments={visibleSideComments}
|
||||||
selectedIds={selectedSideCommentIds}
|
selectedIds={selectedSideCommentIds}
|
||||||
activeCommentId={activeSideCommentId}
|
activeCommentId={activeSideCommentId}
|
||||||
collapsed={commentPortalHost ? false : commentSidePanelCollapsed}
|
collapsed={commentPortalHost ? false : commentSidePanelCollapsed}
|
||||||
onCollapsedChange={setCommentSidePanelCollapsed}
|
onCollapsedChange={setCommentSidePanelCollapsed}
|
||||||
onClose={() => {
|
|
||||||
setCommentPanelOpen(false);
|
|
||||||
setCommentSidePanelCollapsed(false);
|
|
||||||
setCommentCreateMode(false);
|
|
||||||
setBoardMode(false);
|
|
||||||
clearBoardComposer();
|
|
||||||
}}
|
|
||||||
onToggleSelect={(commentId) => {
|
onToggleSelect={(commentId) => {
|
||||||
setSelectedSideCommentIds((current) => {
|
setSelectedSideCommentIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
|
|
@ -7102,201 +7266,258 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||||||
) : mode === 'preview' ? (
|
) : mode === 'preview' ? (
|
||||||
<div
|
<div
|
||||||
className={manualEditMode
|
className={`${manualEditMode ? 'manual-edit-workspace' : commentPreviewLayoutClass} preview-viewport preview-viewport-${previewViewport}`}
|
||||||
? `manual-edit-workspace preview-viewport preview-viewport-${previewViewport}`
|
data-testid={manualEditMode ? undefined : 'comment-preview-layout'}
|
||||||
: [
|
style={previewViewportStyle(previewViewport, previewScale, boardPreviewCanvasSize, boardPreviewScaleOptions)}
|
||||||
'comment-preview-layer',
|
|
||||||
`preview-viewport preview-viewport-${previewViewport}`,
|
|
||||||
].filter(Boolean).join(' ')}
|
|
||||||
style={previewViewportStyle(previewViewport, previewScale, previewBodySize)}
|
|
||||||
>
|
>
|
||||||
{manualEditPanel}
|
{manualEditPanel}
|
||||||
<div className={manualEditMode ? 'manual-edit-canvas' : 'comment-frame-clip'}>
|
<div
|
||||||
<div
|
className={manualEditMode ? 'manual-edit-canvas' : 'comment-preview-canvas'}
|
||||||
style={
|
data-testid={manualEditMode ? undefined : 'comment-preview-canvas'}
|
||||||
manualEditMode
|
>
|
||||||
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
|
<div className={manualEditMode ? undefined : 'comment-frame-clip'}>
|
||||||
: previewScaleShellStyle(previewViewport, previewScale)
|
<div
|
||||||
}
|
style={
|
||||||
>
|
manualEditMode
|
||||||
<PreviewDrawOverlay
|
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
|
||||||
active={drawOverlayOpen}
|
: previewScaleShellStyle(previewViewport, previewScale)
|
||||||
onActiveChange={setDrawOverlayOpen}
|
}
|
||||||
captureTarget={null}
|
|
||||||
filePath={file.name}
|
|
||||||
sendDisabled={streaming}
|
|
||||||
sendDisabledReason={t('chat.annotationSendDisabledReason')}
|
|
||||||
>
|
>
|
||||||
<div className="artifact-preview-transport-stack">
|
<PreviewDrawOverlay
|
||||||
{OD_PREVIEW_KEEP_ALIVE ? (
|
active={drawOverlayOpen}
|
||||||
<PooledIframe
|
onActiveChange={setDrawOverlayOpen}
|
||||||
ref={urlPreviewIframeRef}
|
captureTarget={null}
|
||||||
cacheKey={urlPreviewKeepAliveKey}
|
filePath={file.name}
|
||||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
sendDisabled={streaming}
|
||||||
data-od-render-mode="url-load"
|
sendDisabledReason={t('chat.annotationSendDisabledReason')}
|
||||||
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
>
|
||||||
aria-hidden={useUrlLoadPreview ? undefined : true}
|
<div className="artifact-preview-transport-stack">
|
||||||
tabIndex={useUrlLoadPreview ? 0 : -1}
|
{OD_PREVIEW_KEEP_ALIVE ? (
|
||||||
title={file.name}
|
<PooledIframe
|
||||||
sandbox="allow-scripts allow-downloads"
|
ref={urlPreviewIframeRef}
|
||||||
src={urlTransportSrc}
|
cacheKey={urlPreviewKeepAliveKey}
|
||||||
onLoad={() => {
|
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
||||||
const frame = urlPreviewIframeRef.current;
|
data-od-render-mode="url-load"
|
||||||
if (useUrlLoadPreview) iframeRef.current = frame;
|
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
||||||
dcViewportRestoreAtRef.current = Date.now();
|
aria-hidden={useUrlLoadPreview ? undefined : true}
|
||||||
frame?.contentWindow?.postMessage({
|
tabIndex={useUrlLoadPreview ? 0 : -1}
|
||||||
type: '__dc_set_viewport',
|
title={file.name}
|
||||||
...dcViewportRef.current,
|
sandbox="allow-scripts allow-downloads"
|
||||||
}, '*');
|
src={urlTransportSrc}
|
||||||
syncBridgeModes(frame);
|
onLoad={() => {
|
||||||
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
const frame = urlPreviewIframeRef.current;
|
||||||
}}
|
if (useUrlLoadPreview) iframeRef.current = frame;
|
||||||
/>
|
dcViewportRestoreAtRef.current = Date.now();
|
||||||
) : (
|
frame?.contentWindow?.postMessage({
|
||||||
|
type: '__dc_set_viewport',
|
||||||
|
...dcViewportRef.current,
|
||||||
|
}, '*');
|
||||||
|
syncBridgeModes(frame);
|
||||||
|
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<iframe
|
||||||
|
ref={urlPreviewIframeRef}
|
||||||
|
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
||||||
|
data-od-render-mode="url-load"
|
||||||
|
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
||||||
|
aria-hidden={useUrlLoadPreview ? undefined : true}
|
||||||
|
tabIndex={useUrlLoadPreview ? 0 : -1}
|
||||||
|
title={file.name}
|
||||||
|
sandbox="allow-scripts allow-downloads"
|
||||||
|
src={urlTransportSrc}
|
||||||
|
onLoad={() => {
|
||||||
|
const frame = urlPreviewIframeRef.current;
|
||||||
|
if (useUrlLoadPreview) iframeRef.current = frame;
|
||||||
|
dcViewportRestoreAtRef.current = Date.now();
|
||||||
|
frame?.contentWindow?.postMessage({
|
||||||
|
type: '__dc_set_viewport',
|
||||||
|
...dcViewportRef.current,
|
||||||
|
}, '*');
|
||||||
|
syncBridgeModes(frame);
|
||||||
|
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<iframe
|
<iframe
|
||||||
ref={urlPreviewIframeRef}
|
key={srcDocTransportResetKey}
|
||||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
ref={srcDocPreviewIframeRef}
|
||||||
data-od-render-mode="url-load"
|
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
|
||||||
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
data-od-render-mode="srcdoc"
|
||||||
aria-hidden={useUrlLoadPreview ? undefined : true}
|
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
|
||||||
tabIndex={useUrlLoadPreview ? 0 : -1}
|
aria-hidden={useUrlLoadPreview ? true : undefined}
|
||||||
|
tabIndex={useUrlLoadPreview ? -1 : 0}
|
||||||
title={file.name}
|
title={file.name}
|
||||||
sandbox="allow-scripts allow-downloads"
|
sandbox="allow-scripts allow-downloads"
|
||||||
src={urlTransportSrc}
|
srcDoc={srcDocTransportContent}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
const frame = urlPreviewIframeRef.current;
|
const frame = srcDocPreviewIframeRef.current;
|
||||||
if (useUrlLoadPreview) iframeRef.current = frame;
|
if (!useUrlLoadPreview) iframeRef.current = frame;
|
||||||
|
// Reset the activation dedupe exactly ONCE per
|
||||||
|
// freshly mounted iframe DOM node, never on the
|
||||||
|
// subsequent load events that the same node
|
||||||
|
// emits during normal srcDoc rendering.
|
||||||
|
//
|
||||||
|
// The iframe's load event fires twice for one
|
||||||
|
// successful activation: once when the lazy
|
||||||
|
// transport shell HTML loads, and again when
|
||||||
|
// our own document.open/write/close inside the
|
||||||
|
// shell finishes. PR #2699 reset the dedupe on
|
||||||
|
// every load so that switching
|
||||||
|
// preview -> source -> preview (which remounts
|
||||||
|
// this iframe as a fresh DOM node) would
|
||||||
|
// re-activate the new shell. But resetting on
|
||||||
|
// every load also re-activated on the SECOND
|
||||||
|
// load of a non-remounted frame, which
|
||||||
|
// re-triggered document.open/write/close, which
|
||||||
|
// re-fired the load event, ad infinitum. The
|
||||||
|
// dedupe ref oscillated between null and the
|
||||||
|
// current srcDoc thousands of times per render
|
||||||
|
// and each iteration restarted every CSS
|
||||||
|
// animation from its `from` keyframe. Designs
|
||||||
|
// using `animation-fill-mode: both` with
|
||||||
|
// `from { opacity: 0 }` stayed at opacity 0
|
||||||
|
// forever and the preview read as blank.
|
||||||
|
// That is issue #2361.
|
||||||
|
//
|
||||||
|
// Tracking the last frame we reset for lets us
|
||||||
|
// keep PR #2699's "remount after Source toggle"
|
||||||
|
// fix while breaking the loop on plain renders.
|
||||||
|
if (frame && srcDocFrameDedupeResetForRef.current !== frame) {
|
||||||
|
srcDocFrameDedupeResetForRef.current = frame;
|
||||||
|
activatedSrcDocTransportHtmlRef.current = null;
|
||||||
|
}
|
||||||
|
if (useLazySrcDocTransport) setSrcDocShellReady(true);
|
||||||
|
activateLoadedSrcDocTransport(frame);
|
||||||
dcViewportRestoreAtRef.current = Date.now();
|
dcViewportRestoreAtRef.current = Date.now();
|
||||||
frame?.contentWindow?.postMessage({
|
frame?.contentWindow?.postMessage({
|
||||||
type: '__dc_set_viewport',
|
type: '__dc_set_viewport',
|
||||||
...dcViewportRef.current,
|
...dcViewportRef.current,
|
||||||
}, '*');
|
}, '*');
|
||||||
|
replayInspectOverridesToIframe(frame);
|
||||||
syncBridgeModes(frame);
|
syncBridgeModes(frame);
|
||||||
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
if (!useUrlLoadPreview) restorePreviewScrollPosition();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
<iframe
|
</PreviewDrawOverlay>
|
||||||
key={srcDocTransportResetKey}
|
</div>
|
||||||
ref={srcDocPreviewIframeRef}
|
|
||||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
|
|
||||||
data-od-render-mode="srcdoc"
|
|
||||||
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
|
|
||||||
aria-hidden={useUrlLoadPreview ? true : undefined}
|
|
||||||
tabIndex={useUrlLoadPreview ? -1 : 0}
|
|
||||||
title={file.name}
|
|
||||||
sandbox="allow-scripts allow-downloads"
|
|
||||||
srcDoc={srcDocTransportContent}
|
|
||||||
onLoad={() => {
|
|
||||||
const frame = srcDocPreviewIframeRef.current;
|
|
||||||
if (!useUrlLoadPreview) iframeRef.current = frame;
|
|
||||||
// Reset the activation dedupe exactly ONCE per
|
|
||||||
// freshly mounted iframe DOM node, never on the
|
|
||||||
// subsequent load events that the same node
|
|
||||||
// emits during normal srcDoc rendering.
|
|
||||||
//
|
|
||||||
// The iframe's load event fires twice for one
|
|
||||||
// successful activation: once when the lazy
|
|
||||||
// transport shell HTML loads, and again when
|
|
||||||
// our own document.open/write/close inside the
|
|
||||||
// shell finishes. PR #2699 reset the dedupe on
|
|
||||||
// every load so that switching
|
|
||||||
// preview -> source -> preview (which remounts
|
|
||||||
// this iframe as a fresh DOM node) would
|
|
||||||
// re-activate the new shell. But resetting on
|
|
||||||
// every load also re-activated on the SECOND
|
|
||||||
// load of a non-remounted frame, which
|
|
||||||
// re-triggered document.open/write/close, which
|
|
||||||
// re-fired the load event, ad infinitum. The
|
|
||||||
// dedupe ref oscillated between null and the
|
|
||||||
// current srcDoc thousands of times per render
|
|
||||||
// and each iteration restarted every CSS
|
|
||||||
// animation from its `from` keyframe. Designs
|
|
||||||
// using `animation-fill-mode: both` with
|
|
||||||
// `from { opacity: 0 }` stayed at opacity 0
|
|
||||||
// forever and the preview read as blank.
|
|
||||||
// That is issue #2361.
|
|
||||||
//
|
|
||||||
// Tracking the last frame we reset for lets us
|
|
||||||
// keep PR #2699's "remount after Source toggle"
|
|
||||||
// fix while breaking the loop on plain renders.
|
|
||||||
if (frame && srcDocFrameDedupeResetForRef.current !== frame) {
|
|
||||||
srcDocFrameDedupeResetForRef.current = frame;
|
|
||||||
activatedSrcDocTransportHtmlRef.current = null;
|
|
||||||
}
|
|
||||||
if (useLazySrcDocTransport) setSrcDocShellReady(true);
|
|
||||||
activateLoadedSrcDocTransport(frame);
|
|
||||||
dcViewportRestoreAtRef.current = Date.now();
|
|
||||||
frame?.contentWindow?.postMessage({
|
|
||||||
type: '__dc_set_viewport',
|
|
||||||
...dcViewportRef.current,
|
|
||||||
}, '*');
|
|
||||||
replayInspectOverridesToIframe(frame);
|
|
||||||
syncBridgeModes(frame);
|
|
||||||
if (!useUrlLoadPreview) restorePreviewScrollPosition();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PreviewDrawOverlay>
|
|
||||||
</div>
|
</div>
|
||||||
|
{boardMode ? (
|
||||||
|
<CommentPreviewOverlays
|
||||||
|
comments={commentCreateMode ? visibleSideComments : []}
|
||||||
|
liveTargets={liveCommentTargets}
|
||||||
|
hoveredTarget={hoveredCommentTarget}
|
||||||
|
hoveredPodMemberId={hoveredPodMemberId}
|
||||||
|
activeTarget={activeCommentTarget}
|
||||||
|
boardTool={boardTool}
|
||||||
|
showActivePin={commentCreateMode}
|
||||||
|
scale={overlayPreviewScale}
|
||||||
|
offsetX={overlayPreviewTransform.offsetX}
|
||||||
|
offsetY={overlayPreviewTransform.offsetY}
|
||||||
|
strokePoints={strokePoints}
|
||||||
|
onOpenComment={(comment, snapshot) => {
|
||||||
|
setCommentPanelOpen(true);
|
||||||
|
setCommentSidePanelCollapsed(false);
|
||||||
|
setCommentCreateMode(true);
|
||||||
|
setBoardMode(true);
|
||||||
|
setActiveCommentTarget(snapshot);
|
||||||
|
setHoveredCommentTarget(snapshot);
|
||||||
|
setActivePreviewCommentId(comment.id);
|
||||||
|
setCommentDraft(comment.note);
|
||||||
|
setQueuedBoardNotes([]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{exportToast ? (
|
||||||
|
<div className="comment-toast-anchor">
|
||||||
|
<Toast
|
||||||
|
message={exportToast}
|
||||||
|
ttlMs={2200}
|
||||||
|
onDismiss={() => setExportToast(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{commentSavedToast ? (
|
||||||
|
<div className="comment-toast-anchor">
|
||||||
|
<Toast
|
||||||
|
message={commentSavedToast}
|
||||||
|
ttlMs={2200}
|
||||||
|
onDismiss={() => setCommentSavedToast(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{templateSavedToast ? (
|
||||||
|
<div className="comment-toast-anchor">
|
||||||
|
<Toast
|
||||||
|
message={templateSavedToast}
|
||||||
|
ttlMs={2200}
|
||||||
|
onDismiss={() => setTemplateSavedToast(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{commentComposer}
|
||||||
|
{boardMode && !commentCreateMode && hoveredCommentTarget && (!activeCommentTarget || commentPortalHost) ? (
|
||||||
|
<AnnotationHoverPopover target={hoveredCommentTarget} scale={overlayPreviewScale} />
|
||||||
|
) : null}
|
||||||
|
{/*
|
||||||
|
Hint banner for Inspect / Picker modes. The bridge in
|
||||||
|
`apps/web/src/runtime/srcdoc.ts` posts `od:comment-targets`
|
||||||
|
with every element annotated with `data-od-id` /
|
||||||
|
`data-screen-label`, so `liveCommentTargets.size` is the
|
||||||
|
authoritative annotation count for the current artifact.
|
||||||
|
|
||||||
|
Two states:
|
||||||
|
- "has targets": the existing copy ("Click any element with
|
||||||
|
`data-od-id` to tune its style.") for users who just don't
|
||||||
|
see the crosshair cursor.
|
||||||
|
- "no targets" (issue #890): a freeform-generated artifact
|
||||||
|
(e.g. PRD → HTML through a Claude-Code-compatible CLI
|
||||||
|
without a skill) ships zero `data-od-id` annotations. The
|
||||||
|
bridge's click handler walks up to <html>, finds nothing,
|
||||||
|
and bails — clicks no-op silently. The static copy made
|
||||||
|
this look broken; the empty-state copy explains what's
|
||||||
|
missing and how to fix it. Mirrored across Inspect and
|
||||||
|
element-pick annotation mode because the failure surface is identical.
|
||||||
|
*/}
|
||||||
|
{inspectMode
|
||||||
|
&& openHintBox
|
||||||
|
&& !activeInspectTarget
|
||||||
|
&& !activeCommentTarget ? (
|
||||||
|
<div
|
||||||
|
className="inspect-empty-hint-container"
|
||||||
|
data-testid="inspect-empty-hint-container"
|
||||||
|
>
|
||||||
|
{liveCommentTargets.size === 0 ? (
|
||||||
|
<div
|
||||||
|
className="inspect-empty-hint"
|
||||||
|
data-testid="inspect-empty-hint-no-targets"
|
||||||
|
>
|
||||||
|
{inspectMode
|
||||||
|
? t('chat.inspect.noEditableTargets')
|
||||||
|
: t('chat.inspect.noCommentTargets')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="inspect-empty-hint"
|
||||||
|
data-testid="inspect-empty-hint"
|
||||||
|
>
|
||||||
|
{inspectMode ? t('chat.inspect.editHint') : t('chat.inspect.commentHint')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Close Inspect Hint"
|
||||||
|
aria-label="Close Inspect Hint"
|
||||||
|
onClick={() => setOpenHintBox(false)}
|
||||||
|
className="orbit-artifact-ghost"
|
||||||
|
>
|
||||||
|
<Icon className="" name="close" size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{boardMode ? (
|
|
||||||
<CommentPreviewOverlays
|
|
||||||
comments={commentCreateMode ? visibleSideComments : []}
|
|
||||||
liveTargets={liveCommentTargets}
|
|
||||||
hoveredTarget={hoveredCommentTarget}
|
|
||||||
hoveredPodMemberId={hoveredPodMemberId}
|
|
||||||
activeTarget={activeCommentTarget}
|
|
||||||
boardTool={boardTool}
|
|
||||||
showActivePin={commentCreateMode}
|
|
||||||
scale={overlayPreviewScale}
|
|
||||||
offsetX={overlayPreviewTransform.offsetX}
|
|
||||||
offsetY={overlayPreviewTransform.offsetY}
|
|
||||||
strokePoints={strokePoints}
|
|
||||||
onOpenComment={(comment, snapshot) => {
|
|
||||||
setCommentPanelOpen(true);
|
|
||||||
setCommentSidePanelCollapsed(false);
|
|
||||||
setCommentCreateMode(true);
|
|
||||||
setBoardMode(true);
|
|
||||||
setActiveCommentTarget(snapshot);
|
|
||||||
setHoveredCommentTarget(snapshot);
|
|
||||||
setActivePreviewCommentId(comment.id);
|
|
||||||
setCommentDraft(comment.note);
|
|
||||||
setQueuedBoardNotes([]);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{exportToast ? (
|
|
||||||
<div className="comment-toast-anchor">
|
|
||||||
<Toast
|
|
||||||
message={exportToast}
|
|
||||||
ttlMs={2200}
|
|
||||||
onDismiss={() => setExportToast(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{commentSavedToast ? (
|
|
||||||
<div className="comment-toast-anchor">
|
|
||||||
<Toast
|
|
||||||
message={commentSavedToast}
|
|
||||||
ttlMs={2200}
|
|
||||||
onDismiss={() => setCommentSavedToast(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{templateSavedToast ? (
|
|
||||||
<div className="comment-toast-anchor">
|
|
||||||
<Toast
|
|
||||||
message={templateSavedToast}
|
|
||||||
ttlMs={2200}
|
|
||||||
onDismiss={() => setTemplateSavedToast(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{commentComposer}
|
|
||||||
{boardMode && !commentCreateMode && hoveredCommentTarget && (!activeCommentTarget || commentPortalHost) ? (
|
|
||||||
<AnnotationHoverPopover target={hoveredCommentTarget} scale={overlayPreviewScale} />
|
|
||||||
) : null}
|
|
||||||
{commentPortalHost && commentSidePanel
|
{commentPortalHost && commentSidePanel
|
||||||
? createPortal(commentSidePanel, commentPortalHost)
|
? createPortal(commentSidePanel, commentPortalHost)
|
||||||
: commentPortalId
|
: commentPortalId
|
||||||
|
|
@ -7339,64 +7560,6 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
||||||
error={inspectError}
|
error={inspectError}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{/*
|
|
||||||
Hint banner for Inspect / Picker modes. The bridge in
|
|
||||||
`apps/web/src/runtime/srcdoc.ts` posts `od:comment-targets`
|
|
||||||
with every element annotated with `data-od-id` /
|
|
||||||
`data-screen-label`, so `liveCommentTargets.size` is the
|
|
||||||
authoritative annotation count for the current artifact.
|
|
||||||
|
|
||||||
Two states:
|
|
||||||
- "has targets": the existing copy ("Click any element with
|
|
||||||
`data-od-id` to tune its style.") for users who just don't
|
|
||||||
see the crosshair cursor.
|
|
||||||
- "no targets" (issue #890): a freeform-generated artifact
|
|
||||||
(e.g. PRD → HTML through a Claude-Code-compatible CLI
|
|
||||||
without a skill) ships zero `data-od-id` annotations. The
|
|
||||||
bridge's click handler walks up to <html>, finds nothing,
|
|
||||||
and bails — clicks no-op silently. The static copy made
|
|
||||||
this look broken; the empty-state copy explains what's
|
|
||||||
missing and how to fix it. Mirrored across Inspect and
|
|
||||||
element-pick annotation mode because the failure surface is identical.
|
|
||||||
*/}
|
|
||||||
{inspectMode
|
|
||||||
&& openHintBox
|
|
||||||
&& !activeInspectTarget
|
|
||||||
&& !activeCommentTarget ? (
|
|
||||||
<div
|
|
||||||
className={`inspect-empty-hint-container${
|
|
||||||
commentPanelOpen && !commentSidePanelCollapsed ? ' comment-side-panel-open' : ''
|
|
||||||
}`}
|
|
||||||
data-testid="inspect-empty-hint-container"
|
|
||||||
>
|
|
||||||
{liveCommentTargets.size === 0 ? (
|
|
||||||
<div
|
|
||||||
className="inspect-empty-hint"
|
|
||||||
data-testid="inspect-empty-hint-no-targets"
|
|
||||||
>
|
|
||||||
{inspectMode
|
|
||||||
? t('chat.inspect.noEditableTargets')
|
|
||||||
: t('chat.inspect.noCommentTargets')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="inspect-empty-hint"
|
|
||||||
data-testid="inspect-empty-hint"
|
|
||||||
>
|
|
||||||
{inspectMode ? t('chat.inspect.editHint') : t('chat.inspect.commentHint')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title="Close Inspect Hint"
|
|
||||||
aria-label="Close Inspect Hint"
|
|
||||||
onClick={() => setOpenHintBox(false)}
|
|
||||||
className="orbit-artifact-ghost"
|
|
||||||
>
|
|
||||||
<Icon className="" name="close" size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<pre className="viewer-source">{source}</pre>
|
<pre className="viewer-source">{source}</pre>
|
||||||
|
|
|
||||||
|
|
@ -978,6 +978,53 @@
|
||||||
.live-artifact-preview-layer.preview-viewport[data-active='false'] {
|
.live-artifact-preview-layer.preview-viewport[data-active='false'] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.comment-preview-layer {
|
||||||
|
--comment-side-dock-width: 320px;
|
||||||
|
--comment-side-dock-rail-width: 42px;
|
||||||
|
--comment-side-dock-stacked-height: 220px;
|
||||||
|
--comment-side-dock-stacked-rail-height: 48px;
|
||||||
|
}
|
||||||
|
.comment-preview-layer-with-side-dock {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) var(--comment-side-dock-width);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.preview-viewport:not(.preview-viewport-desktop).comment-preview-layer-with-side-dock {
|
||||||
|
display: grid;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: stretch;
|
||||||
|
padding: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.preview-viewport:not(.preview-viewport-desktop).comment-preview-layer-side-dock-stacked {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.comment-preview-layer-with-side-dock.comment-preview-layer-dock-collapsed {
|
||||||
|
grid-template-columns: minmax(0, 1fr) var(--comment-side-dock-rail-width);
|
||||||
|
}
|
||||||
|
.comment-preview-layer-with-side-dock.comment-preview-layer-side-dock-stacked {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr) var(--comment-side-dock-stacked-height);
|
||||||
|
}
|
||||||
|
.comment-preview-layer-with-side-dock.comment-preview-layer-side-dock-stacked.comment-preview-layer-dock-collapsed {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr) var(--comment-side-dock-stacked-rail-height);
|
||||||
|
}
|
||||||
|
.comment-preview-canvas {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.comment-preview-layer-with-side-dock .comment-preview-canvas {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
.preview-viewport:not(.preview-viewport-desktop) {
|
.preview-viewport:not(.preview-viewport-desktop) {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -995,7 +1042,7 @@
|
||||||
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
||||||
}
|
}
|
||||||
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip,
|
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip,
|
||||||
.preview-viewport:not(.preview-viewport-desktop) .comment-frame-clip,
|
.preview-viewport:not(.preview-viewport-desktop):not(.comment-preview-layer-with-side-dock) .comment-preview-canvas,
|
||||||
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas {
|
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas {
|
||||||
width: calc(var(--preview-viewport-width) * var(--preview-scale, 1));
|
width: calc(var(--preview-viewport-width) * var(--preview-scale, 1));
|
||||||
height: calc(var(--preview-viewport-height) * var(--preview-scale, 1));
|
height: calc(var(--preview-viewport-height) * var(--preview-scale, 1));
|
||||||
|
|
@ -1008,19 +1055,23 @@
|
||||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.22);
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.22);
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
}
|
}
|
||||||
|
.preview-viewport:not(.preview-viewport-desktop).comment-preview-layer-with-side-dock .comment-preview-canvas {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip > div,
|
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip > div,
|
||||||
.preview-viewport:not(.preview-viewport-desktop) .comment-frame-clip > div,
|
.preview-viewport:not(.preview-viewport-desktop) .comment-frame-clip > div,
|
||||||
|
.preview-viewport:not(.preview-viewport-desktop) .comment-preview-canvas > .comment-frame-clip > div,
|
||||||
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas > div {
|
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas > div {
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip,
|
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip,
|
||||||
.preview-viewport:not(.preview-viewport-desktop) .comment-frame-clip,
|
|
||||||
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas {
|
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas {
|
||||||
position: relative;
|
position: relative;
|
||||||
inset: auto;
|
inset: auto;
|
||||||
}
|
}
|
||||||
.preview-viewport-mobile .preview-frame-clip,
|
.preview-viewport-mobile .preview-frame-clip,
|
||||||
.preview-viewport-mobile .comment-frame-clip,
|
.preview-viewport-mobile:not(.comment-preview-layer-with-side-dock) .comment-preview-canvas,
|
||||||
.preview-viewport-mobile.manual-edit-workspace .manual-edit-canvas {
|
.preview-viewport-mobile.manual-edit-workspace .manual-edit-canvas {
|
||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
}
|
}
|
||||||
|
|
@ -1324,29 +1375,59 @@
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
}
|
}
|
||||||
/* Right-side comment thread panel. Shown while board (comment) mode
|
/* Right-side comment thread panel. Shown while board (comment) mode
|
||||||
is on; takes the place of the chat sidebar's removed Comments tab.
|
is on; it docks beside the artifact preview so canvas clicks remain
|
||||||
Floats over the artifact preview at the right edge. */
|
available for placing comments. */
|
||||||
|
.comment-side-dock {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
width: var(--comment-side-dock-width);
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.comment-side-dock.collapsed {
|
||||||
|
width: var(--comment-side-dock-rail-width);
|
||||||
|
}
|
||||||
|
.comment-preview-layer-side-dock-stacked .comment-side-dock {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--comment-side-dock-stacked-height);
|
||||||
|
}
|
||||||
|
.comment-preview-layer-side-dock-stacked .comment-side-dock.collapsed {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--comment-side-dock-stacked-rail-height);
|
||||||
|
}
|
||||||
|
.comment-preview-layer-side-dock-stacked .comment-side-dock.collapsed .comment-side-rail {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.comment-preview-layer-side-dock-stacked .comment-side-dock.collapsed .comment-side-rail span {
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
|
}
|
||||||
.comment-side-panel {
|
.comment-side-panel {
|
||||||
--comment-accent: #ff5a3c;
|
--comment-accent: #ff5a3c;
|
||||||
--comment-accent-strong: color-mix(in srgb, var(--comment-accent) 78%, var(--text));
|
--comment-accent-strong: color-mix(in srgb, var(--comment-accent) 78%, var(--text));
|
||||||
--comment-accent-surface: color-mix(in srgb, var(--comment-accent) 10%, var(--bg-panel));
|
--comment-accent-surface: color-mix(in srgb, var(--comment-accent) 10%, var(--bg-panel));
|
||||||
--comment-accent-surface-strong: color-mix(in srgb, var(--comment-accent) 18%, var(--bg-panel));
|
--comment-accent-surface-strong: color-mix(in srgb, var(--comment-accent) 18%, var(--bg-panel));
|
||||||
--comment-accent-border: color-mix(in srgb, var(--comment-accent) 64%, var(--border));
|
--comment-accent-border: color-mix(in srgb, var(--comment-accent) 64%, var(--border));
|
||||||
position: absolute;
|
position: relative;
|
||||||
top: 8px;
|
|
||||||
right: 8px;
|
|
||||||
bottom: 8px;
|
|
||||||
width: 320px;
|
width: 320px;
|
||||||
max-width: calc(100% - 16px);
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
background: var(--bg-panel, #fff);
|
background: var(--bg-panel, #fff);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
z-index: 30;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.comment-preview-layer-side-dock-stacked .comment-side-panel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.comment-side-header {
|
.comment-side-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -1394,7 +1475,7 @@
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
.comment-side-close {
|
.comment-side-collapse {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
@ -1408,18 +1489,15 @@
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.comment-side-close:hover {
|
.comment-side-collapse:hover {
|
||||||
background: var(--bg-subtle);
|
background: var(--bg-subtle);
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.comment-side-rail {
|
.comment-side-rail {
|
||||||
position: absolute;
|
position: relative;
|
||||||
top: 8px;
|
|
||||||
right: 8px;
|
|
||||||
bottom: 8px;
|
|
||||||
z-index: 30;
|
|
||||||
width: 42px;
|
width: 42px;
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -1689,16 +1767,15 @@
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inspect panel — sibling of the comment popover. Anchored to the
|
/* Inspect panel — sibling of the preview canvas and comment popover.
|
||||||
right side of the preview surface. Width is fixed so layout doesn't
|
Keep the usual 296px width, but allow narrow board layouts to shrink it
|
||||||
reflow as the user scrubs slider values; controls reserve space for
|
inside the unclipped preview layer instead of losing controls. */
|
||||||
their numeric readouts. */
|
|
||||||
.inspect-panel {
|
.inspect-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 14px;
|
top: 14px;
|
||||||
right: 14px;
|
right: 14px;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
width: 296px;
|
width: min(296px, calc(100% - 28px));
|
||||||
max-height: calc(100% - 28px);
|
max-height: calc(100% - 28px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1885,20 +1962,6 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inspect-empty-hint-container.comment-side-panel-open {
|
|
||||||
right: 340px;
|
|
||||||
max-width: min(480px, calc(100% - 368px));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.inspect-empty-hint-container.comment-side-panel-open {
|
|
||||||
left: 14px;
|
|
||||||
right: 14px;
|
|
||||||
top: 54px;
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.inspect-empty-hint-container button,
|
.inspect-empty-hint-container button,
|
||||||
.inspect-empty-hint-container .close-button {
|
.inspect-empty-hint-container .close-button {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
|
|
||||||
193
apps/web/tests/components/ChatComposer.tools-menu-caret.test.tsx
Normal file
193
apps/web/tests/components/ChatComposer.tools-menu-caret.test.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ChatComposer } from '../../src/components/ChatComposer';
|
||||||
|
|
||||||
|
// Regression coverage for #3195. The mention popover (typed `@`) prevents
|
||||||
|
// the textarea from losing focus on mousedown for every picker button —
|
||||||
|
// the comment at `ChatComposer.tsx:3039-3043` explains why: without it,
|
||||||
|
// `selectionStart` resets on the focus transfer and the insert handler
|
||||||
|
// targets the wrong substring (caret jumps to the start, the inserted
|
||||||
|
// token lands at offset 0 instead of at the user's cursor).
|
||||||
|
//
|
||||||
|
// The right-side `@`-button tools popover (`ToolsPluginsPanel`,
|
||||||
|
// `ToolsSkillsPanel`, `ToolsMcpPanel`) skips that protection — pick rows
|
||||||
|
// have `onClick` but no `onMouseDown={(e) => e.preventDefault()}`. So
|
||||||
|
// every insertion through the tools popover is at risk of the same caret
|
||||||
|
// reset whenever a real mouse triggers focus transfer first.
|
||||||
|
//
|
||||||
|
// We can't reliably observe the focus transfer in jsdom (it does not
|
||||||
|
// move focus on raw mousedown), so the test asserts the contract
|
||||||
|
// directly: the picker row must call `preventDefault()` on mousedown so
|
||||||
|
// that browsers never get to move focus before the click handler reads
|
||||||
|
// `textarea.selectionStart`.
|
||||||
|
|
||||||
|
const COMMUNITY_PLUGIN = {
|
||||||
|
id: 'sample-plugin',
|
||||||
|
title: 'Sample Plugin',
|
||||||
|
version: '1.0.0',
|
||||||
|
trust: 'restricted' as const,
|
||||||
|
sourceKind: 'bundled' as const,
|
||||||
|
source: 'bundled/sample',
|
||||||
|
capabilitiesGranted: [],
|
||||||
|
manifest: {
|
||||||
|
name: 'sample-plugin',
|
||||||
|
title: 'Sample Plugin',
|
||||||
|
description: 'Sample',
|
||||||
|
od: { kind: 'skill' },
|
||||||
|
},
|
||||||
|
fsPath: '/plugins/sample',
|
||||||
|
installedAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SKILL = {
|
||||||
|
id: 'deck-builder',
|
||||||
|
name: 'Deck Builder',
|
||||||
|
description: 'Build a polished slide deck.',
|
||||||
|
triggers: ['deck'],
|
||||||
|
mode: 'deck' as const,
|
||||||
|
previewType: 'html',
|
||||||
|
designSystemRequired: false,
|
||||||
|
defaultFor: [],
|
||||||
|
upstream: null,
|
||||||
|
hasBody: true,
|
||||||
|
examplePrompt: 'Make a deck',
|
||||||
|
aggregatesExamples: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MCP_SERVER = {
|
||||||
|
id: 'slack',
|
||||||
|
label: 'Slack MCP',
|
||||||
|
transport: 'stdio' as const,
|
||||||
|
enabled: true,
|
||||||
|
command: 'slack-mcp',
|
||||||
|
};
|
||||||
|
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
let plugins = [COMMUNITY_PLUGIN];
|
||||||
|
let skills = [SKILL];
|
||||||
|
let servers = [MCP_SERVER];
|
||||||
|
|
||||||
|
function renderComposer(
|
||||||
|
overrides: Partial<ComponentProps<typeof ChatComposer>> = {},
|
||||||
|
) {
|
||||||
|
return render(
|
||||||
|
<ChatComposer
|
||||||
|
projectId="project-1"
|
||||||
|
projectFiles={[]}
|
||||||
|
streaming={false}
|
||||||
|
onEnsureProject={async () => 'project-1'}
|
||||||
|
onSend={vi.fn()}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
onOpenMcpSettings={vi.fn()}
|
||||||
|
skills={skills}
|
||||||
|
{...overrides}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
plugins = [COMMUNITY_PLUGIN];
|
||||||
|
skills = [SKILL];
|
||||||
|
servers = [MCP_SERVER];
|
||||||
|
fetchMock = vi.fn(async (url: string) => {
|
||||||
|
if (url === '/api/mcp/servers') {
|
||||||
|
return new Response(JSON.stringify({ servers, templates: [] }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === '/api/plugins') {
|
||||||
|
return new Response(JSON.stringify({ plugins }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === '/api/skills') {
|
||||||
|
return new Response(JSON.stringify({ skills }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === '/api/projects/project-1') {
|
||||||
|
return new Response(JSON.stringify({ project: { id: 'project-1', skillId: SKILL.id } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch ${url}`);
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openToolsPopover() {
|
||||||
|
const trigger = document.querySelector(
|
||||||
|
'.composer-tools-trigger',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(trigger).toBeTruthy();
|
||||||
|
fireEvent.click(trigger!);
|
||||||
|
await waitFor(() => expect(screen.getByRole('menu')).toBeTruthy());
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTab(label: string) {
|
||||||
|
const tab = Array.from(
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('.composer-tools-tab'),
|
||||||
|
).find((el) => el.textContent?.trim() === label);
|
||||||
|
expect(tab).toBeTruthy();
|
||||||
|
fireEvent.click(tab!);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowByText(text: string): HTMLButtonElement {
|
||||||
|
// Skill / MCP rows are themselves the picker `<button>` (.composer-tools-row).
|
||||||
|
// Plugin rows wrap two buttons inside a `<div>`; the picker is the
|
||||||
|
// `.composer-tools-row-main` child, so prefer that selector first.
|
||||||
|
const row = Array.from(
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('button.composer-tools-row-main, button.composer-tools-row'),
|
||||||
|
).find((btn) => btn.textContent?.includes(text));
|
||||||
|
expect(row).toBeTruthy();
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatComposer tools-menu picker mousedown protection (#3195)', () => {
|
||||||
|
it('the skills picker prevents default on mousedown so the caret survives focus transfer', async () => {
|
||||||
|
renderComposer();
|
||||||
|
await openToolsPopover();
|
||||||
|
selectTab('Skills');
|
||||||
|
|
||||||
|
const row = rowByText('Deck Builder');
|
||||||
|
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
|
||||||
|
row.dispatchEvent(event);
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the plugins picker prevents default on mousedown so the caret survives focus transfer', async () => {
|
||||||
|
renderComposer();
|
||||||
|
await openToolsPopover();
|
||||||
|
selectTab('Plugins');
|
||||||
|
|
||||||
|
const row = rowByText('Sample Plugin');
|
||||||
|
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
|
||||||
|
row.dispatchEvent(event);
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the MCP picker prevents default on mousedown so the caret survives focus transfer', async () => {
|
||||||
|
renderComposer();
|
||||||
|
await openToolsPopover();
|
||||||
|
selectTab('MCP');
|
||||||
|
|
||||||
|
const row = rowByText('Slack MCP');
|
||||||
|
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
|
||||||
|
row.dispatchEvent(event);
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -29,6 +29,7 @@ import {
|
||||||
LiveArtifactRefreshHistoryPanel,
|
LiveArtifactRefreshHistoryPanel,
|
||||||
SvgViewer,
|
SvgViewer,
|
||||||
applyInspectOverridesToSource,
|
applyInspectOverridesToSource,
|
||||||
|
commentPreviewCanvasSize,
|
||||||
effectivePreviewScale,
|
effectivePreviewScale,
|
||||||
parseInspectOverridesFromSource,
|
parseInspectOverridesFromSource,
|
||||||
previewOverlayTransform,
|
previewOverlayTransform,
|
||||||
|
|
@ -75,6 +76,30 @@ function deferredResponse() {
|
||||||
return { promise, resolve };
|
return { promise, resolve };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function srcDocActivationMessages(calls: readonly (readonly unknown[])[]) {
|
||||||
|
return calls
|
||||||
|
.map(([message]) => message)
|
||||||
|
.filter((message): message is { type: 'od:srcdoc-transport-activate'; html: string } => {
|
||||||
|
if (typeof message !== 'object' || message === null) return false;
|
||||||
|
const data = message as { type?: unknown; html?: unknown };
|
||||||
|
return data.type === 'od:srcdoc-transport-activate' && typeof data.html === 'string';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function testRect(left: number, top: number, width: number, height: number): DOMRect {
|
||||||
|
return {
|
||||||
|
x: left,
|
||||||
|
y: top,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
right: left + width,
|
||||||
|
bottom: top + height,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
} as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
function clickAgentTool(testId: string) {
|
function clickAgentTool(testId: string) {
|
||||||
fireEvent.click(screen.getByTestId(testId));
|
fireEvent.click(screen.getByTestId(testId));
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +120,10 @@ describe('FileViewer preview scale', () => {
|
||||||
'.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas',
|
'.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas',
|
||||||
);
|
);
|
||||||
expect(css).toMatch(
|
expect(css).toMatch(
|
||||||
/\.preview-viewport:not\(\.preview-viewport-desktop\) \.preview-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\) \.comment-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\)\.manual-edit-workspace \.manual-edit-canvas \{\s*\n\s*position: relative;/,
|
/\.preview-viewport:not\(\.preview-viewport-desktop\) \.preview-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\):not\(\.comment-preview-layer-with-side-dock\) \.comment-preview-canvas,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\)\.manual-edit-workspace \.manual-edit-canvas \{\s*\n\s*width: calc\(var\(--preview-viewport-width\) \* var\(--preview-scale, 1\)\);/,
|
||||||
|
);
|
||||||
|
expect(css).toMatch(
|
||||||
|
/\.preview-viewport:not\(\.preview-viewport-desktop\) \.preview-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\)\.manual-edit-workspace \.manual-edit-canvas \{\s*\n\s*position: relative;/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -121,6 +149,60 @@ describe('FileViewer preview scale', () => {
|
||||||
expect(effectivePreviewScale('tablet', 1.25, { width: 820, height: 700 })).toBeLessThan(1);
|
expect(effectivePreviewScale('tablet', 1.25, { width: 820, height: 700 })).toBeLessThan(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the reduced board canvas size when the side dock is open', () => {
|
||||||
|
const dockedCanvas = commentPreviewCanvasSize(
|
||||||
|
{ width: 900, height: 700 },
|
||||||
|
{ boardMode: true, sidePanelCollapsed: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dockedCanvas).toEqual({ width: 552, height: 684 });
|
||||||
|
expect(effectivePreviewScale('tablet', 1, dockedCanvas)).toBeLessThan(
|
||||||
|
effectivePreviewScale('tablet', 1, { width: 900, height: 700 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses stacked canvas sizing for narrow board panes instead of a 1px docked canvas', () => {
|
||||||
|
const narrowCanvas = commentPreviewCanvasSize(
|
||||||
|
{ width: 400, height: 700 },
|
||||||
|
{ boardMode: true, sidePanelCollapsed: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(narrowCanvas).toEqual({ width: 384, height: 452 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subtracts only the collapsed stacked rail height when the side dock is collapsed in the stacked layout', () => {
|
||||||
|
const expandedStackedCanvas = commentPreviewCanvasSize(
|
||||||
|
{ width: 300, height: 700 },
|
||||||
|
{ boardMode: true, sidePanelCollapsed: false },
|
||||||
|
);
|
||||||
|
const collapsedStackedCanvas = commentPreviewCanvasSize(
|
||||||
|
{ width: 300, height: 700 },
|
||||||
|
{ boardMode: true, sidePanelCollapsed: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(expandedStackedCanvas).toEqual({ width: 284, height: 452 });
|
||||||
|
expect(collapsedStackedCanvas).toEqual({ width: 284, height: 624 });
|
||||||
|
expect(collapsedStackedCanvas!.height).toBeGreaterThan(expandedStackedCanvas!.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the rendered non-desktop dock padding in board canvas sizing', () => {
|
||||||
|
const dockedCanvas = commentPreviewCanvasSize(
|
||||||
|
{ width: 900, height: 700 },
|
||||||
|
{ boardMode: true, sidePanelCollapsed: false, viewport: 'tablet' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dockedCanvas).toEqual({ width: 520, height: 652 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fits non-desktop board previews against the inner canvas without subtracting viewport padding again', () => {
|
||||||
|
const dockedCanvas = commentPreviewCanvasSize(
|
||||||
|
{ width: 900, height: 700 },
|
||||||
|
{ boardMode: true, sidePanelCollapsed: false, viewport: 'tablet' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(effectivePreviewScale('tablet', 1, dockedCanvas, { canvasPadding: 0 })).toBeCloseTo(652 / 1180);
|
||||||
|
});
|
||||||
|
|
||||||
it('offsets tablet and mobile overlays to the centered viewport card', () => {
|
it('offsets tablet and mobile overlays to the centered viewport card', () => {
|
||||||
expect(previewOverlayTransform('desktop', 1.25, { width: 1200, height: 800 })).toEqual({
|
expect(previewOverlayTransform('desktop', 1.25, { width: 1200, height: 800 })).toEqual({
|
||||||
scale: 1.25,
|
scale: 1.25,
|
||||||
|
|
@ -2034,23 +2116,69 @@ describe('FileViewer tweaks toolbar', () => {
|
||||||
expect(screen.queryByTestId('inspect-empty-hint-container')).toBeNull();
|
expect(screen.queryByTestId('inspect-empty-hint-container')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exits comment mode when closing the comments side panel', () => {
|
it('keeps the picker hint inside the canvas and clear of the open comment side panel', () => {
|
||||||
const openComment: PreviewComment = {
|
render(
|
||||||
id: 'comment-open',
|
<FileViewer
|
||||||
projectId: 'project-1',
|
projectId="project-1"
|
||||||
conversationId: 'conversation-1',
|
projectKind="prototype"
|
||||||
filePath: 'preview.html',
|
file={htmlPreviewFile()}
|
||||||
elementId: 'pin-open',
|
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||||
selector: '[data-od-pin="pin-open"]',
|
/>,
|
||||||
label: 'pin-open',
|
);
|
||||||
text: '',
|
|
||||||
htmlHint: '',
|
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
|
||||||
position: { x: 24, y: 32, width: 18, height: 18 },
|
|
||||||
note: 'Open comment',
|
const canvas = screen.getByTestId('comment-preview-canvas');
|
||||||
status: 'open',
|
const dock = screen.getByTestId('comment-side-dock');
|
||||||
createdAt: Date.now(),
|
|
||||||
updatedAt: Date.now(),
|
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
|
||||||
};
|
expect(canvas.contains(screen.getByTestId('artifact-preview-frame'))).toBe(true);
|
||||||
|
expect(dock.contains(screen.getByTestId('artifact-preview-frame'))).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /hide comments/i }));
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
|
||||||
|
expect(screen.getByTestId('comment-side-collapsed-rail')).toBeTruthy();
|
||||||
|
expect(canvas.contains(screen.getByTestId('artifact-preview-frame'))).toBe(true);
|
||||||
|
expect(dock.contains(screen.getByTestId('artifact-preview-frame'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps non-docked tablet comment-tool previews fitted to the padded canvas', async () => {
|
||||||
|
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||||
|
.mockImplementation(function getBoundingClientRectMock(this: HTMLElement) {
|
||||||
|
if (this.classList.contains('viewer-body')) return testRect(0, 0, 900, 700);
|
||||||
|
return testRect(0, 0, 0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FileViewer
|
||||||
|
projectId="project-1"
|
||||||
|
projectKind="prototype"
|
||||||
|
file={htmlPreviewFile()}
|
||||||
|
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('Preview viewport'));
|
||||||
|
fireEvent.click(screen.getByRole('option', { name: 'Tablet' }));
|
||||||
|
clickAgentTool('board-mode-toggle');
|
||||||
|
|
||||||
|
const layout = screen.getByTestId('comment-preview-layout');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(layout.className).not.toContain('comment-preview-layer-with-side-dock');
|
||||||
|
expect(Number(layout.style.getPropertyValue('--preview-scale'))).toBeCloseTo((700 - 48) / 1180);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('docks the comment side panel outside the clickable preview canvas', () => {
|
||||||
|
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||||
|
.mockImplementation(function getBoundingClientRectMock(this: HTMLElement) {
|
||||||
|
if (this.classList.contains('viewer-body')) return testRect(0, 0, 900, 700);
|
||||||
|
if (this.dataset.testid === 'comment-preview-canvas') return testRect(8, 8, 552, 684);
|
||||||
|
if (this.dataset.testid === 'comment-side-dock') return testRect(572, 8, 320, 684);
|
||||||
|
if (this.dataset.testid === 'comment-side-panel') return testRect(572, 8, 320, 684);
|
||||||
|
return testRect(0, 0, 0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<FileViewer
|
<FileViewer
|
||||||
|
|
@ -2058,22 +2186,53 @@ describe('FileViewer tweaks toolbar', () => {
|
||||||
projectKind="prototype"
|
projectKind="prototype"
|
||||||
file={htmlPreviewFile()}
|
file={htmlPreviewFile()}
|
||||||
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||||
previewComments={[openComment]}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
|
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
|
||||||
|
|
||||||
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
|
const canvas = screen.getByTestId('comment-preview-canvas');
|
||||||
expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('true');
|
const dock = screen.getByTestId('comment-side-dock');
|
||||||
expect(screen.getByTestId('comment-saved-marker-pin-open')).toBeTruthy();
|
const panel = screen.getByTestId('comment-side-panel');
|
||||||
|
const canvasBox = canvas.getBoundingClientRect();
|
||||||
|
const dockBox = dock.getBoundingClientRect();
|
||||||
|
const panelBox = panel.getBoundingClientRect();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
expect(canvas.contains(screen.getByTestId('artifact-preview-frame'))).toBe(true);
|
||||||
|
expect(dock.contains(panel)).toBe(true);
|
||||||
|
expect(canvas.contains(panel)).toBe(false);
|
||||||
|
expect(screen.getByTestId('comment-preview-layout').className).toContain(
|
||||||
|
'comment-preview-layer-with-side-dock',
|
||||||
|
);
|
||||||
|
expect(dockBox.left).toBeGreaterThanOrEqual(canvasBox.right);
|
||||||
|
expect(panelBox.left).toBeGreaterThanOrEqual(canvasBox.right);
|
||||||
|
});
|
||||||
|
|
||||||
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
|
it('uses the narrow board layout when docking would leave too little canvas', async () => {
|
||||||
expect(screen.queryByTestId('comment-saved-marker-pin-open')).toBeNull();
|
const getBoundingClientRectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||||
expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('false');
|
.mockImplementation(function getBoundingClientRectMock(this: HTMLElement) {
|
||||||
expect(screen.getByTestId('board-mode-toggle').getAttribute('aria-pressed')).toBe('false');
|
if (this.classList.contains('viewer-body')) return testRect(0, 0, 400, 700);
|
||||||
|
return testRect(0, 0, 0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FileViewer
|
||||||
|
projectId="project-1"
|
||||||
|
projectKind="prototype"
|
||||||
|
file={htmlPreviewFile()}
|
||||||
|
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('comment-preview-layout').className).toContain(
|
||||||
|
'comment-preview-layer-side-dock-stacked',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
getBoundingClientRectSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps saved comment pins visible while adding another comment', async () => {
|
it('keeps saved comment pins visible while adding another comment', async () => {
|
||||||
|
|
@ -2543,16 +2702,14 @@ describe('FileViewer tweaks toolbar', () => {
|
||||||
expect(screen.queryByText('Do not recreate this stale comment')).toBeNull();
|
expect(screen.queryByText('Do not recreate this stale comment')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes the comment side panel from the header close button', () => {
|
it('moves focus between comment side panel toggles when collapsing and expanding without a pre-focused click target', async () => {
|
||||||
const onCollapseChange = vi.fn();
|
const onCollapseChange = vi.fn();
|
||||||
const onClose = vi.fn();
|
|
||||||
const onSelectAll = vi.fn();
|
const onSelectAll = vi.fn();
|
||||||
const onReply = vi.fn();
|
const onReply = vi.fn();
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [open, setOpen] = useState(true);
|
return (
|
||||||
return open ? (
|
|
||||||
<CommentSidePanel
|
<CommentSidePanel
|
||||||
comments={[
|
comments={[
|
||||||
{
|
{
|
||||||
|
|
@ -2579,10 +2736,6 @@ describe('FileViewer tweaks toolbar', () => {
|
||||||
onCollapseChange(next);
|
onCollapseChange(next);
|
||||||
setCollapsed(next);
|
setCollapsed(next);
|
||||||
}}
|
}}
|
||||||
onClose={() => {
|
|
||||||
onClose();
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
onToggleSelect={() => {}}
|
onToggleSelect={() => {}}
|
||||||
onSelectAll={onSelectAll}
|
onSelectAll={onSelectAll}
|
||||||
onClearSelection={() => {}}
|
onClearSelection={() => {}}
|
||||||
|
|
@ -2591,7 +2744,7 @@ describe('FileViewer tweaks toolbar', () => {
|
||||||
sending={false}
|
sending={false}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
) : null;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
render(<Harness />);
|
render(<Harness />);
|
||||||
|
|
@ -2603,13 +2756,69 @@ describe('FileViewer tweaks toolbar', () => {
|
||||||
fireEvent.click(screen.getByText('不要github,换成微信').closest('[data-testid="comment-side-item"]')!);
|
fireEvent.click(screen.getByText('不要github,换成微信').closest('[data-testid="comment-side-item"]')!);
|
||||||
expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ id: 'comment-1' }));
|
expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ id: 'comment-1' }));
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
const hideComments = screen.getByRole('button', { name: /hide comments/i });
|
||||||
|
|
||||||
expect(onClose).toHaveBeenCalledOnce();
|
fireEvent.click(hideComments);
|
||||||
expect(onCollapseChange).not.toHaveBeenCalled();
|
|
||||||
|
expect(onCollapseChange).toHaveBeenLastCalledWith(true);
|
||||||
expect(screen.queryByText('不要github,换成微信')).toBeNull();
|
expect(screen.queryByText('不要github,换成微信')).toBeNull();
|
||||||
expect(screen.queryByTestId('comment-side-selectbar')).toBeNull();
|
expect(screen.queryByTestId('comment-side-selectbar')).toBeNull();
|
||||||
expect(screen.queryByTestId('comment-side-collapsed-rail')).toBeNull();
|
const showComments = screen.getByTestId('comment-side-collapsed-rail');
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(showComments));
|
||||||
|
|
||||||
|
fireEvent.click(showComments);
|
||||||
|
|
||||||
|
expect(onCollapseChange).toHaveBeenLastCalledWith(false);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.activeElement).toBe(screen.getByRole('button', { name: /hide comments/i }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces comment side dock disclosure state without pointing at an unmounted panel', () => {
|
||||||
|
function Harness() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
return (
|
||||||
|
<CommentSidePanel
|
||||||
|
comments={[]}
|
||||||
|
selectedIds={new Set()}
|
||||||
|
activeCommentId={null}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onCollapsedChange={setCollapsed}
|
||||||
|
onToggleSelect={() => {}}
|
||||||
|
onSelectAll={() => {}}
|
||||||
|
onClearSelection={() => {}}
|
||||||
|
onReply={() => {}}
|
||||||
|
onSendSelected={() => {}}
|
||||||
|
sending={false}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<Harness />);
|
||||||
|
|
||||||
|
const panel = screen.getByTestId('comment-side-panel');
|
||||||
|
const hideComments = screen.getByRole('button', { name: /hide comments/i });
|
||||||
|
const panelId = panel.id;
|
||||||
|
|
||||||
|
expect(panelId).toBeTruthy();
|
||||||
|
expect(hideComments.getAttribute('aria-controls')).toBe(panelId);
|
||||||
|
expect(hideComments.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(hideComments);
|
||||||
|
|
||||||
|
const showComments = screen.getByTestId('comment-side-collapsed-rail');
|
||||||
|
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
|
||||||
|
expect(document.getElementById(panelId)).toBeNull();
|
||||||
|
expect(showComments.getAttribute('aria-controls')).toBeNull();
|
||||||
|
expect(showComments.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the inspect panel shrink inside narrow preview layouts', () => {
|
||||||
|
const css = readFileSync(join(process.cwd(), 'src/styles/viewer/core.css'), 'utf8');
|
||||||
|
const rule = css.match(/\.inspect-panel\s*\{[^}]+\}/)?.[0] ?? '';
|
||||||
|
|
||||||
|
expect(rule).toContain('width: min(296px, calc(100% - 28px));');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not classify text labels containing a standalone article as links', () => {
|
it('does not classify text labels containing a standalone article as links', () => {
|
||||||
|
|
@ -2637,7 +2846,6 @@ describe('FileViewer tweaks toolbar', () => {
|
||||||
activeCommentId={null}
|
activeCommentId={null}
|
||||||
collapsed={false}
|
collapsed={false}
|
||||||
onCollapsedChange={() => {}}
|
onCollapsedChange={() => {}}
|
||||||
onClose={() => {}}
|
|
||||||
onToggleSelect={() => {}}
|
onToggleSelect={() => {}}
|
||||||
onSelectAll={() => {}}
|
onSelectAll={() => {}}
|
||||||
onClearSelection={() => {}}
|
onClearSelection={() => {}}
|
||||||
|
|
|
||||||
|
|
@ -132,4 +132,27 @@ describe('conversation timestamps', () => {
|
||||||
|
|
||||||
expect(conversationMetaLabel(conversation, t as never)).toBe('15s');
|
expect(conversationMetaLabel(conversation, t as never)).toBe('15s');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers cumulative conversation duration over the latest run duration', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2025-01-15T14:00:00Z'));
|
||||||
|
const t = (key: string, vars?: Record<string, string | number>) =>
|
||||||
|
key === 'common.minutesShort' ? `${vars?.n}m` : key;
|
||||||
|
const conversation = {
|
||||||
|
id: 'conv-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
title: 'Multi-run session',
|
||||||
|
createdAt: Date.parse('2025-01-15T12:00:00Z'),
|
||||||
|
updatedAt: Date.parse('2025-01-15T12:03:00Z'),
|
||||||
|
totalDurationMs: 85_000,
|
||||||
|
latestRun: {
|
||||||
|
status: 'succeeded',
|
||||||
|
startedAt: 120_000,
|
||||||
|
endedAt: 130_000,
|
||||||
|
durationMs: 10_000,
|
||||||
|
},
|
||||||
|
} satisfies Conversation & { totalDurationMs: number };
|
||||||
|
|
||||||
|
expect(conversationMetaLabel(conversation, t as never)).toBe('1m 25s');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,5 @@
|
||||||
# 2. Run the relevant nix build/flake check
|
# 2. Run the relevant nix build/flake check
|
||||||
# 3. Copy the expected hash printed by Nix into the matching field below
|
# 3. Copy the expected hash printed by Nix into the matching field below
|
||||||
daemonHash = "sha256-nSMVyVSHfcXV5fLMXM3tfdQxZRb+FNF6P4iuJw/Z8Mo=";
|
daemonHash = "sha256-nSMVyVSHfcXV5fLMXM3tfdQxZRb+FNF6P4iuJw/Z8Mo=";
|
||||||
webHash = "sha256-QOufFb3Hb5js3jK6QEl3WfnxNAa4DdZfMKoALTHY4hI=";
|
webHash = "sha256-IlXE7iNoT/+mcVbtzhJdcP5fNs7Hk8AYZMxfJ33dXck=";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
"description": "Local-first design product: detects your installed code-agent CLI, runs design skills + design systems, streams artifacts into a sandboxed preview.",
|
"description": "Local-first design product: detects your installed code-agent CLI, runs design skills + design systems, streams artifacts into a sandboxed preview.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"od": "./apps/daemon/dist/cli.js"
|
"od": "./apps/daemon/bin/od.mjs"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "node ./scripts/postinstall.mjs",
|
"postinstall": "node ./scripts/postinstall.mjs",
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
"tools-pack": "pnpm exec tools-pack",
|
"tools-pack": "pnpm exec tools-pack",
|
||||||
"tools-serve": "pnpm exec tools-serve",
|
"tools-serve": "pnpm exec tools-serve",
|
||||||
"nix:update-hash": "node --experimental-strip-types ./scripts/update-nix-pnpm-deps-hash.ts",
|
"nix:update-hash": "node --experimental-strip-types ./scripts/update-nix-pnpm-deps-hash.ts",
|
||||||
"guard": "tsx ./scripts/guard.ts && node --import tsx --test scripts/style-policy.test.ts scripts/approve-fork-pr-workflows.test.ts",
|
"guard": "tsx ./scripts/guard.ts && node --import tsx --test scripts/style-policy.test.ts scripts/approve-fork-pr-workflows.test.ts scripts/postinstall.test.ts",
|
||||||
"i18n:check": "tsx ./scripts/i18n-check.ts",
|
"i18n:check": "tsx ./scripts/i18n-check.ts",
|
||||||
"i18n:coverage": "tsx ./scripts/i18n-coverage-report.ts",
|
"i18n:coverage": "tsx ./scripts/i18n-coverage-report.ts",
|
||||||
"sync:community-pets": "node --experimental-strip-types scripts/sync-community-pets.ts",
|
"sync:community-pets": "node --experimental-strip-types scripts/sync-community-pets.ts",
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
"typecheck": "pnpm -r --workspace-concurrency=4 --if-present run typecheck && tsc -p scripts/tsconfig.json --noEmit"
|
"typecheck": "pnpm -r --workspace-concurrency=4 --if-present run typecheck && tsc -p scripts/tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@open-design/daemon": "workspace:*",
|
||||||
"@open-design/tools-dev": "workspace:*",
|
"@open-design/tools-dev": "workspace:*",
|
||||||
"@open-design/tools-pack": "workspace:*",
|
"@open-design/tools-pack": "workspace:*",
|
||||||
"@open-design/tools-serve": "workspace:*",
|
"@open-design/tools-serve": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
import type { ResearchOptions } from './research';
|
import type { ResearchOptions } from './research';
|
||||||
import type { RunContextSelection } from './context.js';
|
import type { RunContextSelection } from './context.js';
|
||||||
import type { MediaExecutionPolicy } from './media.js';
|
import type { MediaExecutionPolicy } from './media.js';
|
||||||
|
import type { McpAuthMode, McpServerConfig, McpTransport } from './mcp';
|
||||||
|
|
||||||
export type ChatRole = 'user' | 'assistant';
|
export type ChatRole = 'user' | 'assistant';
|
||||||
export type ChatCommentSelectionKind = PreviewCommentSelectionKind | 'visual';
|
export type ChatCommentSelectionKind = PreviewCommentSelectionKind | 'visual';
|
||||||
|
|
@ -45,6 +46,12 @@ export interface ChatRequest {
|
||||||
* local providers.
|
* local providers.
|
||||||
*/
|
*/
|
||||||
mediaExecution?: MediaExecutionPolicy;
|
mediaExecution?: MediaExecutionPolicy;
|
||||||
|
/**
|
||||||
|
* Run-scoped tool bundle supplied by an orchestrator such as Muginn.
|
||||||
|
* These servers are made available only to the spawned agent for this run
|
||||||
|
* and are never written into the persistent Settings MCP registry.
|
||||||
|
*/
|
||||||
|
toolBundle?: RunScopedToolBundle;
|
||||||
/**
|
/**
|
||||||
* Optional analytics context for the v2 run_created / run_finished
|
* Optional analytics context for the v2 run_created / run_finished
|
||||||
* events. The daemon never trusts these for behavior — they only
|
* events. The daemon never trusts these for behavior — they only
|
||||||
|
|
@ -109,6 +116,31 @@ export interface ChatAnalyticsHints {
|
||||||
designSystemRunContext?: ChatAnalyticsDesignSystemRunContext;
|
designSystemRunContext?: ChatAnalyticsDesignSystemRunContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RunScopedMcpServerConfig extends Omit<McpServerConfig, 'enabled'> {
|
||||||
|
/**
|
||||||
|
* Omitted means enabled for this run. The daemon normalizes run-scoped
|
||||||
|
* inputs through the same sanitizer as persisted MCP config, but callers
|
||||||
|
* should not need to send persisted-settings boilerplate for disposable
|
||||||
|
* tool bundles.
|
||||||
|
*/
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunScopedToolBundle {
|
||||||
|
mcpServers?: RunScopedMcpServerConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunScopedToolBundleSummary {
|
||||||
|
mcpServers: Array<{
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
templateId?: string;
|
||||||
|
transport: McpTransport;
|
||||||
|
enabled: boolean;
|
||||||
|
authMode?: McpAuthMode;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatRunCreateRequest extends ChatRequest {
|
export interface ChatRunCreateRequest extends ChatRequest {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
|
@ -130,6 +162,8 @@ export interface McpRunCreateRequest {
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
pluginInputs?: Record<string, unknown>;
|
pluginInputs?: Record<string, unknown>;
|
||||||
|
mediaExecution?: MediaExecutionPolicy;
|
||||||
|
toolBundle?: RunScopedToolBundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChatRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled';
|
export type ChatRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled';
|
||||||
|
|
@ -219,6 +253,8 @@ export interface ChatRunStatusResponse {
|
||||||
eventsLogPath?: string | null;
|
eventsLogPath?: string | null;
|
||||||
/** Present on daemon run status responses that know the effective run policy. */
|
/** Present on daemon run status responses that know the effective run policy. */
|
||||||
mediaExecution?: MediaExecutionPolicy;
|
mediaExecution?: MediaExecutionPolicy;
|
||||||
|
/** Run-scoped tool bundle summary with secrets and command details redacted. */
|
||||||
|
toolBundle?: RunScopedToolBundleSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatRunListResponse {
|
export interface ChatRunListResponse {
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,7 @@ export interface Conversation {
|
||||||
title: string | null;
|
title: string | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
totalDurationMs?: number;
|
||||||
latestRun?: {
|
latestRun?: {
|
||||||
status: ChatRunStatus;
|
status: ChatRunStatus;
|
||||||
startedAt?: number;
|
startedAt?: number;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@open-design/daemon':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:apps/daemon
|
||||||
'@open-design/tools-dev':
|
'@open-design/tools-dev':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:tools/dev
|
version: link:tools/dev
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,8 @@ const residualAllowedExactPaths = new Set([
|
||||||
// executed directly by Node and are not loaded by the app runtime.
|
// executed directly by Node and are not loaded by the app runtime.
|
||||||
"scripts/import-prompt-templates.mjs",
|
"scripts/import-prompt-templates.mjs",
|
||||||
"scripts/postinstall.mjs",
|
"scripts/postinstall.mjs",
|
||||||
|
// Checked-in bin shim so pnpm can link `od` before daemon dist output exists.
|
||||||
|
"apps/daemon/bin/od.mjs",
|
||||||
"apps/packaged/esbuild.config.mjs",
|
"apps/packaged/esbuild.config.mjs",
|
||||||
// Browser service workers must be served as JavaScript files.
|
// Browser service workers must be served as JavaScript files.
|
||||||
"apps/web/public/od-notifications-sw.js",
|
"apps/web/public/od-notifications-sw.js",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const buildTargets = [
|
||||||
"packages/sidecar-proto",
|
"packages/sidecar-proto",
|
||||||
"packages/sidecar",
|
"packages/sidecar",
|
||||||
"packages/diagnostics",
|
"packages/diagnostics",
|
||||||
|
"apps/daemon",
|
||||||
"tools/dev",
|
"tools/dev",
|
||||||
"tools/pack",
|
"tools/pack",
|
||||||
"tools/serve",
|
"tools/serve",
|
||||||
|
|
|
||||||
159
scripts/postinstall.test.ts
Normal file
159
scripts/postinstall.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const repoRoot = join(import.meta.dirname, "..");
|
||||||
|
|
||||||
|
function readJson(path: string): unknown {
|
||||||
|
return JSON.parse(readFileSync(join(repoRoot, path), "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPackageJson(path: string): Record<string, unknown> {
|
||||||
|
const manifest = readJson(path);
|
||||||
|
assert(typeof manifest === "object" && manifest !== null);
|
||||||
|
return manifest as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageName(manifest: unknown): string {
|
||||||
|
assert(typeof manifest === "object" && manifest !== null);
|
||||||
|
const name = (manifest as { name?: unknown }).name;
|
||||||
|
assert(typeof name === "string");
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageBinTargets(manifest: unknown): string[] {
|
||||||
|
assert(typeof manifest === "object" && manifest !== null);
|
||||||
|
const bin = (manifest as { bin?: unknown }).bin;
|
||||||
|
if (typeof bin === "string") return [bin];
|
||||||
|
if (typeof bin !== "object" || bin === null) return [];
|
||||||
|
return Object.values(bin).filter((value): value is string => typeof value === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
function dependencySpecifier(manifest: Record<string, unknown>, name: string): string | undefined {
|
||||||
|
const dependencyFields = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"] as const;
|
||||||
|
for (const field of dependencyFields) {
|
||||||
|
const dependencies = manifest[field];
|
||||||
|
if (typeof dependencies !== "object" || dependencies === null) continue;
|
||||||
|
const specifier = (dependencies as Record<string, unknown>)[name];
|
||||||
|
if (typeof specifier === "string") return specifier;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function distDelegatingBinTargets(directory: string, manifest: unknown): string[] {
|
||||||
|
return packageBinTargets(manifest).filter((binTarget) => {
|
||||||
|
if (binTarget.startsWith("./dist/")) return true;
|
||||||
|
const source = readFileSync(join(repoRoot, directory, binTarget), "utf8");
|
||||||
|
return source.includes("../dist/") || source.includes("./dist/") || source.includes("/dist/");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceDependencyNames(manifest: unknown, includeDevDependencies = false): Set<string> {
|
||||||
|
assert(typeof manifest === "object" && manifest !== null);
|
||||||
|
const dependencyFields = includeDevDependencies
|
||||||
|
? ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]
|
||||||
|
: ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||||
|
const names = new Set<string>();
|
||||||
|
|
||||||
|
for (const field of dependencyFields) {
|
||||||
|
const dependencies = (manifest as Record<string, unknown>)[field];
|
||||||
|
if (typeof dependencies !== "object" || dependencies === null) continue;
|
||||||
|
for (const [name, version] of Object.entries(dependencies)) {
|
||||||
|
if (typeof version === "string" && version.startsWith("workspace:")) {
|
||||||
|
names.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
function postinstallBuildTargets(): Set<string> {
|
||||||
|
const source = readFileSync(join(repoRoot, "scripts/postinstall.mjs"), "utf8");
|
||||||
|
const targets = [...source.matchAll(/"([^"]+)"/g)]
|
||||||
|
.map((match) => match[1])
|
||||||
|
.filter((value): value is string => value != null && /^(?:apps|packages|tools)\//.test(value));
|
||||||
|
return new Set(targets);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspacePackageDirectories(): string[] {
|
||||||
|
const scopedPackageDirectories = ["apps", "packages", "tools"].flatMap((scope) =>
|
||||||
|
readdirSync(join(repoRoot, scope), { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => `${scope}/${entry.name}`),
|
||||||
|
);
|
||||||
|
return ["e2e", ...scopedPackageDirectories]
|
||||||
|
.filter((directory) => existsSync(join(repoRoot, directory, "package.json")))
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("workspace bin entries use checked-in targets so pnpm can link them before postinstall", () => {
|
||||||
|
const manifests = new Map(
|
||||||
|
workspacePackageDirectories().map((directory) => [
|
||||||
|
directory,
|
||||||
|
readJson(`${directory}/package.json`),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const consumedWorkspacePackages = new Set<string>();
|
||||||
|
for (const manifest of manifests.values()) {
|
||||||
|
for (const name of workspaceDependencyNames(manifest)) {
|
||||||
|
consumedWorkspacePackages.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlinkableBins = [...manifests.entries()]
|
||||||
|
.filter(([, manifest]) => consumedWorkspacePackages.has(packageName(manifest)))
|
||||||
|
.flatMap(([directory, manifest]) =>
|
||||||
|
packageBinTargets(manifest).map((binTarget) => ({
|
||||||
|
binTarget,
|
||||||
|
directory,
|
||||||
|
resolvedPath: join(repoRoot, directory, binTarget),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.filter(({ resolvedPath }) => !existsSync(resolvedPath))
|
||||||
|
.map(({ binTarget, directory }) => `${directory}:${binTarget}`);
|
||||||
|
|
||||||
|
assert.deepEqual(unlinkableBins, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("root workspace depends on the daemon package so pnpm exec resolves the od bin", () => {
|
||||||
|
const rootManifest = readPackageJson("package.json");
|
||||||
|
const daemonManifest = readPackageJson("apps/daemon/package.json");
|
||||||
|
|
||||||
|
assert.equal(dependencySpecifier(rootManifest, "@open-design/daemon"), "workspace:*");
|
||||||
|
assert.deepEqual((rootManifest as { bin?: unknown }).bin, {
|
||||||
|
od: "./apps/daemon/bin/od.mjs",
|
||||||
|
});
|
||||||
|
assert.deepEqual((daemonManifest as { bin?: unknown }).bin, {
|
||||||
|
od: "./bin/od.mjs",
|
||||||
|
});
|
||||||
|
assert.equal(existsSync(join(repoRoot, "apps/daemon/bin/od.mjs")), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("postinstall builds workspace packages whose linkable bins delegate to dist", () => {
|
||||||
|
const rootManifest = readPackageJson("package.json");
|
||||||
|
const manifests = new Map(
|
||||||
|
workspacePackageDirectories().map((directory) => [
|
||||||
|
directory,
|
||||||
|
readJson(`${directory}/package.json`),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const consumedWorkspacePackages = new Set<string>();
|
||||||
|
for (const name of workspaceDependencyNames(rootManifest, true)) {
|
||||||
|
consumedWorkspacePackages.add(name);
|
||||||
|
}
|
||||||
|
for (const manifest of manifests.values()) {
|
||||||
|
for (const name of workspaceDependencyNames(manifest)) {
|
||||||
|
consumedWorkspacePackages.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingBuildTargets = [...manifests.entries()]
|
||||||
|
.filter(([, manifest]) => consumedWorkspacePackages.has(packageName(manifest)))
|
||||||
|
.filter(([directory, manifest]) => distDelegatingBinTargets(directory, manifest).length > 0)
|
||||||
|
.map(([directory]) => directory)
|
||||||
|
.filter((directory) => !postinstallBuildTargets().has(directory));
|
||||||
|
|
||||||
|
assert.deepEqual(missingBuildTargets, []);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue