From 08c350fb0f1eef4a8485c2d89fffea02ded33837 Mon Sep 17 00:00:00 2001 From: lefarcen <935902669@qq.com> Date: Fri, 29 May 2026 11:06:06 +0800 Subject: [PATCH 01/56] fix(analytics): bucket feedback agent/model directly on the event (#3240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(analytics): bucket feedback agent/model directly on the event Reason × agent / reason × model splits on `assistant_feedback_reason_submit` were 25-74% `unknown` because the event only carried `run_id` — analyses had to join back to `run_created/run_finished`, which loses rows whenever the feedback is given to a message whose run sits outside the query window (the common case for feedback on older messages), and whose `model_id` was `null` to begin with (the user didn't pick a specific model — went with the agent's default). Carry `agent_provider_id` and `model_id` directly on every feedback event so the analyses no longer need to join. Replace `null/unknown` with the `default` bucket via `modelIdForTracking` (and let `agentIdToTracking` fall through to `other`) at every emit site — `null` was an analyst-hostile mix of "no selection" and "join failed"; `default` is a real, analysable bucket. On `run_finished`, upgrade the model to the agent-reported value from initializing/model status events when the user did not pick one — covers ACP, claude-stream, copilot-stream, json-event-stream, qoder, pi-rpc. * fix(analytics): use feedbackAgentProviderIdToTracking and assistantFeedbackModelId for feedback events Wire API-mode agent ids (anthropic-api → anthropic) and agentName-parsed model ids through the feedback emit path. Previously the feedback props used agentIdToTracking (no anthropic-api case) and assistantModelDetail (no agentName fallback), causing model_id='default' and agent_provider_id='other' for API-mode agents. Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code) * fix(analytics): extend feedback/run schema for full agent/model coverage Layered on top of the conflict resolution and the v1 emit switchover in 0c1b30440. Three things the prior commits did not cover: 1) The v2 `assistant_feedback_*` family (page='studio') shares `AssistantFeedbackBase`. Add `agent_provider_id` + `model_id` once on the base so all four derived emits (reason_view, click, reason_click, reason_submit) carry the same context as the v1 family, instead of leaving the v2 dashboard with the same `unknown` gap the v1 PR was trying to close. 2) Tighten `FeedbackSubmitResultProps.model_id` and `feedbackAgentProviderIdToTracking` from `string | null` / `TrackingFeedbackProviderId | null` to non-null. The web emit paths already bucket null/empty through `modelIdForTracking` and the `?? 'other'` fallback; collapsing that at the helper / contract layer means `null` becomes a TS error at every new emit site, so we can't regress the unknown bucket again in a future event. 3) Comment on `run_finished.model_id` so reviewers reading `finishedModelId` see why the agent-reported value upgrades the request-side one. * fix(analytics): continue event scan past usage to find agent-reported model The reverse scan for agentReportedModel was broken: the loop broke on the first usage event (terminal) before ever reaching the status:initializing or status:model event (emitted at run start, lower index). This meant run_finished.model_id always fell through to modelIdForTracking(null) = 'default' for any run that reported usage tokens. Fix: track haveUsageTokens as a flag and defer the break until both usage tokens are found and either the model is not needed (user picked one) or the agent-reported model has been captured. Extract the logic into scanRunEventsForFinishedProps for unit testability. Tests: six new cases in run-lifecycle-analytics.test.ts cover the initializing→usage append order, ACP status:model, detail field fallback, early exit when reqBodyModel is set, no-status event, and empty events. Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code) * fix(analytics): guard usage block with !haveUsageTokens to prevent early events overwriting terminal tokens In the reverse-scan loop of scanRunEventsForFinishedProps, the usage block lacked a !haveUsageTokens guard. When needAgentModel is true and the agentReportedModel lives at the start of the run (lower index), the loop walks all the way back past multiple usage events (one per step/turn in multi-step runs), overwriting inputTokens/outputTokens on each pass. The surviving values were those of the earliest step, not the terminal total. Adding !haveUsageTokens to the usage block condition ensures only the first (terminal) usage event seen in reverse sets the token counts; subsequent earlier usage events are skipped while the scan continues for agentReportedModel. Adds a test case for initializing(model) → usage(step1) → usage(terminal) asserting both terminal token counts and agentReportedModel. Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code) --- apps/daemon/src/server.ts | 94 +++++++++++++++---- .../tests/run-lifecycle-analytics.test.ts | 87 ++++++++++++++++- apps/web/src/components/AssistantMessage.tsx | 22 ++++- packages/contracts/src/analytics/events.ts | 59 +++++++++--- 4 files changed, 225 insertions(+), 37 deletions(-) diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index ef02826ac..bc5a6cd50 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -242,6 +242,7 @@ import { observePendingInstallerApplyAttempts } from './update-apply-observation import { agentIdToTracking, deriveConfigureGlobals, + modelIdForTracking, projectKindToTracking, type ObservabilityEventRequest, } from '@open-design/contracts/analytics'; @@ -1915,6 +1916,53 @@ export function __forTestResolveRunProjectKindForAnalytics(args) { return resolveRunProjectKindForAnalytics(args); } +// Scans run.events newest→oldest to extract usage token counts and the +// agent-reported model name. The scan must not short-circuit on usage +// before reaching the model signal: usage is a terminal event while +// status:initializing/model is emitted at the very start of the run, so +// in reverse iteration usage is seen first. The loop continues until both +// usage tokens are found AND (the caller already has a model from reqBody +// OR the agent-reported model has been found). +function scanRunEventsForFinishedProps(events, reqBodyModel) { + let inputTokens; + let outputTokens; + let agentReportedModel = null; + const needAgentModel = !(typeof reqBodyModel === 'string' && reqBodyModel.trim()); + let haveUsageTokens = false; + for (let i = events.length - 1; i >= 0; i -= 1) { + const ev = events[i]; + const data = ev?.data; + if (ev?.event === 'agent' && data?.type === 'usage' && data.usage && !haveUsageTokens) { + const u = data.usage; + if (typeof u.input_tokens === 'number') inputTokens = u.input_tokens; + if (typeof u.output_tokens === 'number') outputTokens = u.output_tokens; + if (inputTokens !== undefined || outputTokens !== undefined) haveUsageTokens = true; + } + if ( + !agentReportedModel && + ev?.event === 'agent' && + data?.type === 'status' && + (data.label === 'model' || data.label === 'initializing') + ) { + const candidate = + typeof data.model === 'string' + ? data.model + : typeof data.detail === 'string' + ? data.detail + : null; + if (candidate && candidate.trim()) { + agentReportedModel = candidate.trim(); + } + } + if (haveUsageTokens && (!needAgentModel || agentReportedModel)) break; + } + return { inputTokens, outputTokens, agentReportedModel }; +} + +export function __forTestScanRunEventsForFinishedProps(events, reqBodyModel) { + return scanRunEventsForFinishedProps(events, reqBodyModel); +} + function githubRepoNameFromPluginName(name) { const slug = String(name) .toLowerCase() @@ -12641,11 +12689,17 @@ export async function startServer({ ? (reqBody.attachments as unknown[]).length > 0 : false, user_query_tokens: userQueryTokens, - model_id: typeof reqBody.model === 'string' ? reqBody.model : null, - agent_provider_id: - typeof reqBody.agentId === 'string' - ? agentIdToTracking(reqBody.agentId) - : null, + // `modelIdForTracking` buckets null/empty into `'default'` so the + // PostHog `model_id` column always has an analysable value. The + // user-picked model only lands here on `run_created` (the agent + // hasn't initialised yet); `run_finished` below upgrades this to + // the agent-reported model when available. + model_id: modelIdForTracking( + typeof reqBody.model === 'string' ? reqBody.model : null, + ), + agent_provider_id: agentIdToTracking( + typeof reqBody.agentId === 'string' ? reqBody.agentId : null, + ), skill_id: typeof reqBody.skillId === 'string' ? reqBody.skillId : null, mcp_id: null, token_count_source: userQueryTokens > 0 ? 'estimated' : 'unknown', @@ -12671,26 +12725,22 @@ export async function startServer({ // child close without error event, etc.). const result = runResultFromStatus(status.status); const errorCode = deriveRunErrorCode(status); - let inputTokens: number | undefined; - let outputTokens: number | undefined; - for (let i = run.events.length - 1; i >= 0; i -= 1) { - const ev = run.events[i]; - const data = ev?.data as - | { type?: string; usage?: Record | null } - | null - | undefined; - if (ev?.event === 'agent' && data?.type === 'usage' && data.usage) { - const u = data.usage; - if (typeof u.input_tokens === 'number') inputTokens = u.input_tokens; - if (typeof u.output_tokens === 'number') outputTokens = u.output_tokens; - if (inputTokens !== undefined || outputTokens !== undefined) break; - } - } + // ACP reports { type:'status', label:'model', model: } after + // session/new; stream adapters report { type:'status', + // label:'initializing', model: } at run start. The scan must + // not short-circuit on usage before reaching the model signal — + // see `scanRunEventsForFinishedProps` for the invariant. + const { inputTokens, outputTokens, agentReportedModel } = + scanRunEventsForFinishedProps(run.events, reqBody.model); const haveUsage = inputTokens !== undefined || outputTokens !== undefined; const totalTokens = inputTokens !== undefined && outputTokens !== undefined ? inputTokens + outputTokens : undefined; + const finishedModelId = + typeof reqBody.model === 'string' && reqBody.model.trim() + ? modelIdForTracking(reqBody.model) + : modelIdForTracking(agentReportedModel); design.analytics.capture({ eventName: 'run_finished', context: analyticsContext, @@ -12702,6 +12752,10 @@ export async function startServer({ // `design_system_generation` to match the run_created shape. area: isDesignSystemRun ? 'design_system_generation' : 'chat_panel', result, + // `model_id` upgrades the request-side value with the + // agent-reported model on terminal state; see + // `finishedModelId` derivation above. + model_id: finishedModelId, // Incremental count of `.html` paths the run produced or // modified, deduped per file. Replaces the hard-coded `0` // that masked the "did this run actually generate an diff --git a/apps/daemon/tests/run-lifecycle-analytics.test.ts b/apps/daemon/tests/run-lifecycle-analytics.test.ts index f09aa230d..d9d052ff7 100644 --- a/apps/daemon/tests/run-lifecycle-analytics.test.ts +++ b/apps/daemon/tests/run-lifecycle-analytics.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { __forTestResolveRunProjectKindForAnalytics } from '../src/server.js'; +import { + __forTestResolveRunProjectKindForAnalytics, + __forTestScanRunEventsForFinishedProps, +} from '../src/server.js'; describe('run lifecycle analytics', () => { it('falls back to stored project metadata when analytics hints omit project kind', () => { @@ -38,3 +41,85 @@ describe('run lifecycle analytics', () => { ).toBe('design_system'); }); }); + +describe('scanRunEventsForFinishedProps', () => { + function usageEvent(inputTokens: number, outputTokens: number) { + return { event: 'agent', data: { type: 'usage', usage: { input_tokens: inputTokens, output_tokens: outputTokens } } }; + } + + function initializingEvent(model: string) { + return { event: 'agent', data: { type: 'status', label: 'initializing', model } }; + } + + function modelEvent(model: string) { + return { event: 'agent', data: { type: 'status', label: 'model', model } }; + } + + it('extracts agent model from initializing event when usage event follows it (real run order)', () => { + // Append order mirrors a real run: initializing first, usage last. + // Reverse scan must not stop at usage before reading the model signal. + const events = [initializingEvent('claude-opus-4'), usageEvent(100, 200)]; + const result = __forTestScanRunEventsForFinishedProps(events, ''); + expect(result.agentReportedModel).toBe('claude-opus-4'); + expect(result.inputTokens).toBe(100); + expect(result.outputTokens).toBe(200); + }); + + it('extracts agent model from ACP status:model event when usage follows it', () => { + const events = [modelEvent('gpt-4o'), usageEvent(50, 75)]; + const result = __forTestScanRunEventsForFinishedProps(events, ''); + expect(result.agentReportedModel).toBe('gpt-4o'); + expect(result.inputTokens).toBe(50); + }); + + it('reads model from detail field when model field is absent', () => { + const events = [ + { event: 'agent', data: { type: 'status', label: 'initializing', detail: 'gemini-pro' } }, + usageEvent(10, 20), + ]; + const result = __forTestScanRunEventsForFinishedProps(events, ''); + expect(result.agentReportedModel).toBe('gemini-pro'); + }); + + it('stops early on usage when reqBodyModel is set (no need to scan for agent model)', () => { + // When the user picked a model, needAgentModel=false so the loop exits + // as soon as usage tokens are found — it does not need to walk back to + // the initializing event. + const events = [initializingEvent('claude-opus-4'), usageEvent(30, 40)]; + const result = __forTestScanRunEventsForFinishedProps(events, 'claude-haiku-4-5'); + expect(result.inputTokens).toBe(30); + expect(result.outputTokens).toBe(40); + // agentReportedModel may or may not be found (early exit), but the caller + // ignores it when reqBodyModel is set — no assertion on its value here. + }); + + it('returns null agentReportedModel when no status event is present', () => { + const events = [usageEvent(5, 10)]; + const result = __forTestScanRunEventsForFinishedProps(events, ''); + expect(result.agentReportedModel).toBeNull(); + expect(result.inputTokens).toBe(5); + }); + + it('handles empty event list', () => { + const result = __forTestScanRunEventsForFinishedProps([], ''); + expect(result.agentReportedModel).toBeNull(); + expect(result.inputTokens).toBeUndefined(); + expect(result.outputTokens).toBeUndefined(); + }); + + it('uses terminal usage event tokens when multiple usage events exist', () => { + // Multi-step/multi-turn runs emit one usage event per step/turn (json-event-stream, + // pi-rpc). Reverse scan hits the terminal (highest-index) usage first; the + // !haveUsageTokens guard must prevent earlier usage events from overwriting those values + // while the loop continues scanning back for agentReportedModel. + const events = [ + initializingEvent('claude-opus-4'), + usageEvent(100, 200), // step 1 — must NOT overwrite terminal values + usageEvent(500, 750), // terminal turn — seen first in reverse, values must survive + ]; + const result = __forTestScanRunEventsForFinishedProps(events, ''); + expect(result.agentReportedModel).toBe('claude-opus-4'); + expect(result.inputTokens).toBe(500); + expect(result.outputTokens).toBe(750); + }); +}); diff --git a/apps/web/src/components/AssistantMessage.tsx b/apps/web/src/components/AssistantMessage.tsx index 8164e632a..3a063bd71 100644 --- a/apps/web/src/components/AssistantMessage.tsx +++ b/apps/web/src/components/AssistantMessage.tsx @@ -21,7 +21,9 @@ import { } from "../analytics/events"; import { feedbackAgentProviderIdToTracking, + modelIdForTracking, normalizeCustomReason, + type TrackingFeedbackProviderId, type TrackingFeedbackReasonCode, type TrackingFeedbackRatingWithNone, type TrackingProjectKind, @@ -645,7 +647,7 @@ export function AssistantMessage({ conversationId={conversationId} runId={message.runId ?? null} assistantMessageId={message.id} - modelId={assistantFeedbackModelId(message)} + modelId={modelIdForTracking(assistantFeedbackModelId(message))} agentProviderId={feedbackAgentProviderIdToTracking(message.agentId)} producedFileCount={displayedProduced.length} hasDesignSystemContext={hasDesignSystemContext} @@ -869,8 +871,8 @@ function AssistantFeedback({ conversationId: string | null; runId: string | null; assistantMessageId: string; - modelId: string | null; - agentProviderId: ReturnType; + modelId: string; + agentProviderId: TrackingFeedbackProviderId; producedFileCount: number; }) { const t = useT(); @@ -925,6 +927,8 @@ function AssistantFeedback({ conversation_id: conversationId, assistant_message_id: assistantMessageId, run_id: runId ?? null, + agent_provider_id: agentProviderId, + model_id: modelId, rating: reasonRating, }); } @@ -936,6 +940,8 @@ function AssistantFeedback({ conversationId, assistantMessageId, runId, + agentProviderId, + modelId, ]); const toggleFeedback = (rating: ChatMessageFeedbackRating) => { const nextRating = selected === rating ? null : rating; @@ -959,6 +965,8 @@ function AssistantFeedback({ conversation_id: conversationId, assistant_message_id: assistantMessageId, run_id: runId ?? "", + agent_provider_id: agentProviderId, + model_id: modelId, rating, rating_before: ratingBefore, has_produced_files: producedFileCount > 0, @@ -978,6 +986,8 @@ function AssistantFeedback({ conversation_id: conversationId, assistant_message_id: assistantMessageId, run_id: runId ?? null, + agent_provider_id: agentProviderId, + model_id: modelId, rating: ratingAfter, rating_before: ratingBefore, has_produced_files: producedFileCount > 0, @@ -1017,6 +1027,8 @@ function AssistantFeedback({ conversation_id: conversationId, assistant_message_id: assistantMessageId, run_id: runId ?? "", + agent_provider_id: agentProviderId, + model_id: modelId, rating: reasonRating, ...(reasonJoined ? { reason: reasonJoined } : {}), reason_count: reasonCodes.length, @@ -1041,8 +1053,8 @@ function AssistantFeedback({ conversation_id: conversationId, assistant_message_id: assistantMessageId, run_id: runId ?? "", - model_id: modelId, agent_provider_id: agentProviderId, + model_id: modelId, rating: reasonRating, ...(reasonJoined ? { reason: reasonJoined } : {}), reason_count: reasonCodes.length, @@ -1066,6 +1078,8 @@ function AssistantFeedback({ conversation_id: conversationId, assistant_message_id: assistantMessageId, run_id: runId ?? null, + agent_provider_id: agentProviderId, + model_id: modelId, rating: reasonRating, reason: reasons, reason_count: reasons.length, diff --git a/packages/contracts/src/analytics/events.ts b/packages/contracts/src/analytics/events.ts index 74a9e3974..a018327ce 100644 --- a/packages/contracts/src/analytics/events.ts +++ b/packages/contracts/src/analytics/events.ts @@ -1318,6 +1318,12 @@ export interface ShareOptionPopoverClickProps { } // FEEDBACK clicks (CSV rows 56 / 58) +// `agent_provider_id` / `model_id` are carried on every feedback event so +// `reason × agent` and `reason × model` analyses don't need to join back to +// `run_created` (which loses rows when the feedback is given to a message +// whose run is outside the query window — the dominant source of "unknown" +// in earlier reports). `model_id` uses `'default'` instead of null when the +// user did not pick a specific model; see `modelIdForTracking`. export interface AssistantFeedbackButtonClickProps { page_name: 'chat_panel'; area: 'chat_panel'; @@ -1328,6 +1334,8 @@ export interface AssistantFeedbackButtonClickProps { conversation_id: string | null; assistant_message_id: string; run_id: string; + agent_provider_id: TrackingFeedbackProviderId; + model_id: string; // For `clear_feedback_rating`, `rating` carries the rating that was // cleared (not the previous-before-clear value, which lives in // `rating_before`). Mason flagged the v1 emission supplied the wrong @@ -1347,6 +1355,8 @@ export interface AssistantFeedbackReasonSubmitClickProps { conversation_id: string | null; assistant_message_id: string; run_id: string; + agent_provider_id: TrackingFeedbackProviderId; + model_id: string; rating: 'positive' | 'negative'; reason?: string; reason_count: number; @@ -1730,8 +1740,11 @@ export interface RunCreatedProps { aspect?: string; has_attachment: boolean; user_query_tokens: number; - model_id: string | null; - agent_provider_id: string | null; + // `'default'` when the user did not pick a specific model and the agent's + // own default was selected; use `modelIdForTracking` to bucket null/empty + // into `'default'` at every emit site. + model_id: string; + agent_provider_id: TrackingCliProviderId; skill_id: string | null; mcp_id: string | null; token_count_source: TrackingTokenCountSource; @@ -1853,8 +1866,14 @@ export interface FeedbackSubmitResultProps { conversation_id: string | null; assistant_message_id: string; run_id: string; - model_id: string | null; - agent_provider_id: TrackingFeedbackProviderId | null; + // `model_id` uses `modelIdForTracking` to bucket null/empty into the real + // `'default'` bucket (user accepted the agent's own default), so the + // PostHog `model_id` column never carries the analyst-hostile mix of + // "no selection" and "join failed" that `null/unknown` used to mean. + // `agent_provider_id` carries the BYOK provider when the agent maps to + // one, so reason × provider analyses can split CLI vs API surfaces. + model_id: string; + agent_provider_id: TrackingFeedbackProviderId; rating: 'positive' | 'negative'; reason?: string; reason_count: number; @@ -1874,6 +1893,12 @@ interface AssistantFeedbackBase { // but the product funnel keys off this; we emit `null` rather than dropping // the field so PostHog can distinguish "no run id" from "field forgotten". run_id: string | null; + // Same rationale as `FeedbackSubmitResultProps`: carry agent/model on the + // event itself so reason × agent / reason × model analyses don't depend + // on joining back to `run_created`. Buckets via `modelIdForTracking` and + // `feedbackAgentProviderIdToTracking` at every emit site. + agent_provider_id: TrackingFeedbackProviderId; + model_id: string; rating: TrackingFeedbackRating; } @@ -2069,6 +2094,16 @@ export function executionModeToTracking( return mode === 'daemon' ? 'local_cli' : 'byok'; } +// Model id bucket for analytics. `'default'` represents "user did not pick +// a specific model — went with the agent's own default". This is a real, +// analysable bucket, distinct from `null/unknown` which previously masked +// both "no selection" and "join failed". Callers that have a non-empty +// model string pass it through unchanged. +export function modelIdForTracking(model: string | null | undefined): string { + const trimmed = typeof model === 'string' ? model.trim() : ''; + return trimmed.length > 0 ? trimmed : 'default'; +} + // Daemon agent id (apps/daemon/src/agents.ts) → CSV cli_provider_id. export function agentIdToTracking(agentId: string | null | undefined): TrackingCliProviderId { switch (agentId) { @@ -2105,22 +2140,22 @@ export function agentIdToTracking(agentId: string | null | undefined): TrackingC export function feedbackAgentProviderIdToTracking( agentId: string | null | undefined, -): TrackingFeedbackProviderId | null { +): TrackingFeedbackProviderId { switch (agentId) { case 'anthropic-api': - return byokProtocolToTracking('anthropic'); + return byokProtocolToTracking('anthropic') ?? 'other'; case 'openai-api': - return byokProtocolToTracking('openai'); + return byokProtocolToTracking('openai') ?? 'other'; case 'azure-openai-api': - return byokProtocolToTracking('azure'); + return byokProtocolToTracking('azure') ?? 'other'; case 'google-gemini-api': - return byokProtocolToTracking('google'); + return byokProtocolToTracking('google') ?? 'other'; case 'ollama-cloud-api': - return byokProtocolToTracking('ollama'); + return byokProtocolToTracking('ollama') ?? 'other'; case 'senseaudio-api': - return byokProtocolToTracking('senseaudio'); + return byokProtocolToTracking('senseaudio') ?? 'other'; default: - return agentId ? agentIdToTracking(agentId) : null; + return agentIdToTracking(agentId); } } From 20136c4da936d3c8fd862f545dd5af574264f7b7 Mon Sep 17 00:00:00 2001 From: Weston Houghton Date: Thu, 28 May 2026 23:17:04 -0400 Subject: [PATCH 02/56] fix(skills): stream-copy fallback when skill staging hits cross-fs EPERM (#3249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(skills): fall back to a stream copy when skill staging hits EPERM `fs.cp` copies each file with copy_file_range(2), which the kernel rejects across some filesystem pairs — e.g. a container image layer (`/app`) copied onto a ZFS/overlay bind mount (`/data`) — surfacing EPERM. Node doesn't fall back to a userspace copy, so skill staging failed and degraded to absolute paths, losing the `.od-skills` write barrier. Retry recoverable copy errors (EPERM/EXDEV/ENOTSUP/EOPNOTSUPP) with a dereferencing read/write copy that works across any source/dest filesystem; non-recoverable errors still degrade as before. A test seam injects a synthetic EPERM since the real errno only reproduces on those mounts. * fix(skills): preserve source file mode in the EPERM stream-copy fallback The cross-filesystem fallback copied contents with createWriteStream, which opens the destination at the default 0644 and drops the source's exec bit. Skills shell out to staged helper scripts (e.g. skills/pptx-html-fidelity-audit/scripts/*.py), so on the EPERM/EXDEV path this fallback repairs they would fail with EACCES. chmod (masked to 0o777, so the agent-writable staging copy never inherits setuid/setgid/sticky) + utimes each copied file from the source stat so the fallback matches fs.cp's mode/timestamp preservation. Adds a regression test that stages an executable fixture through the synthetic-EPERM seam and asserts the exec bit survives. --- apps/daemon/src/cwd-aliases.ts | 79 +++++++++++++++--- apps/daemon/tests/cwd-aliases.test.ts | 110 ++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 11 deletions(-) diff --git a/apps/daemon/src/cwd-aliases.ts b/apps/daemon/src/cwd-aliases.ts index 73806443d..74636e2ad 100644 --- a/apps/daemon/src/cwd-aliases.ts +++ b/apps/daemon/src/cwd-aliases.ts @@ -28,9 +28,11 @@ // source root so an environment that puts `skills/` itself behind a // symlink (e.g. a content-addressable mount) is followed correctly. +import { createReadStream, createWriteStream } from 'node:fs'; import { createHash } from 'node:crypto'; -import { cp, lstat, rm, stat } from 'node:fs/promises'; +import { chmod, cp, lstat, mkdir, readdir, rm, stat, utimes } from 'node:fs/promises'; import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; export const SKILLS_CWD_ALIAS = '.od-skills'; @@ -52,6 +54,46 @@ export function skillCwdAliasSegment(dir: string): string { return `${folder}-${digest}`; } +// copy_file_range(2) — used by fs.cp under the hood — is rejected with +// these errno codes when source and destination live on different +// filesystems (commonly EXDEV; a container image layer copied onto a +// ZFS/overlay bind mount surfaces EPERM). Node doesn't fall back to a +// userspace copy on any of them, so we do. +const RECOVERABLE_COPY_CODES = new Set(['EPERM', 'EXDEV', 'ENOTSUP', 'EOPNOTSUPP']); + +type SkillCopyFn = ( + source: string, + destination: string, + options: { recursive: boolean; dereference: boolean; preserveTimestamps: boolean }, +) => Promise; + +// Recursive copy that mirrors `cp({ dereference: true })` without going +// through copy_file_range. `stat()` (not `lstat`) follows symlinks, so +// every staged entry lands as a real directory or regular file — keeping +// `.od-skills/` a self-contained write barrier even on the fallback path. +async function copyTreeDereferenced(srcDir: string, destDir: string): Promise { + await mkdir(destDir, { recursive: true }); + for (const entry of await readdir(srcDir, { withFileTypes: true })) { + const from = path.join(srcDir, entry.name); + const to = path.join(destDir, entry.name); + const entryStat = await stat(from); + if (entryStat.isDirectory()) { + await copyTreeDereferenced(from, to); + } else if (entryStat.isFile()) { + await pipeline(createReadStream(from), createWriteStream(to)); + // createWriteStream opens the destination with the default 0644, so + // restore the source's permission bits (notably the exec bit on + // skill helper scripts) and mtime — `fs.cp` preserves these, and + // skills shell out to staged scripts. Mask to 0o777 so the + // agent-writable staging copy never inherits setuid/setgid/sticky. + await chmod(to, entryStat.mode & 0o777); + await utimes(to, entryStat.atime, entryStat.mtime); + } + // Sockets, FIFOs, and devices can't appear in a sane skill folder and + // copying them would hang or fail — skip them. + } +} + /** * Copy `` to `/.od-skills//` so an agent can * reach skill side files via a cwd-relative path. Idempotent and @@ -68,6 +110,11 @@ export async function stageActiveSkill( folderName: string, sourceDir: string, log: SkillStagingLogger = () => {}, + // Seam for tests: the real copy_file_range EPERM only reproduces on + // specific cross-filesystem mounts (ZFS/overlay), so tests inject a + // copy that rejects with a synthetic code to drive the fallback path. + nativeCopy: SkillCopyFn = (source, destination, options) => + cp(source, destination, options), ): Promise { if (!cwd) { return { staged: false, reason: 'no project cwd' }; @@ -123,16 +170,26 @@ export async function stageActiveSkill( // reflected and a partially-failed previous run cannot leave junk // behind. await rm(stagedPath, { recursive: true, force: true }); - await cp(sourceDir, stagedPath, { - recursive: true, - // Resolve every symlink we find inside the skill so the staged - // copy is a fully self-contained set of regular files. This is - // what makes the copy a true write barrier — no entry under - // `.od-skills/...` can resolve back to a real file outside the - // project cwd. - dereference: true, - preserveTimestamps: true, - }); + try { + await nativeCopy(sourceDir, stagedPath, { + recursive: true, + // Resolve every symlink we find inside the skill so the staged + // copy is a fully self-contained set of regular files. This is + // what makes the copy a true write barrier — no entry under + // `.od-skills/...` can resolve back to a real file outside the + // project cwd. + dereference: true, + preserveTimestamps: true, + }); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code ?? ''; + if (!RECOVERABLE_COPY_CODES.has(code)) throw err; + log( + `[od] skill-stage: native copy failed (${code}); retrying with stream copy`, + ); + await rm(stagedPath, { recursive: true, force: true }); + await copyTreeDereferenced(sourceDir, stagedPath); + } return { staged: true, stagedPath }; } catch (err) { log(`[od] skill-stage failed: ${(err as Error).message}`); diff --git a/apps/daemon/tests/cwd-aliases.test.ts b/apps/daemon/tests/cwd-aliases.test.ts index 71fd9bffe..6db3a37e6 100644 --- a/apps/daemon/tests/cwd-aliases.test.ts +++ b/apps/daemon/tests/cwd-aliases.test.ts @@ -1,8 +1,11 @@ import { + chmodSync, + existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, + statSync, symlinkSync, writeFileSync, } from 'node:fs'; @@ -251,4 +254,111 @@ describe('stageActiveSkill', () => { expect(result.reason).toContain(expectedReason); }, ); + + it('falls back to a dereferenced stream copy when the native copy fails with EPERM', async () => { + // Repro for the Docker/ZFS report: `fs.cp` -> copy_file_range(2) is + // rejected with EPERM across the image-layer -> bind-mount boundary + // and Node doesn't fall back. The real errno only appears on those + // mounts, so inject a copy that rejects with a synthetic EPERM. + const fs = fresh(); + const cwd = path.join(fs, 'project'); + const sourceDir = writeSampleSkill(path.join(fs, 'skills'), 'blog-post'); + // A symlinked side file proves the fallback still dereferences, so the + // staged copy stays a self-contained write barrier. + symlinkSync( + path.join(sourceDir, 'assets', 'template.html'), + path.join(sourceDir, 'assets', 'linked.html'), + ); + mkdirSync(cwd); + + const messages: string[] = []; + const eperm = Object.assign( + new Error('EPERM: operation not permitted, copyfile'), + { code: 'EPERM' }, + ); + + const result = await stageActiveSkill( + cwd, + 'blog-post', + sourceDir, + (m) => messages.push(m), + () => Promise.reject(eperm), + ); + + expect(result.staged).toBe(true); + const staged = result.stagedPath!; + expect(readFileSync(path.join(staged, 'SKILL.md'), 'utf8')).toContain( + 'original SKILL', + ); + const linked = path.join(staged, 'assets', 'linked.html'); + expect(lstatSync(linked).isSymbolicLink()).toBe(false); + expect(lstatSync(linked).isFile()).toBe(true); + expect(readFileSync(linked, 'utf8')).toContain('original'); + expect(messages.some((m) => m.includes('stream copy'))).toBe(true); + }); + + it('preserves the source exec bit through the stream-copy fallback (EPERM path)', async () => { + // Regression for PR #3249 review: skills shell out to staged helper + // scripts, so the fallback copy must keep the source's exec bit. A + // plain stream copy would reset it to the default 0644 and the agent + // would hit EACCES on the exact cross-fs path this fallback repairs. + const fs = fresh(); + const cwd = path.join(fs, 'project'); + const sourceDir = writeSampleSkill(path.join(fs, 'skills'), 'blog-post'); + const script = path.join(sourceDir, 'scripts', 'run.sh'); + mkdirSync(path.dirname(script)); + writeFileSync(script, '#!/usr/bin/env bash\necho hi\n'); + chmodSync(script, 0o755); + mkdirSync(cwd); + + const eperm = Object.assign(new Error('EPERM: operation not permitted'), { + code: 'EPERM', + }); + const result = await stageActiveSkill( + cwd, + 'blog-post', + sourceDir, + () => {}, + () => Promise.reject(eperm), + ); + + expect(result.staged).toBe(true); + const stagedScript = path.join(result.stagedPath!, 'scripts', 'run.sh'); + // Exec bit survives on the helper script… + expect(statSync(stagedScript).mode & 0o111).not.toBe(0); + // …while a non-executable sibling is not made executable. + expect(statSync(path.join(result.stagedPath!, 'SKILL.md')).mode & 0o111).toBe( + 0, + ); + }); + + it('degrades to the absolute-path fallback on a non-recoverable copy error', async () => { + const fs = fresh(); + const cwd = path.join(fs, 'project'); + const sourceDir = writeSampleSkill(path.join(fs, 'skills'), 'blog-post'); + mkdirSync(cwd); + + const enospc = Object.assign( + new Error('ENOSPC: no space left on device'), + { code: 'ENOSPC' }, + ); + const messages: string[] = []; + + const result = await stageActiveSkill( + cwd, + 'blog-post', + sourceDir, + (m) => messages.push(m), + () => Promise.reject(enospc), + ); + + // Not a cross-filesystem rejection — propagates to the existing + // degrade path instead of attempting the stream-copy fallback. + expect(result.staged).toBe(false); + expect(result.reason).toMatch(/ENOSPC/); + expect( + existsSync(path.join(cwd, SKILLS_CWD_ALIAS, 'blog-post', 'SKILL.md')), + ).toBe(false); + expect(messages.some((m) => m.includes('stream copy'))).toBe(false); + }); }); From f67d245744a3dfc9c2792cd464b9094767b20610 Mon Sep 17 00:00:00 2001 From: laihenyi Date: Fri, 29 May 2026 05:17:18 +0200 Subject: [PATCH 03/56] docs(i18n): fix zh-TW README parity drift from English (#3251) - Comparison table: design systems 72 -> 129 (match EN README) - Repository structure tree: add missing kami-deck.html template entry Both were drift from the English README. The deeper EN-wide count inconsistency (badge 149/131 vs body 72/31) is tracked in #3250. --- README.zh-TW.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.zh-TW.md b/README.zh-TW.md index 2e081db90..aeb9f1a4b 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -713,7 +713,8 @@ open-design/ │ └── browser-chrome.html │ ├── templates/ -│ └── deck-framework.html ← deck 基線(nav / counter / print) +│ ├── deck-framework.html ← deck 基線(nav / counter / print) +│ └── kami-deck.html ← kami 風格 deck 起手(羊皮紙 / 墨藍襯線) │ ├── scripts/ │ └── sync-design-systems.ts ← 從上游 awesome-design-md tarball 重新匯入 @@ -888,7 +889,7 @@ Chat / artifact 迴圈最顯眼,但這套倉庫裡還有幾個能力被埋得 | 可部署 Vercel | ❌ | ❌ | **✅** | | Agent 執行時 | 內建 (Opus 4.7) | 內建 ([`pi-ai`][piai]) | **委託給使用者已裝好的 CLI** | | Skill | 私有 | 12 套自定義 TS 模組 + `SKILL.md` | **31 套基於檔案的 [`SKILL.md`][skill],可丟入** | -| Design system | 私有 | `DESIGN.md`(v0.2 路線圖) | **`DESIGN.md` × 72 套,開箱即有** | +| Design system | 私有 | `DESIGN.md`(v0.2 路線圖) | **`DESIGN.md` × 129 套,開箱即有** | | Provider 靈活度 | 僅 Anthropic | 7+([`pi-ai`][piai]) | **16 套 CLI adapter + OpenAI 相容 BYOK 代理** | | 初始化問題表單 | ❌ | ❌ | **✅ 硬規則 turn 1** | | 方向選擇器 | ❌ | ❌ | **✅ 5 套確定性方向** | From 4a0900ca8111cf3b2a3b77e04a0542c7715ae52e Mon Sep 17 00:00:00 2001 From: Yuhao Chen Date: Fri, 29 May 2026 11:17:57 +0800 Subject: [PATCH 04/56] fix(web): remove passive video play badge (#3252) --- .../plugins-home/cards/MediaSurface.tsx | 5 ----- apps/web/src/styles/home/plugins-home.css | 18 ------------------ .../plugins-home-media-surface.test.tsx | 14 ++++++++++++++ 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/plugins-home/cards/MediaSurface.tsx b/apps/web/src/components/plugins-home/cards/MediaSurface.tsx index c4fdb4429..803a0ba0a 100644 --- a/apps/web/src/components/plugins-home/cards/MediaSurface.tsx +++ b/apps/web/src/components/plugins-home/cards/MediaSurface.tsx @@ -68,11 +68,6 @@ export function MediaSurface({ preview, pluginTitle, inView }: Props) { preload="none" /> ) : null} - {preview.mediaType === 'video' && !preview.imageOnly ? ( - - - - ) : null} ); } diff --git a/apps/web/src/styles/home/plugins-home.css b/apps/web/src/styles/home/plugins-home.css index 3778af526..d228c7bfe 100644 --- a/apps/web/src/styles/home/plugins-home.css +++ b/apps/web/src/styles/home/plugins-home.css @@ -514,24 +514,6 @@ background-position: -200% 0, 0 0; } } -.plugins-home__media-badge { - position: absolute; - bottom: 8px; - right: 8px; - width: 28px; - height: 28px; - border-radius: 50%; - background: rgba(0, 0, 0, 0.55); - color: white; - display: inline-flex; - align-items: center; - justify-content: center; - z-index: 2; - pointer-events: none; - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); -} - /* Sandboxed HTML iframe — scaled-down "thumbnail" of a real page */ .plugins-home__html { position: absolute; diff --git a/apps/web/tests/components/plugins-home-media-surface.test.tsx b/apps/web/tests/components/plugins-home-media-surface.test.tsx index d58ce9db2..76a2c6d38 100644 --- a/apps/web/tests/components/plugins-home-media-surface.test.tsx +++ b/apps/web/tests/components/plugins-home-media-surface.test.tsx @@ -30,6 +30,13 @@ const POSTER: MediaPreviewSpec = { imageOnly: true, }; +const VIDEO_POSTER: MediaPreviewSpec = { + ...POSTER, + mediaType: 'video', + videoUrl: 'https://example.invalid/preview.mp4', + imageOnly: false, +}; + afterEach(() => { cleanup(); }); @@ -99,4 +106,11 @@ describe('MediaSurface broken-poster fallback (#2955)', () => { expect(container.querySelector('img.plugins-home__media-img')).toBeNull(); expect(container.querySelector('.plugins-home__media-fallback')).not.toBeNull(); }); + + it('does not render a passive play badge for video cards that already auto-play on hover', () => { + const { container } = render( + , + ); + expect(container.querySelector('.plugins-home__media-badge')).toBeNull(); + }); }); From d0921ed335b6d0c3e474f68d7e57dee5f52577d8 Mon Sep 17 00:00:00 2001 From: Yuhao Chen Date: Fri, 29 May 2026 11:18:19 +0800 Subject: [PATCH 05/56] fix(skills): avoid orphan web prototype files (#3253) --- design-templates/web-prototype/SKILL.md | 14 ++++++++++---- plugins/_official/examples/web-prototype/SKILL.md | 14 ++++++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/design-templates/web-prototype/SKILL.md b/design-templates/web-prototype/SKILL.md index d2b54690a..1e4322281 100644 --- a/design-templates/web-prototype/SKILL.md +++ b/design-templates/web-prototype/SKILL.md @@ -48,9 +48,13 @@ web-prototype/ 2. **Read `references/layouts.md`** so you know which section skeletons exist. Don't write a section type that isn't covered — pick the closest layout and adapt. 3. **Read the active DESIGN.md** (already injected into your system prompt). Map its colors to the six `:root` variables in the seed; don't introduce new tokens. -### Step 1 — Copy the seed +### Step 1 — Prepare the artifact from the seed -Copy `assets/template.html` to the project root as `index.html`. Replace the six `:root` variables with the active design system's tokens. Replace the page `` and the topnav brand. +Choose one kebab-case artifact slug before composing the page. Use `assets/template.html` as the seed for the final artifact HTML. + +Do not write a project-root HTML draft with file-write before emitting the final `<artifact>`. The live-artifact output is the canonical HTML file for this generation turn; an extra `index.html`, `cast.html`, or brief-derived draft can be stranded beside it as an orphan. + +Replace the six `:root` variables with the active design system's tokens. Replace the page `<title>` and the topnav brand. ### Step 2 — Plan the section list @@ -67,7 +71,7 @@ State the chosen list in one sentence to the user *before* writing — they can ### Step 3 — Paste and fill -For each chosen layout, copy the `<section>` block from `layouts.md` into `<main id="content">` of your `index.html`. Replace bracketed `[REPLACE]` strings with real, specific copy from the user's brief. **No filler** — if a slot is empty, the section is the wrong choice; pick a different layout. +For each chosen layout, copy the `<section>` block from `layouts.md` into `<main id="content">` of the artifact HTML. Replace bracketed `[REPLACE]` strings with real, specific copy from the user's brief. **No filler** — if a slot is empty, the section is the wrong choice; pick a different layout. ### Step 4 — Self-check @@ -75,7 +79,7 @@ Run through `references/checklist.md` top to bottom. Every P0 item must pass bef ### Step 5 — Emit the artifact -Wrap `index.html` in `<artifact>` tags. One sentence before describing what's there. Stop after `</artifact>`. +Wrap the completed HTML in `<artifact>` tags using the artifact slug as the `identifier`. One sentence before describing what's there. Stop after `</artifact>`. ## Hard rules (the seed protects most of these — don't fight it) @@ -94,4 +98,6 @@ Wrap `index.html` in `<artifact>` tags. One sentence before describing what's th </artifact> ``` +Open Design derives the canonical HTML artifact from this identifier. Do not also write another root HTML file for the same generation turn. + One sentence before the artifact. Nothing after. diff --git a/plugins/_official/examples/web-prototype/SKILL.md b/plugins/_official/examples/web-prototype/SKILL.md index d2b54690a..1e4322281 100644 --- a/plugins/_official/examples/web-prototype/SKILL.md +++ b/plugins/_official/examples/web-prototype/SKILL.md @@ -48,9 +48,13 @@ web-prototype/ 2. **Read `references/layouts.md`** so you know which section skeletons exist. Don't write a section type that isn't covered — pick the closest layout and adapt. 3. **Read the active DESIGN.md** (already injected into your system prompt). Map its colors to the six `:root` variables in the seed; don't introduce new tokens. -### Step 1 — Copy the seed +### Step 1 — Prepare the artifact from the seed -Copy `assets/template.html` to the project root as `index.html`. Replace the six `:root` variables with the active design system's tokens. Replace the page `<title>` and the topnav brand. +Choose one kebab-case artifact slug before composing the page. Use `assets/template.html` as the seed for the final artifact HTML. + +Do not write a project-root HTML draft with file-write before emitting the final `<artifact>`. The live-artifact output is the canonical HTML file for this generation turn; an extra `index.html`, `cast.html`, or brief-derived draft can be stranded beside it as an orphan. + +Replace the six `:root` variables with the active design system's tokens. Replace the page `<title>` and the topnav brand. ### Step 2 — Plan the section list @@ -67,7 +71,7 @@ State the chosen list in one sentence to the user *before* writing — they can ### Step 3 — Paste and fill -For each chosen layout, copy the `<section>` block from `layouts.md` into `<main id="content">` of your `index.html`. Replace bracketed `[REPLACE]` strings with real, specific copy from the user's brief. **No filler** — if a slot is empty, the section is the wrong choice; pick a different layout. +For each chosen layout, copy the `<section>` block from `layouts.md` into `<main id="content">` of the artifact HTML. Replace bracketed `[REPLACE]` strings with real, specific copy from the user's brief. **No filler** — if a slot is empty, the section is the wrong choice; pick a different layout. ### Step 4 — Self-check @@ -75,7 +79,7 @@ Run through `references/checklist.md` top to bottom. Every P0 item must pass bef ### Step 5 — Emit the artifact -Wrap `index.html` in `<artifact>` tags. One sentence before describing what's there. Stop after `</artifact>`. +Wrap the completed HTML in `<artifact>` tags using the artifact slug as the `identifier`. One sentence before describing what's there. Stop after `</artifact>`. ## Hard rules (the seed protects most of these — don't fight it) @@ -94,4 +98,6 @@ Wrap `index.html` in `<artifact>` tags. One sentence before describing what's th </artifact> ``` +Open Design derives the canonical HTML artifact from this identifier. Do not also write another root HTML file for the same generation turn. + One sentence before the artifact. Nothing after. From 5319e14dc09330b89df84d7cb4e6f101c9648b93 Mon Sep 17 00:00:00 2001 From: Anurag Pappula <127645370+Anuragp22@users.noreply.github.com> Date: Fri, 29 May 2026 08:48:45 +0530 Subject: [PATCH 06/56] docs: sync README skill and design-system counts to 137 / 150 (#3254) * docs: bump skill count to 137 in TL;DR and header badge * docs: sync at-a-glance and comparison-table counts, drop broken arithmetic * docs: sync remaining body references to 137 skills --- README.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9d1fdd0ce..53465afed 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Open Design — the open-source Claude Design alternative -> **Open Design is the open-source, local-first alternative to [Claude Design][cd].** Web-deployable, BYOK at every layer — **16 coding-agent CLIs** auto-detected on your `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) become the design engine, driven by **132 composable Skills** and **150 brand-grade Design Systems**. No CLI? An OpenAI-compatible BYOK proxy is the same loop minus the spawn. +> **Open Design is the open-source, local-first alternative to [Claude Design][cd].** Web-deployable, BYOK at every layer — **16 coding-agent CLIs** auto-detected on your `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) become the design engine, driven by **137 composable Skills** and **150 brand-grade Design Systems**. No CLI? An OpenAI-compatible BYOK proxy is the same loop minus the spawn. > [!IMPORTANT] > ### 🔥 `0.8.0-preview` is here. Design's old world ends here. @@ -31,7 +31,7 @@ <a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a> <a href="#supported-coding-agents"><img alt="Agents" src="https://img.shields.io/badge/agents-16%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a> <a href="#design-systems"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-150-orange?style=flat-square" /></a> - <a href="#skills"><img alt="Skills" src="https://img.shields.io/badge/skills-132-teal?style=flat-square" /></a> + <a href="#skills"><img alt="Skills" src="https://img.shields.io/badge/skills-137-teal?style=flat-square" /></a> <a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" /></a> <a href="https://x.com/nexudotio"><img alt="Follow @nexudotio on X" src="https://img.shields.io/badge/follow-%40nexudotio-1DA1F2?style=flat-square&logo=x&logoColor=white" /></a> <a href="QUICKSTART.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a> @@ -64,8 +64,8 @@ OD stands on four open-source shoulders: |---|---| | **Coding-agent CLIs (16)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Qoder CLI · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI — auto-detected on `PATH`, swap with one click | | **BYOK fallback** | Protocol-specific API proxy at `/api/proxy/{anthropic,openai,azure,google,ollama,senseaudio}/stream` — paste `baseUrl` + `apiKey` + `model`, choose Anthropic / OpenAI / Azure OpenAI / Google Gemini / Ollama Cloud / SenseAudio, and the daemon normalizes SSE back to the same chat stream. SenseAudio chat additionally exposes `generate_image` and `generate_video` tools so the model can write rendered artifacts straight into the active project's folder. Internal-IP/SSRF blocked at the daemon edge. | -| **Design systems built-in** | **129** — 2 hand-authored starters + 70 product systems (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) from [`awesome-design-md`][acd2], plus 57 design skills from [`awesome-design-skills`][ads] added directly under `design-systems/` | -| **Skills built-in** | **132** — 27 in `prototype` mode (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 in `deck` mode (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Grouped in the picker by `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. | +| **Design systems built-in** | **150** — hand-authored starters plus product systems (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) from [`awesome-design-md`][acd2], with curated entries from [`awesome-design-skills`][ads] added directly under `design-systems/` | +| **Skills built-in** | **137** — across `prototype` (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …), `deck` (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`), and `image` / `video` / `audio` / `template` / `design-system` / `utility` modes. Grouped in the picker by `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. | | **Media generation** | Image · video · audio surfaces ship alongside the design loop. **gpt-image-2** (Azure / OpenAI) for posters, avatars, infographics, illustrated maps · **Seedance 2.0** (ByteDance) for cinematic 15s text-to-video and image-to-video · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) for HTML→MP4 motion graphics (product reveals, kinetic typography, data charts, social overlays, logo outros). Other image generators can already plug in through **Custom Image API** / **ImageRouter** when they expose an OpenAI-compatible image endpoint; workflow-first local runtimes such as **ComfyUI** are tracked separately as planned adapters. **93** ready-to-replicate prompts gallery — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — under [`prompt-templates/`](prompt-templates/), with preview thumbnails and source attribution. Same chat surface as code; outputs a real `.mp4` / `.png` chip into the project workspace. | | **Visual directions** | 5 curated schools (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — each ships a deterministic OKLch palette + font stack ([`apps/daemon/src/prompts/directions.ts`](apps/daemon/src/prompts/directions.ts)) | | **Device frames** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixel-accurate, shared across skills under [`assets/frames/`](assets/frames/) | @@ -129,9 +129,9 @@ Linux AppImage packaging is available through the optional release lane and is c ## Skills -**132 skills ship in the box.** Each is a folder under [`skills/`](skills/) following the Claude Code [`SKILL.md`][skill] convention with an extended `od:` frontmatter that the daemon parses verbatim — `mode`, `platform`, `scenario`, `preview.type`, `design_system.requires`, `default_for`, `featured`, `fidelity`, `speaker_notes`, `animations`, `example_prompt` ([`apps/daemon/src/skills.ts`](apps/daemon/src/skills.ts)). +**137 skills ship in the box.** Each is a folder under [`skills/`](skills/) following the Claude Code [`SKILL.md`][skill] convention with an extended `od:` frontmatter that the daemon parses verbatim — `mode`, `platform`, `scenario`, `preview.type`, `design_system.requires`, `default_for`, `featured`, `fidelity`, `speaker_notes`, `animations`, `example_prompt` ([`apps/daemon/src/skills.ts`](apps/daemon/src/skills.ts)). -Two **modes** anchor the interactive catalog: **`prototype`** (32 skills — anything that renders as a single-page artifact, from a magazine landing to a phone screen to a PM spec doc) and **`deck`** (9 skills — horizontal-swipe presentations with deck-framework chrome). The catalog also ships `image`, `video`, `audio`, `template`, `design-system`, and `utility` modes for media generation, catalog updaters, and post-export audit helpers. The **`scenario`** field is what the picker groups them by: `design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`. +Two **modes** anchor the interactive catalog: **`prototype`** (anything that renders as a single-page artifact, from a magazine landing to a phone screen to a PM spec doc) and **`deck`** (horizontal-swipe presentations with deck-framework chrome). The catalog also ships `image`, `video`, `audio`, `template`, `design-system`, and `utility` modes for media generation, catalog updaters, and post-export audit helpers. The **`scenario`** field is what the picker groups them by: `design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`. ### Showcase examples @@ -260,7 +260,7 @@ What you compose at send time isn't "system + user". It's: DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critique) + identity charter (OFFICIAL_DESIGNER_PROMPT, anti-AI-slop, junior-pass) + active DESIGN.md (150 systems available) - + active SKILL.md (132 skills available) + + active SKILL.md (137 skills available) + project metadata (kind, fidelity, speakerNotes, animations, inspiration ids) + skill side files (auto-injected pre-flight: read assets/template.html + references/*.md) + (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print) @@ -408,7 +408,7 @@ For desktop/background startup, fixed-port restarts, and media generation dispat The first load: 1. Detects which agent CLIs you have on `PATH` and picks one automatically. -2. Loads 132 skills + 150 design systems. +2. Loads 137 skills + 150 design systems. 3. Pops the welcome dialog so you can paste an Anthropic key (only needed for the BYOK fallback path). 4. **Auto-creates `./.od/`** — the local runtime folder for the SQLite project DB, per-project artifacts, and saved renders. There is no `od init` step; the daemon `mkdir`s everything it needs on boot. @@ -709,7 +709,7 @@ open-design/ │ ├── sidecar/ ← generic sidecar runtime primitives │ └── platform/ ← generic process/platform primitives │ -├── skills/ ← 132 SKILL.md skill bundles (32 prototype + 9 deck + image / video / audio / template / design-system / utility) +├── skills/ ← 137 SKILL.md skill bundles (prototype, deck, image, video, audio, template, design-system, utility) │ ├── web-prototype/ ← default for prototype mode │ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/ │ ├── mobile-app/ mobile-onboarding/ gamified-app/ @@ -895,7 +895,7 @@ The chat / artifact loop gets the spotlight, but a handful of less-visible capab - **Claude Design ZIP import.** Drop an export from claude.ai onto the welcome dialog. `POST /api/import/claude-design` extracts it into a real `.od/projects/<id>/`, opens the entry file as a tab, and stages a continue-where-Anthropic-left-off prompt for your local agent. No re-prompting, no "ask the model to re-create what we just had". ([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts) — `/api/import/claude-design`) - **Multi-provider BYOK proxy.** `POST /api/proxy/{anthropic,openai,azure,google,ollama,senseaudio}/stream` takes `{ baseUrl, apiKey, model, messages }`, builds the provider-specific upstream request, normalizes SSE chunks into `delta/end/error`, and allows loopback local LLM providers while rejecting non-loopback private, link-local, CGNAT, multicast, reserved, and redirect targets to head off SSRF. OpenAI-compatible covers OpenAI, Azure AI Foundry `/openai/v1`, DeepSeek, Groq, MiMo, OpenRouter, Ollama, LM Studio, and self-hosted vLLM; Azure OpenAI adds deployment URL + `api-version`; Google uses Gemini `:streamGenerateContent`. -- **User-saved templates.** Once you like a render, `POST /api/templates` snapshots the HTML + metadata into the SQLite `templates` table. The next project picks it from a "your templates" row in the picker — same surface as the shipped 132, but yours. +- **User-saved templates.** Once you like a render, `POST /api/templates` snapshots the HTML + metadata into the SQLite `templates` table. The next project picks it from a "your templates" row in the picker — same surface as the shipped 137, but yours. - **Tab persistence.** Every project remembers its open files and active tab in the `tabs` table. Reopen the project tomorrow and the workspace looks exactly the way you left it. - **Artifact lint API.** `POST /api/artifacts/lint` runs structural checks on a generated artifact (broken `<artifact>` framing, missing required side files, stale palette tokens) and returns findings the agent can read back into its next turn. The five-dim self-critique uses this to ground its score in real evidence, not vibes. - **Sidecar protocol + desktop automation.** Daemon, web, and desktop processes carry typed five-field stamps (`app · mode · namespace · ipc · source`) and expose a JSON-RPC IPC channel at `/tmp/open-design/ipc/<namespace>/<app>.sock`. `tools-dev inspect desktop status \| eval \| screenshot` drives that channel, so headless E2E works against a real Electron shell without bespoke harnesses ([`packages/sidecar-proto/`](packages/sidecar-proto/), [`apps/desktop/src/main/`](apps/desktop/src/main/)). @@ -921,8 +921,8 @@ The whole machinery below is the [`huashu-design`](https://github.com/alchaincyf | Form factor | Web (claude.ai) | Desktop (Electron) | **Web app + local daemon** | | Deployable on Vercel | ❌ | ❌ | **✅** | | Agent runtime | Bundled (Opus 4.7) | Bundled ([`pi-ai`][piai]) | **Delegated to user's existing CLI** | -| Skills | Proprietary | 12 custom TS modules + `SKILL.md` | **132 file-based [`SKILL.md`][skill] bundles, droppable** | -| Design system | Proprietary | `DESIGN.md` (v0.2 roadmap) | **`DESIGN.md` × 129 systems shipped** | +| Skills | Proprietary | 12 custom TS modules + `SKILL.md` | **137 file-based [`SKILL.md`][skill] bundles, droppable** | +| Design system | Proprietary | `DESIGN.md` (v0.2 roadmap) | **`DESIGN.md` × 150 systems shipped** | | Provider flexibility | Anthropic only | 7+ via [`pi-ai`][piai] | **16 CLI adapters + OpenAI-compatible BYOK proxy** | | Init question form | ❌ | ❌ | **✅ Hard rule, turn 1** | | Direction picker | ❌ | ❌ | **✅ 5 deterministic directions** | @@ -994,7 +994,7 @@ Long-form provenance write-up — what we take from each, what we deliberately d - [x] Daemon + agent detection (16 CLI adapters) + skill registry + design-system catalog - [x] Web app + chat + question form + 5-direction picker + todo progress + sandboxed preview -- [x] 132 skills + 150 design systems + 5 visual directions + 5 device frames +- [x] 137 skills + 150 design systems + 5 visual directions + 5 device frames - [x] SQLite-backed projects · conversations · messages · tabs · templates - [x] Multi-provider BYOK proxy (`/api/proxy/{anthropic,openai,azure,google,ollama,senseaudio}/stream`) with SSRF guard - [x] Claude Design ZIP import (`/api/import/claude-design`) From 9c6a69490bd5761a4f0b11403efa65144c12c935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=9D=E6=99=A8?= <1296787025@qq.com> Date: Fri, 29 May 2026 11:19:14 +0800 Subject: [PATCH 07/56] fix(web): localize mention picker copy (#3255) --- apps/web/src/components/ChatComposer.tsx | 42 ++++++++-------- apps/web/src/i18n/locales/en.ts | 19 +++++++ apps/web/src/i18n/locales/zh-CN.ts | 19 +++++++ apps/web/src/i18n/locales/zh-TW.ts | 19 +++++++ apps/web/src/i18n/types.ts | 19 +++++++ .../ChatComposer.context-pickers.test.tsx | 50 +++++++++++++++++-- 6 files changed, 144 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/ChatComposer.tsx b/apps/web/src/components/ChatComposer.tsx index 93329aaef..0e21d4e01 100644 --- a/apps/web/src/components/ChatComposer.tsx +++ b/apps/web/src/components/ChatComposer.tsx @@ -2426,8 +2426,8 @@ function mcpTemplateMatchesQuery(tpl: McpTemplate, query: string): boolean { .includes(q); } -function pluginSourceLabel(plugin: InstalledPluginRecord): string { - return plugin.sourceKind === 'bundled' ? 'Official' : 'My plugin'; +function pluginSourceLabel(plugin: InstalledPluginRecord, t: TranslateFn): string { + return plugin.sourceKind === 'bundled' ? t('chat.mentionPluginOfficial') : t('chat.mentionPluginMine'); } function ToolsImportPanel({ @@ -2574,16 +2574,16 @@ function MentionPopover({ onPickMcp: (server: McpServerConfig) => void; onPickConnector: (connector: ConnectorDetail) => void; }) { - const { locale } = useI18n(); + const { locale, t } = useI18n(); const ref = useRef<HTMLDivElement | null>(null); const [tab, setTab] = useState<MentionTab>('all'); const tabs: Array<{ id: MentionTab; label: string }> = [ - { id: 'all', label: 'All' }, - { id: 'plugins', label: 'Plugins' }, - { id: 'skills', label: 'Skills' }, - { id: 'mcp', label: 'MCP' }, - { id: 'connectors', label: 'Connectors' }, - { id: 'files', label: 'Design files' }, + { id: 'all', label: t('chat.mentionTabAll') }, + { id: 'plugins', label: t('chat.mentionTabPlugins') }, + { id: 'skills', label: t('chat.mentionTabSkills') }, + { id: 'mcp', label: t('chat.mentionTabMcp') }, + { id: 'connectors', label: t('chat.mentionTabConnectors') }, + { id: 'files', label: t('chat.mentionTabFiles') }, ]; const showPlugins = tab === 'all' || tab === 'plugins'; const showSkills = tab === 'all' || tab === 'skills'; @@ -2601,7 +2601,7 @@ function MentionPopover({ }, [connectors, files, plugins, skills, mcpServers, tab]); return ( <div className="mention-popover" data-testid="mention-popover"> - <div className="mention-tabs" role="tablist" aria-label="Mention surfaces"> + <div className="mention-tabs" role="tablist" aria-label={t('chat.mentionTabsAria')}> {tabs.map((item) => ( <button key={item.id} @@ -2620,15 +2620,15 @@ function MentionPopover({ {!hasVisibleResults ? ( <div className="mention-empty"> {query ? ( - <>No results for “{query}”.</> + <>{t('chat.mentionNoResults', { query })}</> ) : ( - <>Search plugins, skills, MCP servers, connectors, and Design Files.</> + <>{t('chat.mentionSearchPrompt')}</> )} </div> ) : null} {showPlugins && plugins.length > 0 ? ( <> - <div className="mention-section-label">Plugins</div> + <div className="mention-section-label">{t('chat.mentionSectionPlugins')}</div> {plugins.map((p) => ( <button key={`plugin-${p.id}`} @@ -2645,14 +2645,14 @@ function MentionPopover({ {p.manifest?.description ?? p.id} </span> </span> - <span className="mention-meta">{pluginSourceLabel(p)}</span> + <span className="mention-meta">{pluginSourceLabel(p, t)}</span> </button> ))} </> ) : null} {showSkills && skills.length > 0 ? ( <> - <div className="mention-section-label">Skills</div> + <div className="mention-section-label">{t('chat.mentionSectionSkills')}</div> {skills.map((skill) => { const active = skill.id === currentSkillId; return ( @@ -2671,7 +2671,7 @@ function MentionPopover({ {localizeSkillDescription(locale, skill) || skill.id} </span> </span> - <span className="mention-meta">{active ? 'Active' : skill.mode}</span> + <span className="mention-meta">{active ? t('chat.mentionActiveSkill') : skill.mode}</span> </button> ); })} @@ -2679,7 +2679,7 @@ function MentionPopover({ ) : null} {showMcp && mcpServers.length > 0 ? ( <> - <div className="mention-section-label">MCP</div> + <div className="mention-section-label">{t('chat.mentionSectionMcp')}</div> {mcpServers.map((server) => ( <button key={`mcp-${server.id}`} @@ -2687,7 +2687,7 @@ function MentionPopover({ type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => onPickMcp(server)} - title={`Use ${server.label || server.id}`} + title={t('chat.mentionUseMcpTitle', { name: server.label || server.id })} > <Icon name="link" size={12} /> <span className="mention-item-body"> @@ -2703,7 +2703,7 @@ function MentionPopover({ ) : null} {showConnectors && connectors.length > 0 ? ( <> - <div className="mention-section-label">Connectors</div> + <div className="mention-section-label">{t('chat.mentionSectionConnectors')}</div> {connectors.map((connector) => ( <button key={`connector-${connector.id}`} @@ -2711,7 +2711,7 @@ function MentionPopover({ type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => onPickConnector(connector)} - title={`Use ${connector.name}`} + title={t('chat.mentionUseConnectorTitle', { name: connector.name })} > <Icon name="link" size={12} /> <span className="mention-item-body"> @@ -2727,7 +2727,7 @@ function MentionPopover({ ) : null} {showFiles && files.length > 0 ? ( <> - <div className="mention-section-label">Design files</div> + <div className="mention-section-label">{t('chat.mentionSectionFiles')}</div> {files.map((f) => { const key = f.path ?? f.name; return ( diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 593a40246..20feef751 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -1470,6 +1470,25 @@ export const en: Dict = { 'chat.importFolder': 'Link code folder', 'chat.importSkills': 'Skills and design systems', 'chat.importProject': 'Reference another project', + 'chat.mentionTabsAria': 'Mention surfaces', + 'chat.mentionTabAll': 'All', + 'chat.mentionTabPlugins': 'Plugins', + 'chat.mentionTabSkills': 'Skills', + 'chat.mentionTabMcp': 'MCP', + 'chat.mentionTabConnectors': 'Connectors', + 'chat.mentionTabFiles': 'Design files', + 'chat.mentionNoResults': 'No results for “{query}”.', + 'chat.mentionSearchPrompt': 'Search plugins, skills, MCP servers, connectors, and Design Files.', + 'chat.mentionSectionPlugins': 'Plugins', + 'chat.mentionSectionSkills': 'Skills', + 'chat.mentionSectionMcp': 'MCP', + 'chat.mentionSectionConnectors': 'Connectors', + 'chat.mentionSectionFiles': 'Design files', + 'chat.mentionActiveSkill': 'Active', + 'chat.mentionUseMcpTitle': 'Use {name}', + 'chat.mentionUseConnectorTitle': 'Use {name}', + 'chat.mentionPluginOfficial': 'Official', + 'chat.mentionPluginMine': 'My plugin', 'chat.linkedFolderRemoveAria': 'Remove linked folder {path}', 'chat.linkedFolderNotFound': 'Folder does not exist', 'chat.linkedFolderAlready': 'This folder is already linked', diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 9819c2ae2..ff74b3ec3 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -1461,6 +1461,25 @@ export const zhCN: Dict = { 'chat.importFolder': '关联代码目录', 'chat.importSkills': '技能与设计体系', 'chat.importProject': '引用其它项目', + 'chat.mentionTabsAria': '提及来源', + 'chat.mentionTabAll': '全部', + 'chat.mentionTabPlugins': '插件', + 'chat.mentionTabSkills': '技能', + 'chat.mentionTabMcp': 'MCP', + 'chat.mentionTabConnectors': '连接器', + 'chat.mentionTabFiles': '设计文件', + 'chat.mentionNoResults': '没有找到“{query}”的结果。', + 'chat.mentionSearchPrompt': '搜索插件、技能、MCP 服务器、连接器和设计文件。', + 'chat.mentionSectionPlugins': '插件', + 'chat.mentionSectionSkills': '技能', + 'chat.mentionSectionMcp': 'MCP', + 'chat.mentionSectionConnectors': '连接器', + 'chat.mentionSectionFiles': '设计文件', + 'chat.mentionActiveSkill': '已启用', + 'chat.mentionUseMcpTitle': '使用 {name}', + 'chat.mentionUseConnectorTitle': '使用 {name}', + 'chat.mentionPluginOfficial': '官方', + 'chat.mentionPluginMine': '我的插件', 'chat.linkedFolderRemoveAria': '移除关联文件夹 {path}', 'chat.linkedFolderNotFound': '文件夹不存在', 'chat.linkedFolderAlready': '该文件夹已关联', diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 106484017..567bf68d9 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -1061,6 +1061,25 @@ export const zhTW: Dict = { 'chat.importFolder': '關聯程式碼目錄', 'chat.importSkills': '技能與設計系統', 'chat.importProject': '引用其它專案', + 'chat.mentionTabsAria': '提及來源', + 'chat.mentionTabAll': '全部', + 'chat.mentionTabPlugins': '外掛', + 'chat.mentionTabSkills': '技能', + 'chat.mentionTabMcp': 'MCP', + 'chat.mentionTabConnectors': '連接器', + 'chat.mentionTabFiles': '設計檔案', + 'chat.mentionNoResults': '找不到「{query}」的結果。', + 'chat.mentionSearchPrompt': '搜尋外掛、技能、MCP 伺服器、連接器和設計檔案。', + 'chat.mentionSectionPlugins': '外掛', + 'chat.mentionSectionSkills': '技能', + 'chat.mentionSectionMcp': 'MCP', + 'chat.mentionSectionConnectors': '連接器', + 'chat.mentionSectionFiles': '設計檔案', + 'chat.mentionActiveSkill': '已啟用', + 'chat.mentionUseMcpTitle': '使用 {name}', + 'chat.mentionUseConnectorTitle': '使用 {name}', + 'chat.mentionPluginOfficial': '官方', + 'chat.mentionPluginMine': '我的外掛', 'chat.linkedFolderRemoveAria': '移除關聯資料夾 {path}', 'chat.linkedFolderNotFound': '資料夾不存在', 'chat.linkedFolderAlready': '該資料夾已關聯', diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index 12d514bfc..ba7c7040b 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -1781,6 +1781,25 @@ export interface Dict { 'chat.importFolder': string; 'chat.importSkills': string; 'chat.importProject': string; + 'chat.mentionTabsAria': string; + 'chat.mentionTabAll': string; + 'chat.mentionTabPlugins': string; + 'chat.mentionTabSkills': string; + 'chat.mentionTabMcp': string; + 'chat.mentionTabConnectors': string; + 'chat.mentionTabFiles': string; + 'chat.mentionNoResults': string; + 'chat.mentionSearchPrompt': string; + 'chat.mentionSectionPlugins': string; + 'chat.mentionSectionSkills': string; + 'chat.mentionSectionMcp': string; + 'chat.mentionSectionConnectors': string; + 'chat.mentionSectionFiles': string; + 'chat.mentionActiveSkill': string; + 'chat.mentionUseMcpTitle': string; + 'chat.mentionUseConnectorTitle': string; + 'chat.mentionPluginOfficial': string; + 'chat.mentionPluginMine': string; 'chat.linkedFolderRemoveAria': string; 'chat.linkedFolderNotFound': string; 'chat.linkedFolderAlready': string; diff --git a/apps/web/tests/components/ChatComposer.context-pickers.test.tsx b/apps/web/tests/components/ChatComposer.context-pickers.test.tsx index f2f378ebc..192e23557 100644 --- a/apps/web/tests/components/ChatComposer.context-pickers.test.tsx +++ b/apps/web/tests/components/ChatComposer.context-pickers.test.tsx @@ -5,6 +5,8 @@ import type { ComponentProps } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatComposer } from '../../src/components/ChatComposer'; +import { I18nProvider } from '../../src/i18n'; +import type { Locale } from '../../src/i18n/types'; const COMMUNITY_PLUGIN = { id: 'community-deck', @@ -115,8 +117,11 @@ let plugins = [COMMUNITY_PLUGIN, USER_PLUGIN]; let skills = [SKILL]; let servers = [MCP_SERVER]; -function renderComposer(overrides: Partial<ComponentProps<typeof ChatComposer>> = {}) { - return render( +function renderComposer( + overrides: Partial<ComponentProps<typeof ChatComposer>> = {}, + options: { locale?: Locale } = {}, +) { + const tree = ( <ChatComposer projectId="project-1" projectFiles={[]} @@ -127,7 +132,19 @@ function renderComposer(overrides: Partial<ComponentProps<typeof ChatComposer>> onOpenMcpSettings={vi.fn()} skills={skills} {...overrides} - />, + /> + ); + + if (options.locale) { + return render( + <I18nProvider initial={options.locale}> + {tree} + </I18nProvider>, + ); + } + + return render( + tree, ); } @@ -196,6 +213,33 @@ describe('ChatComposer context pickers', () => { expect(screen.getByText('Search plugins, skills, MCP servers, connectors, and Design Files.')).toBeTruthy(); }); + it('localizes @ panel tabs and empty states in Chinese mode', async () => { + plugins = []; + skills = []; + servers = []; + renderComposer({}, { locale: 'zh-CN' }); + const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement; + + fireEvent.change(input, { + target: { value: '@', selectionStart: 1 }, + }); + + expect(screen.getByRole('tab', { name: '全部' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: '插件' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: '技能' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: 'MCP' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: '连接器' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: '设计文件' })).toBeTruthy(); + expect(screen.getByText('搜索插件、技能、MCP 服务器、连接器和设计文件。')).toBeTruthy(); + + fireEvent.change(input, { + target: { value: '@missing', selectionStart: 8 }, + }); + + expect(screen.getByText('没有找到“missing”的结果。')).toBeTruthy(); + expect(screen.queryByText('No results for “missing”.')).toBeNull(); + }); + it('selects an MCP server from @ search and keeps the inline token visible', async () => { renderComposer(); const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement; From afc6e9a39f98d8f13fa7e2775d8923810088e878 Mon Sep 17 00:00:00 2001 From: Jane <522700967@qq.com> Date: Fri, 29 May 2026 11:19:29 +0800 Subject: [PATCH 08/56] feat(landing-page): localize templates subcategory chip labels across 16 locales (#3256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "scene" chip rail under each `/plugins/templates/<kind>/` page shipped 23 chip labels in English (`UI & product mockups`, `Brand & logo`, `Storyboards`, `Social & content`, `Avatar & portrait`, `Illustration & style`, plus the rest of the 24-slug subcategory map covering all seven artifact kinds). Only the `zh` override carried a translation; every other non-English locale fell back to English on its scene rail. The result: a visitor reading the rest of `/ja/plugins/templates/image/` in Japanese (hero, kind chips, FAQ, card chrome — all localized in PR #3218) hit a row of English chips at the bottom that read as machine output rather than first-party copy. This change fills `subcategory: { ... }` for the remaining 16 landing locales: `zh-tw`, `ja`, `ko`, `de`, `fr`, `ru`, `es`, `pt-br`, `it`, `vi`, `pl`, `id`, `nl`, `ar`, `tr`, `uk`. The existing `zh` translation is untouched. Brand-name tokens (`UI`, `HyperFrames`, etc.) stay in English; localizable terms (`Apps`, `Brand`, `Logo`, `Avatar`, `Storyboards`, …) are translated where the language has a clean native equivalent. Conjunctions follow locale convention — `&` for Latin-script locales that read it as native chrome, `·` for CJK locales where it works better than `&` next to ideographs, and `و / & / และ`-style natural conjunctions for the rest. Translations were generated with `claude-haiku-4-5` over OpenRouter using a single batch script with explicit instructions on chip-width budget (≈120px, target 1–4 native words), sentence casing, and brand-token preservation. Output was validated for JSON shape (every locale returns all 23 slugs) before splicing into the override blocks. Validation: pnpm --filter @open-design/landing-page typecheck -> 0 errors / 0 warnings; local dev (port 3067) renders the chip rail in Japanese / Russian / Traditional Chinese / Arabic / German / French on `/<locale>/plugins/templates/image/` (and the same rail on the other six artifact kinds, which share the subcategory slug map). Co-authored-by: Joey-nexu <joeylee12629@gmail.com> --- apps/landing-page/app/_lib/plugins-i18n.ts | 400 +++++++++++++++++++++ 1 file changed, 400 insertions(+) diff --git a/apps/landing-page/app/_lib/plugins-i18n.ts b/apps/landing-page/app/_lib/plugins-i18n.ts index 0f3e9ecb0..fa40b5bbc 100644 --- a/apps/landing-page/app/_lib/plugins-i18n.ts +++ b/apps/landing-page/app/_lib/plugins-i18n.ts @@ -586,6 +586,31 @@ const overrides: Partial<Record<LandingLocaleCode, Partial<PluginsCopy>>> = { '短形式聲音識別的音訊提示和素材 — UI 音效、轉場提示音、旁白腳本。', }, }, + subcategory: { + 'business-dashboards': '儀表板', + 'app-prototypes': '應用程式', + 'landing-marketing': '登陸頁·行銷', + 'developer-tools': '開發者工具', + 'docs-reports': '文件·報告', + 'brand-design': '品牌·設計', + 'pitch-business': '提案·商務', + 'course-training': '課程·培訓', + 'reports-briefings': '報告·簡報', + 'product-sales': '產品·銷售', + 'engineering-talks': '工程分享', + 'creative-decks': '創意簡報', + 'ui-product-mockups': 'UI·產品模型', + 'brand-visuals': '品牌·標誌', + 'storyboards-motion-refs': '分鏡腳本', + 'social-content': '社群·內容', + 'avatar-portrait': '頭像·肖像', + 'illustration-style': '插圖·風格', + 'motion-effects': '動畫·特效', + 'social-short-form': '社群短影音', + 'marketing-product': '行銷·產品', + 'data-explainers': '資料·圖解', + 'cinematic-story': '電影敘事', + }, }, ja: { hubLabel: 'プラグインライブラリ', hubHeading: (n) => `${n} 個の組み合わせ可能なパーツ。`, @@ -710,6 +735,31 @@ const overrides: Partial<Record<LandingLocaleCode, Partial<PluginsCopy>>> = { 'ショートフォームソニックアイデンティティのためのオーディオプロンプトとステム — UIサウンド、トランジショナルバンパー、ボイスオーバースクリプト。', }, }, + subcategory: { + 'business-dashboards': 'ダッシュボード', + 'app-prototypes': 'アプリ', + 'landing-marketing': 'ランディング・マーケティング', + 'developer-tools': '開発者ツール', + 'docs-reports': 'ドキュメント・レポート', + 'brand-design': 'ブランド・デザイン', + 'pitch-business': 'ピッチ・ビジネス', + 'course-training': 'コース・トレーニング', + 'reports-briefings': 'レポート・ブリーフィング', + 'product-sales': 'プロダクト・セールス', + 'engineering-talks': 'エンジニアリング', + 'creative-decks': 'クリエイティブデッキ', + 'ui-product-mockups': 'UI・プロダクトモックアップ', + 'brand-visuals': 'ブランド・ロゴ', + 'storyboards-motion-refs': 'ストーリーボード', + 'social-content': 'ソーシャル・コンテンツ', + 'avatar-portrait': 'アバター・ポートレート', + 'illustration-style': 'イラスト・スタイル', + 'motion-effects': 'モーション・エフェクト', + 'social-short-form': 'ソーシャル短編', + 'marketing-product': 'マーケティング・プロダクト', + 'data-explainers': 'データ・解説', + 'cinematic-story': 'シネマティック', + }, }, ko: { hubLabel: '플러그인 라이브러리', hubHeading: (n) => `${n}개의 조합 가능한 구성요소.`, @@ -834,6 +884,31 @@ const overrides: Partial<Record<LandingLocaleCode, Partial<PluginsCopy>>> = { '숏폼 소닉 아이덴티티를 위한 오디오 프롬프트 및 스템 — UI 사운드, 전환 범퍼, 보이스오버 스크립트.', }, }, + subcategory: { + 'business-dashboards': '대시보드', + 'app-prototypes': '앱', + 'landing-marketing': '랜딩 & 마케팅', + 'developer-tools': '개발자 도구', + 'docs-reports': '문서 & 리포트', + 'brand-design': '브랜드 & 디자인', + 'pitch-business': '피치 & 비즈니스', + 'course-training': '강좌 & 교육', + 'reports-briefings': '리포트 & 브리핑', + 'product-sales': '제품 & 판매', + 'engineering-talks': '엔지니어링 토크', + 'creative-decks': '크리에이티브 덱', + 'ui-product-mockups': 'UI & 제품 목업', + 'brand-visuals': '브랜드 & 로고', + 'storyboards-motion-refs': '스토리보드', + 'social-content': '소셜 & 콘텐츠', + 'avatar-portrait': '아바타 & 초상화', + 'illustration-style': '일러스트 & 스타일', + 'motion-effects': '모션 & 이펙트', + 'social-short-form': '소셜 숏폼', + 'marketing-product': '마케팅 & 제품', + 'data-explainers': '데이터 & 설명', + 'cinematic-story': '시네마틱 스토리', + }, }, de: { hubLabel: 'Plugin-Bibliothek', hubHeading: (n) => `${n} kombinierbare Bausteine.`, @@ -958,6 +1033,31 @@ const overrides: Partial<Record<LandingLocaleCode, Partial<PluginsCopy>>> = { 'Audio-Prompts und Stems für Short-Form Sonic Identity — UI-Sounds, Übergangsbumper, Voiceover-Skripte.', }, }, + subcategory: { + 'business-dashboards': 'Dashboards', + 'app-prototypes': 'Apps', + 'landing-marketing': 'Landing & Marketing', + 'developer-tools': 'Developer-Tools', + 'docs-reports': 'Dokumente & Berichte', + 'brand-design': 'Brand & Design', + 'pitch-business': 'Pitch & Business', + 'course-training': 'Schulung & Training', + 'reports-briefings': 'Berichte & Briefings', + 'product-sales': 'Produkt & Vertrieb', + 'engineering-talks': 'Engineering-Talks', + 'creative-decks': 'Creative Decks', + 'ui-product-mockups': 'UI & Mockups', + 'brand-visuals': 'Brand & Logo', + 'storyboards-motion-refs': 'Storyboards', + 'social-content': 'Social & Content', + 'avatar-portrait': 'Avatar & Portrait', + 'illustration-style': 'Illustration & Stil', + 'motion-effects': 'Motion & Effekte', + 'social-short-form': 'Social Short-Form', + 'marketing-product': 'Marketing & Produkt', + 'data-explainers': 'Daten & Explainer', + 'cinematic-story': 'Cinematic Story', + }, }, fr: { hubLabel: 'Bibliothèque de plugins', hubHeading: (n) => `${n} éléments composables.`, @@ -1082,6 +1182,31 @@ const overrides: Partial<Record<LandingLocaleCode, Partial<PluginsCopy>>> = { "Prompts audio et stems pour identité sonore court-métrage — sons d'interface, bumpers de transition, scripts de voix off.", }, }, + subcategory: { + 'business-dashboards': 'Tableaux de bord', + 'app-prototypes': 'Applications', + 'landing-marketing': 'Landing & marketing', + 'developer-tools': 'Outils développeurs', + 'docs-reports': 'Docs & rapports', + 'brand-design': 'Brand & design', + 'pitch-business': 'Pitch & business', + 'course-training': 'Formation & cours', + 'reports-briefings': 'Rapports & briefings', + 'product-sales': 'Produit & ventes', + 'engineering-talks': 'Engineering talks', + 'creative-decks': 'Présentations créatives', + 'ui-product-mockups': 'UI & maquettes produit', + 'brand-visuals': 'Brand & logo', + 'storyboards-motion-refs': 'Storyboards', + 'social-content': 'Social & contenu', + 'avatar-portrait': 'Avatar & portrait', + 'illustration-style': 'Illustration & style', + 'motion-effects': 'Motion & effets', + 'social-short-form': 'Contenu court social', + 'marketing-product': 'Marketing & produit', + 'data-explainers': 'Data & explainers', + 'cinematic-story': 'Cinematic story', + }, }, ru: { hubLabel: 'Библиотека плагинов', hubHeading: (n) => `${n} компонуемых элементов.`, @@ -1206,6 +1331,31 @@ const overrides: Partial<Record<LandingLocaleCode, Partial<PluginsCopy>>> = { 'Аудиопромпты и основы для короткоформатной звуковой идентичности — звуки UI, переходные бамперы, скрипты озвучки.', }, }, + subcategory: { + 'business-dashboards': 'Панели управления', + 'app-prototypes': 'Приложения', + 'landing-marketing': 'Лендинги & маркетинг', + 'developer-tools': 'Инструменты разработки', + 'docs-reports': 'Документы & отчёты', + 'brand-design': 'Бренд & дизайн', + 'pitch-business': 'Питч & бизнес', + 'course-training': 'Курсы & обучение', + 'reports-briefings': 'Отчёты & брифинги', + 'product-sales': 'Продукт & продажи', + 'engineering-talks': 'Инженерные презентации', + 'creative-decks': 'Креативные колоды', + 'ui-product-mockups': 'UI & макеты продукта', + 'brand-visuals': 'Бренд & логотип', + 'storyboards-motion-refs': 'Раскадровки', + 'social-content': 'Соцсети & контент', + 'avatar-portrait': 'Аватар & портрет', + 'illustration-style': 'Иллюстрация & стиль', + 'motion-effects': 'Анимация & эффекты', + 'social-short-form': 'Короткий контент', + 'marketing-product': 'Маркетинг & продукт', + 'data-explainers': 'Данные & объяснения', + 'cinematic-story': 'Кинематографичные истории', + }, }, es: { hubLabel: 'Biblioteca de plugins', hubHeading: (n) => `${n} piezas componibles.`, @@ -1330,6 +1480,31 @@ const overrides: Partial<Record<LandingLocaleCode, Partial<PluginsCopy>>> = { 'Prompts de audio y stems para identidad sónica a corta distancia — sonidos de UI, bumpers de transición, scripts de voz en off.', }, }, + subcategory: { + 'business-dashboards': 'Dashboards', + 'app-prototypes': 'Apps', + 'landing-marketing': 'Landing & marketing', + 'developer-tools': 'Herramientas para desarrolladores', + 'docs-reports': 'Docs & informes', + 'brand-design': 'Brand & diseño', + 'pitch-business': 'Pitch & negocios', + 'course-training': 'Curso & formación', + 'reports-briefings': 'Informes & resúmenes', + 'product-sales': 'Producto & ventas', + 'engineering-talks': 'Charlas técnicas', + 'creative-decks': 'Presentaciones creativas', + 'ui-product-mockups': 'UI & mockups de producto', + 'brand-visuals': 'Brand & logo', + 'storyboards-motion-refs': 'Storyboards', + 'social-content': 'Social & contenido', + 'avatar-portrait': 'Avatar & retrato', + 'illustration-style': 'Ilustración & estilo', + 'motion-effects': 'Motion & efectos', + 'social-short-form': 'Contenido corto social', + 'marketing-product': 'Marketing & producto', + 'data-explainers': 'Datos & explicadores', + 'cinematic-story': 'Historia cinemática', + }, }, 'pt-br': { hubLabel: 'Biblioteca de plugins', hubHeading: (n) => `${n} peças combináveis.`, @@ -1454,6 +1629,31 @@ const overrides: Partial<Record<LandingLocaleCode, Partial<PluginsCopy>>> = { 'Prompts de áudio e stems para identidade sônica short-form — sons de UI, bumpers de transição, scripts de voiceover.', }, }, + subcategory: { + 'business-dashboards': 'Dashboards', + 'app-prototypes': 'Apps', + 'landing-marketing': 'Landing & marketing', + 'developer-tools': 'Ferramentas para desenvolvedores', + 'docs-reports': 'Docs & relatórios', + 'brand-design': 'Brand & design', + 'pitch-business': 'Pitch & negócios', + 'course-training': 'Curso & treinamento', + 'reports-briefings': 'Relatórios & resumos', + 'product-sales': 'Produto & vendas', + 'engineering-talks': 'Talks de engenharia', + 'creative-decks': 'Decks criativos', + 'ui-product-mockups': 'UI & mockups de produto', + 'brand-visuals': 'Brand & logo', + 'storyboards-motion-refs': 'Storyboards', + 'social-content': 'Social & conteúdo', + 'avatar-portrait': 'Avatar & retrato', + 'illustration-style': 'Ilustração & estilo', + 'motion-effects': 'Motion & efeitos', + 'social-short-form': 'Social em formato curto', + 'marketing-product': 'Marketing & produto', + 'data-explainers': 'Data & explicadores', + 'cinematic-story': 'Cinematic story', + }, }, it: { hubLabel: 'Libreria plugin', hubHeading: (n) => `${n} pezzi componibili.`, @@ -1578,6 +1778,31 @@ const overrides: Partial<Record<LandingLocaleCode, Partial<PluginsCopy>>> = { "Prompt audio e stem per l'identità sonico short-form — suoni UI, bump di transizione, script di voiceover.", }, }, + subcategory: { + 'business-dashboards': 'Dashboard', + 'app-prototypes': 'App', + 'landing-marketing': 'Landing & marketing', + 'developer-tools': 'Developer tools', + 'docs-reports': 'Documenti & report', + 'brand-design': 'Brand & design', + 'pitch-business': 'Pitch & business', + 'course-training': 'Corso & training', + 'reports-briefings': 'Report & briefing', + 'product-sales': 'Prodotto & vendite', + 'engineering-talks': 'Engineering talks', + 'creative-decks': 'Creative deck', + 'ui-product-mockups': 'UI & mockup prodotto', + 'brand-visuals': 'Brand & logo', + 'storyboards-motion-refs': 'Storyboard', + 'social-content': 'Social & contenuti', + 'avatar-portrait': 'Avatar & ritratto', + 'illustration-style': 'Illustrazione & stile', + 'motion-effects': 'Motion & effetti', + 'social-short-form': 'Social short form', + 'marketing-product': 'Marketing & prodotto', + 'data-explainers': 'Dati & spiegazioni', + 'cinematic-story': 'Cinematic story', + }, }, id: { hubLabel: 'Pustaka plugin', hubHeading: (n) => `${n} potongan yang bisa digabungkan.`, @@ -1702,6 +1927,31 @@ const overrides: Partial<Record<LandingLocaleCode, Partial<PluginsCopy>>> = { 'Prompt audio dan stem untuk identitas sonik short-form — suara UI, bumper transisional, skrip voiceover.', }, }, + subcategory: { + 'business-dashboards': 'Dashboard', + 'app-prototypes': 'Aplikasi', + 'landing-marketing': 'Landing & pemasaran', + 'developer-tools': 'Developer tools', + 'docs-reports': 'Dokumen & laporan', + 'brand-design': 'Brand & desain', + 'pitch-business': 'Pitch & bisnis', + 'course-training': 'Kursus & pelatihan', + 'reports-briefings': 'Laporan & briefing', + 'product-sales': 'Produk & penjualan', + 'engineering-talks': 'Engineering talks', + 'creative-decks': 'Deck kreatif', + 'ui-product-mockups': 'UI & mockup produk', + 'brand-visuals': 'Brand & logo', + 'storyboards-motion-refs': 'Storyboard', + 'social-content': 'Sosial & konten', + 'avatar-portrait': 'Avatar & potret', + 'illustration-style': 'Ilustrasi & gaya', + 'motion-effects': 'Motion & efek', + 'social-short-form': 'Konten pendek', + 'marketing-product': 'Pemasaran & produk', + 'data-explainers': 'Data & penjelasan', + 'cinematic-story': 'Cinematic story', + }, }, pl: { hubLabel: 'Biblioteka pluginów', hubHeading: (n) => `${n} komponowalnych elementów.`, @@ -1826,6 +2076,31 @@ const overrides: Partial<Record<LandingLocaleCode, Partial<PluginsCopy>>> = { 'Prompty audio i stemy dla krótkoformatowej tożsamości sonicznej — dźwięki UI, przejściowe bumpers, skrypty voiceover.', }, }, + subcategory: { + 'business-dashboards': 'Panele nawigacyjne', + 'app-prototypes': 'Aplikacje', + 'landing-marketing': 'Landing & marketing', + 'developer-tools': 'Narzędzia dla deweloperów', + 'docs-reports': 'Dokumenty & raporty', + 'brand-design': 'Brand & design', + 'pitch-business': 'Pitch & biznes', + 'course-training': 'Kursy & szkolenia', + 'reports-briefings': 'Raporty & briefingi', + 'product-sales': 'Produkt & sprzedaż', + 'engineering-talks': 'Inżynieria & technologia', + 'creative-decks': 'Kreatywne prezentacje', + 'ui-product-mockups': 'UI & mockupy produktów', + 'brand-visuals': 'Brand & logo', + 'storyboards-motion-refs': 'Storyboardy', + 'social-content': 'Social & treści', + 'avatar-portrait': 'Avatar & portrety', + 'illustration-style': 'Ilustracje & styl', + 'motion-effects': 'Animacja & efekty', + 'social-short-form': 'Social short form', + 'marketing-product': 'Marketing & produkt', + 'data-explainers': 'Dane & infografiki', + 'cinematic-story': 'Cinematic story', + }, }, ar: { hubLabel: 'مكتبة الإضافات', hubHeading: (n) => `${n} قطعة قابلة للتركيب.`, @@ -1950,6 +2225,31 @@ const overrides: Partial<Record<LandingLocaleCode, Partial<PluginsCopy>>> = { 'موجزات صوتية وسيقان لهوية سونية قصيرة الشكل — أصوات واجهة المستخدم والمصدات الانتقالية وسكريبتات السرد.', }, }, + subcategory: { + 'business-dashboards': 'لوحات المعلومات', + 'app-prototypes': 'التطبيقات', + 'landing-marketing': 'الصفحات الهبوط والتسويق', + 'developer-tools': 'أدوات المطورين', + 'docs-reports': 'المستندات والتقارير', + 'brand-design': 'العلامة التجارية والتصميم', + 'pitch-business': 'العروض والأعمال', + 'course-training': 'الدورات والتدريب', + 'reports-briefings': 'التقارير والإحاطات', + 'product-sales': 'المنتج والمبيعات', + 'engineering-talks': 'محادثات الهندسة', + 'creative-decks': 'العروض الإبداعية', + 'ui-product-mockups': 'UI والنماذج الأولية للمنتج', + 'brand-visuals': 'العلامة التجارية والشعار', + 'storyboards-motion-refs': 'اللوحات الموصوفة', + 'social-content': 'وسائل التواصل والمحتوى', + 'avatar-portrait': 'الصورة الرمزية والصورة الشخصية', + 'illustration-style': 'الرسوم التوضيحية والأسلوب', + 'motion-effects': 'الحركة والمؤثرات', + 'social-short-form': 'وسائل التواصل قصيرة الشكل', + 'marketing-product': 'التسويق والمنتج', + 'data-explainers': 'البيانات والشروحات', + 'cinematic-story': 'القصة السينمائية', + }, }, tr: { hubLabel: 'Eklenti kütüphanesi', hubHeading: (n) => `${n} birleştirilebilir parça.`, @@ -2074,6 +2374,31 @@ const overrides: Partial<Record<LandingLocaleCode, Partial<PluginsCopy>>> = { "Kısa form sonic kimliği için ses promptları ve stem'ler — UI sesleri, geçiş bumper'ları, sesli anlatım komut dosyaları.", }, }, + subcategory: { + 'business-dashboards': 'Kontrol Panelleri', + 'app-prototypes': 'Uygulamalar', + 'landing-marketing': 'Landing & pazarlama', + 'developer-tools': 'Geliştirici araçları', + 'docs-reports': 'Dokümanlar & raporlar', + 'brand-design': 'Marka & tasarım', + 'pitch-business': 'Sunum & iş', + 'course-training': 'Kurs & eğitim', + 'reports-briefings': 'Raporlar & özet', + 'product-sales': 'Ürün & satış', + 'engineering-talks': 'Mühendislik konuşmaları', + 'creative-decks': 'Yaratıcı sunumlar', + 'ui-product-mockups': 'UI & ürün mockupları', + 'brand-visuals': 'Marka & logo', + 'storyboards-motion-refs': 'Storyboardlar', + 'social-content': 'Sosyal & içerik', + 'avatar-portrait': 'Avatar & portre', + 'illustration-style': 'İllüstrasyon & stil', + 'motion-effects': 'Hareket & efektler', + 'social-short-form': 'Sosyal kısa form', + 'marketing-product': 'Pazarlama & ürün', + 'data-explainers': 'Veri & açıklamalar', + 'cinematic-story': 'Sinematik hikaye', + }, }, uk: { hubLabel: 'Бібліотека плагінів', hubHeading: (n) => `${n} компонованих елементів.`, @@ -2198,6 +2523,31 @@ const overrides: Partial<Record<LandingLocaleCode, Partial<PluginsCopy>>> = { 'Аудіо-промпти та стеми для короткотривалої звукової ідентичності — звуки UI, перехідні бампери, скрипти озвучення.', }, }, + subcategory: { + 'business-dashboards': 'Панелі & аналітика', + 'app-prototypes': 'Додатки', + 'landing-marketing': 'Лендинги & маркетинг', + 'developer-tools': 'Інструменти розробника', + 'docs-reports': 'Документи & звіти', + 'brand-design': 'Бренд & дизайн', + 'pitch-business': 'Pitch & бізнес', + 'course-training': 'Курси & навчання', + 'reports-briefings': 'Звіти & брифінги', + 'product-sales': 'Продукт & продажі', + 'engineering-talks': 'Engineering презентації', + 'creative-decks': 'Креативні колоди', + 'ui-product-mockups': 'UI & макети продукту', + 'brand-visuals': 'Бренд & логотип', + 'storyboards-motion-refs': 'Storyboards', + 'social-content': 'Соцмережі & контент', + 'avatar-portrait': 'Аватари & портрети', + 'illustration-style': 'Ілюстрації & стиль', + 'motion-effects': 'Анімація & ефекти', + 'social-short-form': 'Короткі відео', + 'marketing-product': 'Маркетинг & продукт', + 'data-explainers': 'Дані & пояснення', + 'cinematic-story': 'Синематографічна історія', + }, }, vi: { hubLabel: 'Thư viện plugin', hubHeading: (n) => `${n} thành phần có thể ghép nối.`, @@ -2322,6 +2672,31 @@ const overrides: Partial<Record<LandingLocaleCode, Partial<PluginsCopy>>> = { 'Các prompt âm thanh và stem cho sonic identity short-form — UI sound, transitional bumper, voice script.', }, }, + subcategory: { + 'business-dashboards': 'Bảng điều khiển', + 'app-prototypes': 'Ứng dụng', + 'landing-marketing': 'Landing & marketing', + 'developer-tools': 'Công cụ nhà phát triển', + 'docs-reports': 'Tài liệu & báo cáo', + 'brand-design': 'Thương hiệu & thiết kế', + 'pitch-business': 'Pitch & kinh doanh', + 'course-training': 'Khóa học & đào tạo', + 'reports-briefings': 'Báo cáo & tóm tắt', + 'product-sales': 'Sản phẩm & bán hàng', + 'engineering-talks': 'Kỹ thuật & thảo luận', + 'creative-decks': 'Bộ slide sáng tạo', + 'ui-product-mockups': 'UI & mockup sản phẩm', + 'brand-visuals': 'Thương hiệu & logo', + 'storyboards-motion-refs': 'Storyboards', + 'social-content': 'Mạng xã hội & nội dung', + 'avatar-portrait': 'Avatar & chân dung', + 'illustration-style': 'Minh họa & phong cách', + 'motion-effects': 'Chuyển động & hiệu ứng', + 'social-short-form': 'Video ngắn mạng xã hội', + 'marketing-product': 'Marketing & sản phẩm', + 'data-explainers': 'Dữ liệu & giải thích', + 'cinematic-story': 'Câu chuyện điện ảnh', + }, }, nl: { hubLabel: 'Plugin-bibliotheek', hubHeading: (n) => `${n} combineerbare onderdelen.`, @@ -2446,6 +2821,31 @@ const overrides: Partial<Record<LandingLocaleCode, Partial<PluginsCopy>>> = { 'Audioprompts en stems voor korte-vorm sonic identity — UI-geluiden, overgangsbumpers, voiceover-scripts.', }, }, + subcategory: { + 'business-dashboards': 'Dashboards', + 'app-prototypes': 'Apps', + 'landing-marketing': 'Landing & marketing', + 'developer-tools': 'Developer tools', + 'docs-reports': 'Docs & rapporten', + 'brand-design': 'Brand & design', + 'pitch-business': 'Pitch & business', + 'course-training': 'Cursus & training', + 'reports-briefings': 'Rapporten & briefings', + 'product-sales': 'Product & sales', + 'engineering-talks': 'Engineering talks', + 'creative-decks': 'Creative decks', + 'ui-product-mockups': 'UI & product mockups', + 'brand-visuals': 'Brand & logo', + 'storyboards-motion-refs': 'Storyboards', + 'social-content': 'Social & content', + 'avatar-portrait': 'Avatar & portret', + 'illustration-style': 'Illustratie & stijl', + 'motion-effects': 'Motion & effects', + 'social-short-form': 'Social short form', + 'marketing-product': 'Marketing & product', + 'data-explainers': 'Data & uitleg', + 'cinematic-story': 'Cinematic story', + }, }, }; From 055680a67d6517800c1f039fa623a452f3ae5679 Mon Sep 17 00:00:00 2001 From: kami <31983330+bulai0408@users.noreply.github.com> Date: Fri, 29 May 2026 11:20:47 +0800 Subject: [PATCH 09/56] fix(daemon): dedupe scheduled routine slots (#1971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(daemon): dedupe scheduled routine slots Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): claim scheduled routine runs atomically Co-authored-by: multica-agent <github@multica.ai> * Fix routine loser snapshot rollback Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): defer scheduled routine side effects Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): terminate in-memory run on scheduled prepare failure If `prepare()` throws after `persistPreparedRun()` has mutated the routine run with real project/conversation/agentRunId values, the catch in `RoutineService.start_` previously left the in-memory chat run queued (no `discard()`), so its `completion` promise hung waiting on `design.runs.wait(run)` forever, and the `routine_runs` row stayed pinned to `routine-pending-*` placeholders even though the underlying project/conversation rows for those real IDs had been created. The catch now calls `handlerStart.discard?.()` so the in-memory run terminates as `canceled`, releasing `completion`, and passes the real IDs through `updateRun` so the persisted failed row reflects what was attempted instead of the placeholder sentinels. A cleanup failure inside `discard()` is logged via `console.error` rather than swallowed, following the same surface-don't-swallow rule the loser cleanup path uses. The original prepare error is still rethrown so the scheduler advances to the next cadence (the slot claim is already terminal, so retrying the same slot would just duplicate-claim and lose). Added regression coverage in `apps/daemon/tests/routines.test.ts` for both the normal prepare-failure path (real IDs persisted, discard fired, completion resolved) and the case where the cleanup itself also throws (failure surfaces via console.error, the row is still finalized with the real IDs). Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): clear placeholder IDs on scheduled prepare failure Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): finalize routine prepare failures * fix(daemon): defer manual routine setup cleanup Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): drop loser chat runs and rollback partial snapshot pins Two follow-ups from the latest scheduler-claim review: - Duplicate scheduled losers used to call `design.runs.finish(run, 'canceled')`, exposing a phantom canceled routine run on `/api/runs` even though no `routine_runs` row, conversation, or messages were ever committed. Split the handler tear-down into `discardUnstarted` (used for never-inserted paths — drops the in-memory run via the new `design.runs.drop()`) and the existing `discard` (used after `prepare()` runs — still finalizes as canceled and rolls back partial state). - `resolvePluginSnapshot()` calls `linkSnapshotToProject()` before linking the conversation/run, so a failure mid-link could leave the reused project pinned to a snapshot the routine never durably claimed while `resolvedRoutineSnapshot` stayed null. Capture the intermediate snapshot id in `partiallyAppliedSnapshotId` when the resolver throws, and let `discard()` fall back to it for `restoreProjectSnapshotLink` so the previous project pin is restored either way. Regression coverage added in `tests/routine-schedule-claims.test.ts`: - A scheduled loser does not surface a phantom canceled chat run via `/api/runs` after the slot is lost. - A resolver that throws after `linkSnapshotToProject()` (forced via a SQLite trigger on `conversations.applied_plugin_snapshot_id`) still restores the reused project's previous pin in `discard()`. * fix(daemon): return prepared routine run ids Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: kami.c <kami.c@chative.com> --- apps/daemon/src/db.ts | 49 +- apps/daemon/src/plugins/snapshots.ts | 42 + apps/daemon/src/routines.ts | 188 +++- apps/daemon/src/runs.ts | 24 + apps/daemon/src/server.ts | 305 ++++-- apps/daemon/tests/plugins-snapshots.test.ts | 33 + .../tests/routine-schedule-claims.test.ts | 921 ++++++++++++++++++ apps/daemon/tests/routines.test.ts | 466 ++++++++- 8 files changed, 1924 insertions(+), 104 deletions(-) create mode 100644 apps/daemon/tests/routine-schedule-claims.test.ts diff --git a/apps/daemon/src/db.ts b/apps/daemon/src/db.ts index 6c3245ad7..c7d54ff62 100644 --- a/apps/daemon/src/db.ts +++ b/apps/daemon/src/db.ts @@ -202,6 +202,14 @@ function migrate(db: SqliteDb): void { FOREIGN KEY(routine_id) REFERENCES routines(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS routine_schedule_claims ( + routine_id TEXT NOT NULL, + slot_at INTEGER NOT NULL, + claimed_at INTEGER NOT NULL, + PRIMARY KEY(routine_id, slot_at), + FOREIGN KEY(routine_id) REFERENCES routines(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_routine_runs_routine ON routine_runs(routine_id, started_at DESC); `); @@ -1495,6 +1503,41 @@ export function insertRoutineRun(db: SqliteDb, r: DbRow) { return getRoutineRun(db, r.id); } +export function insertScheduledRoutineRun(db: SqliteDb, r: DbRow, slotAt: number) { + const insertClaim = db.prepare( + `INSERT OR IGNORE INTO routine_schedule_claims + (routine_id, slot_at, claimed_at) + VALUES (?, ?, ?)`, + ); + const insertRun = db.prepare( + `INSERT INTO routine_runs + (id, routine_id, trigger, status, project_id, conversation_id, + agent_run_id, started_at, completed_at, summary, error, error_code) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + const tx = db.transaction(() => { + const claim = insertClaim.run(r.routineId, slotAt, Date.now()); + if (claim.changes === 0) return false; + insertRun.run( + r.id, + r.routineId, + r.trigger, + r.status, + r.projectId, + r.conversationId, + r.agentRunId, + r.startedAt, + r.completedAt ?? null, + r.summary ?? null, + r.error ?? null, + r.errorCode ?? null, + ); + return true; + }); + if (!tx()) return null; + return getRoutineRun(db, r.id); +} + export function updateRoutineRun(db: SqliteDb, id: string, patch: DbRow) { const existing = getRoutineRun(db, id); if (!existing) return null; @@ -1504,10 +1547,14 @@ export function updateRoutineRun(db: SqliteDb, id: string, patch: DbRow) { }; db.prepare( `UPDATE routine_runs - SET status = ?, completed_at = ?, summary = ?, error = ?, error_code = ? + SET status = ?, project_id = ?, conversation_id = ?, agent_run_id = ?, + completed_at = ?, summary = ?, error = ?, error_code = ? WHERE id = ?`, ).run( merged.status, + merged.projectId, + merged.conversationId, + merged.agentRunId, merged.completedAt ?? null, merged.summary ?? null, merged.error ?? null, diff --git a/apps/daemon/src/plugins/snapshots.ts b/apps/daemon/src/plugins/snapshots.ts index de947e04e..57cc1ba62 100644 --- a/apps/daemon/src/plugins/snapshots.ts +++ b/apps/daemon/src/plugins/snapshots.ts @@ -171,6 +171,48 @@ export function linkSnapshotToProject(db: SqliteDb, snapshotId: string, projectI ).run(snapshotId, projectId); } +export function restoreProjectSnapshotLink( + db: SqliteDb, + projectId: string, + snapshotIdToDiscard: string, + previousSnapshotId: string | null | undefined, + discardedRunId?: string | null | undefined, +): void { + const previous = typeof previousSnapshotId === 'string' && previousSnapshotId.length > 0 + ? previousSnapshotId + : null; + db.prepare( + `UPDATE projects + SET applied_plugin_snapshot_id = ? + WHERE id = ? + AND applied_plugin_snapshot_id = ?`, + ).run(previous, projectId, snapshotIdToDiscard); + const expiry = unreferencedSnapshotExpiry(); + if (typeof discardedRunId === 'string' && discardedRunId.length > 0) { + const result = db.prepare( + `UPDATE applied_plugin_snapshots + SET run_id = NULL, + expires_at = ? + WHERE id = ? + AND project_id = ? + AND run_id = ?`, + ).run(expiry, snapshotIdToDiscard, projectId, discardedRunId); + if (result.changes > 0) return; + } + db.prepare( + `UPDATE applied_plugin_snapshots + SET expires_at = ? + WHERE id = ? + AND run_id IS NULL + AND project_id = ?`, + ).run(expiry, snapshotIdToDiscard, projectId); +} + +function unreferencedSnapshotExpiry(): number | null { + const days = readPluginEnvKnobs().snapshotUnreferencedTtlDays; + return days > 0 ? Date.now() + days * 24 * 60 * 60 * 1000 : null; +} + // Pin a snapshot to a conversation row. Same shape as // `linkSnapshotToProject` but mutates `conversations.applied_plugin_snapshot_id`. // Used when a plugin is applied inside an existing chat composer (§8.4). diff --git a/apps/daemon/src/routines.ts b/apps/daemon/src/routines.ts index 09495094d..69ab06042 100644 --- a/apps/daemon/src/routines.ts +++ b/apps/daemon/src/routines.ts @@ -77,6 +77,24 @@ export interface RoutineRunHandlerStart { conversationId: string; agentRunId: string; completion: Promise<RoutineRunCompletion>; + prepare?: (run: RoutineRun) => void | Promise<void>; + start?: () => void; + // Tear-down for the case where the handler returned a start handle but + // `RoutineService` later reached `prepare()` and it failed — i.e. the + // routine_run row exists, prepare may have partially mutated project / + // conversation / snapshot state, and the in-memory chat run still needs + // to terminate as `canceled`. Callers MUST surface failures rather than + // swallow them (the loser-retry path depends on it). + discard?: () => void; + // Tear-down for the case where the run was NEVER durably inserted — + // either `insertRun()` threw, or `insertRun()` returned `false` because + // a sibling daemon already won the scheduled slot. Prepare has not run, + // so no project / conversation / snapshot writes need rolling back. The + // in-memory chat run must also be removed from the registry instead of + // being finalized as `canceled`, otherwise duplicate-loser slots would + // surface phantom canceled runs on `/api/runs`. Falls back to `discard` + // when the handler does not distinguish the two cases. + discardUnstarted?: () => void; } export interface RoutineRunCompletion { @@ -95,7 +113,7 @@ export type RoutineRunHandler = (input: { export interface RoutinePersistence { list(): Routine[]; - insertRun(run: RoutineRun): void; + insertRun(run: RoutineRun, options?: { scheduledSlotAt?: number }): boolean | void; updateRun(id: string, patch: Partial<RoutineRun>): void; getLatestRun(routineId: string): RoutineRun | null; } @@ -106,6 +124,25 @@ interface ScheduledTimer { fireAt: Date; } +function clearRoutinePlaceholderId(value: string): string { + return value.startsWith('routine-pending-') ? '' : value; +} + +class ScheduledRunPersistenceError extends Error { + constructor( + readonly routineId: string, + readonly slotAt: number, + readonly originalError: unknown, + ) { + super(`Routine ${routineId} scheduled slot ${slotAt} could not be persisted`); + this.name = 'ScheduledRunPersistenceError'; + } +} + +function isScheduledRunPersistenceError(error: unknown): error is ScheduledRunPersistenceError { + return error instanceof ScheduledRunPersistenceError; +} + // ---------- timezone math ---------- // Returns the wall-clock parts of `atUtc` rendered in `timezone`. Uses @@ -458,22 +495,43 @@ export class RoutineService { if (!routine.enabled) return; const fireAt = nextRunAtForSchedule(routine.schedule); if (!fireAt) return; + this.scheduleRoutineAt(routine, fireAt); + } + + private retryScheduledSlot(routineId: string, fireAt: Date): void { + if (!this.started) return; + const routine = this.persistence.list().find((candidate) => candidate.id === routineId); + if (!routine?.enabled) return; + this.scheduleRoutineAt(routine, fireAt); + } + + private scheduleRoutineAt(routine: Routine, fireAt: Date): void { // setTimeout can't carry past 2^31 ms (~24.8 days); we cap and use // a chained re-schedule. Routines fire within hours/days, but a // misconfigured "next month" weekly value could otherwise overflow. const delay = Math.max(1_000, Math.min(2_000_000_000, fireAt.getTime() - Date.now())); const timer = setTimeout(() => { this.timers.delete(routine.id); - this.start_(routine.id, 'scheduled') + const slotAt = fireAt.getTime(); + this.start_(routine.id, 'scheduled', { scheduledSlotAt: slotAt }) + .then(() => { + // Always reschedule so a single fire keeps the cadence alive. + this.rescheduleOne(routine.id); + }) .catch((error) => { console.error( `[od] routine ${routine.id} scheduled run failed:`, - error instanceof Error ? error.message : error, + error instanceof ScheduledRunPersistenceError + ? error.originalError instanceof Error + ? error.originalError.message + : error.originalError + : error instanceof Error ? error.message : error, ); - }) - .finally(() => { - // Always reschedule so a single fire keeps the cadence alive. - this.rescheduleOne(routine.id); + if (isScheduledRunPersistenceError(error)) { + this.retryScheduledSlot(routine.id, fireAt); + } else { + this.rescheduleOne(routine.id); + } }); }, delay); if (typeof timer.unref === 'function') timer.unref(); @@ -491,6 +549,7 @@ export class RoutineService { private async start_( routineId: string, trigger: RoutineRunTrigger, + options: { scheduledSlotAt?: number } = {}, ): Promise<RoutineRunHandlerStart> { if (!this.runHandler) throw new Error('Routine run handler is not configured'); const inflight = this.inflight.get(routineId); @@ -505,7 +564,7 @@ export class RoutineService { const handler = this.runHandler; if (!handler) throw new Error('Routine run handler is not configured'); const handlerStart = await handler({ routine, trigger, startedAt, runId }); - this.persistence.insertRun({ + const run: RoutineRun = { id: runId, routineId: routine.id, trigger, @@ -518,7 +577,106 @@ export class RoutineService { summary: null, error: null, errorCode: null, - }); + }; + const scheduledSlotAt = options.scheduledSlotAt; + const wasScheduled = scheduledSlotAt != null; + const publicProjectId = () => clearRoutinePlaceholderId(run.projectId); + const publicConversationId = () => clearRoutinePlaceholderId(run.conversationId); + const publicAgentRunId = () => clearRoutinePlaceholderId(run.agentRunId); + const scrubRoutinePlaceholders = () => { + run.projectId = publicProjectId(); + run.conversationId = publicConversationId(); + run.agentRunId = publicAgentRunId(); + }; + // Tear-down to use when the durable routine_run row was never + // inserted (insertRun threw, or another daemon already won the slot). + // Prefer the explicit `discardUnstarted` callback when the handler + // distinguishes the two cases — that one drops the in-memory chat run + // entirely instead of finalizing it as `canceled`, so duplicate + // scheduled losers do not surface phantom runs on `/api/runs`. + // Handlers that do not implement the split still see `discard`. + const discardUnstarted = handlerStart.discardUnstarted ?? handlerStart.discard; + let inserted = true; + try { + inserted = this.persistence.insertRun(run, options) !== false; + } catch (error) { + try { + discardUnstarted?.(); + } catch (discardError) { + if (wasScheduled) { + throw new ScheduledRunPersistenceError(routine.id, scheduledSlotAt, discardError); + } + throw discardError; + } + if (wasScheduled) { + throw new ScheduledRunPersistenceError(routine.id, scheduledSlotAt, error); + } + throw error; + } + if (!inserted) { + try { + discardUnstarted?.(); + } catch (discardError) { + if (wasScheduled) { + throw new ScheduledRunPersistenceError(routine.id, scheduledSlotAt, discardError); + } + throw discardError; + } + return handlerStart; + } + try { + await handlerStart.prepare?.(run); + const preparedIdsChanged = + run.projectId !== handlerStart.projectId + || run.conversationId !== handlerStart.conversationId + || run.agentRunId !== handlerStart.agentRunId; + handlerStart.projectId = run.projectId; + handlerStart.conversationId = run.conversationId; + handlerStart.agentRunId = run.agentRunId; + if (wasScheduled || preparedIdsChanged) { + this.persistence.updateRun(runId, { + projectId: run.projectId, + conversationId: run.conversationId, + agentRunId: run.agentRunId, + }); + } + } catch (error) { + // Terminate the in-memory chat run created by `handler(...)` so its + // `completion` promise resolves instead of waiting forever on a + // run that will never start. Surface any cleanup failure rather + // than swallow it, but still finalize the persisted row. + let discardError: unknown = null; + try { + handlerStart.discard?.(); + } catch (err) { + discardError = err; + } + if (discardError != null) { + console.error( + `[od] routine ${routine.id} prepare cleanup failed:`, + discardError instanceof Error ? discardError.message : discardError, + ); + } + // Persist IDs only after `prepare()` has replaced routine + // placeholders with real resources. If preparation failed before + // enrichment, clear the sentinels so the terminal row does not point + // at fabricated project/conversation IDs. For scheduled runs the + // slot claim was already accepted at `insertRun()`, so retrying the + // same slot is not appropriate — let the error propagate so the + // scheduler advances to the next cadence. + scrubRoutinePlaceholders(); + this.persistence.updateRun(runId, { + status: 'failed', + completedAt: Date.now(), + summary: null, + error: error instanceof Error ? error.message : String(error), + errorCode: null, + projectId: run.projectId, + conversationId: run.conversationId, + agentRunId: run.agentRunId, + }); + throw error; + } handlerStart.completion .then((completion) => { this.persistence.updateRun(runId, { @@ -538,6 +696,18 @@ export class RoutineService { errorCode: null, }); }); + try { + handlerStart.start?.(); + } catch (error) { + this.persistence.updateRun(runId, { + status: 'failed', + completedAt: Date.now(), + summary: null, + error: error instanceof Error ? error.message : String(error), + errorCode: null, + }); + throw error; + } return handlerStart; })(); this.inflight.set(routineId, promise); diff --git a/apps/daemon/src/runs.ts b/apps/daemon/src/runs.ts index e8ec3f411..35db31b71 100644 --- a/apps/daemon/src/runs.ts +++ b/apps/daemon/src/runs.ts @@ -295,6 +295,29 @@ export function createChatRunService({ return new Promise((resolve) => run.waiters.add(resolve)); }; + // Drop a run from the in-memory registry without emitting any terminal + // event. Used by callers that prepared a run optimistically (created the + // record before some external precondition was checked) and need to undo + // the create without surfacing the run via `/api/runs`. Only valid before + // the run reaches a terminal status — terminal runs use scheduleCleanup + // and would already have notified any subscribers. + const drop = (run) => { + if (!run) return; + if (TERMINAL_RUN_STATUSES.has(run.status)) return; + runs.delete(run.id); + for (const sse of run.clients) { + try { sse.end(); } catch { /* best-effort detach */ } + } + run.clients.clear(); + // Resolve any pending waiters with a synthetic "canceled" status so + // they unblock instead of hanging forever — the run is being dropped + // because nothing will ever start. + run.status = 'canceled'; + run.updatedAt = Date.now(); + for (const waiter of run.waiters) waiter(statusBody(run)); + run.waiters.clear(); + }; + return { create, start, @@ -307,6 +330,7 @@ export function createChatRunService({ emit, finish, fail, + drop, statusBody, isTerminal(status) { return TERMINAL_RUN_STATUSES.has(status); diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index bc5a6cd50..773d6798e 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -132,6 +132,7 @@ import { registerBuiltInAtomWorkers, registerBundledPlugins, registryRootsForDataDir, + restoreProjectSnapshotLink, resolvePluginSnapshot, runPipelineForRun, runStageWithRegistry, @@ -367,6 +368,7 @@ import { insertProject, insertRoutine, insertRoutineRun, + insertScheduledRoutineRun, insertTemplate, findTemplateByNameAndProject, updateTemplate, @@ -4016,8 +4018,8 @@ export async function startServer({ // delegates "list me everything" / "record a run" back to SQLite. routineService = new RoutineService({ list: () => listRoutines(db).map((row) => routineDbRowToContract(row, null)), - insertRun: (run) => { - insertRoutineRun(db, { + insertRun: (run, options) => { + const row = { id: run.id, routineId: run.routineId, trigger: run.trigger, @@ -4030,7 +4032,12 @@ export async function startServer({ summary: run.summary, error: run.error, errorCode: run.errorCode, - }); + }; + if (options?.scheduledSlotAt != null) { + return Boolean(insertScheduledRoutineRun(db, row, options.scheduledSlotAt)); + } + insertRoutineRun(db, row); + return true; }, updateRun: (id, patch) => { updateRoutineRun(db, id, patch); @@ -12923,12 +12930,13 @@ export async function startServer({ const stamp = formatLocalProjectTimestamp(new Date(now).toISOString()); let projectId; let projectName; - if (routine.target.mode === 'reuse') { - const project = getProject(db, routine.target.projectId); - if (!project) throw new Error(`Routine target project ${routine.target.projectId} not found`); - projectId = project.id; - projectName = project.name; - } else { + const scheduledPlaceholderProjectId = `routine-pending-project-${runId}`; + const scheduledPlaceholderConversationId = `routine-pending-conv-${runId}`; + let createdProjectId: string | null = null; + let createdConversationId: string | null = null; + let previousProjectSnapshotId: string | null = null; + const createRoutineProject = () => { + if (createdProjectId) return; projectId = `routine-${randomUUID()}`; projectName = `${routine.name} · ${stamp}`; insertProject(db, { @@ -12948,67 +12956,102 @@ export async function startServer({ createdAt: now, updatedAt: now, }); + createdProjectId = projectId; + }; + if (routine.target.mode === 'reuse') { + const project = getProject(db, routine.target.projectId); + if (!project) throw new Error(`Routine target project ${routine.target.projectId} not found`); + projectId = project.id; + projectName = project.name; + previousProjectSnapshotId = project.appliedPluginSnapshotId ?? null; } - const conversationId = `routine-conv-${randomUUID()}`; - const conversationTitle = routine.target.mode === 'reuse' + let conversationId = `routine-conv-${randomUUID()}`; + let conversationCreatedEvent: ProjectConversationCreatedSsePayload | null = null; + const routineConversationTitle = () => routine.target.mode === 'reuse' ? `${routine.name} · ${stamp}` : projectName; - insertConversation(db, { - id: conversationId, - projectId, - title: conversationTitle, - createdAt: now, - updatedAt: now, - }); - - // Notify any open `ProjectView` watching this project so its - // conversation list picks up the new routine conversation without - // requiring the user to leave and re-enter the project (#1361). - // For reuse-an-existing-project mode this is the only path the - // open view has to learn the conversation exists; for new-project - // mode this is harmless (no subscribers for a project that was - // just created milliseconds ago). The payload shape is the shared - // `ProjectConversationCreatedSsePayload` from `@open-design/contracts` - // so the daemon producer and the web consumer cannot drift. - /** @type {ProjectConversationCreatedSsePayload} */ - const conversationCreatedEvent = { - type: 'conversation-created', - projectId, - conversationId, - title: conversationTitle, - createdAt: now, + const createRoutineConversation = () => { + if (createdConversationId) return; + if (!projectId) createRoutineProject(); + if (!projectId) throw new Error('Routine project could not be prepared'); + conversationId = `routine-conv-${randomUUID()}`; + insertConversation(db, { + id: conversationId, + projectId, + title: routineConversationTitle(), + createdAt: now, + updatedAt: now, + }); + createdConversationId = conversationId; + conversationCreatedEvent = { + type: 'conversation-created', + projectId, + conversationId, + title: routineConversationTitle(), + createdAt: now, + }; }; - emitProjectEvent(projectId, conversationCreatedEvent); const assistantMessageId = `routine-assistant-${randomUUID()}`; let resolvedRoutineSnapshot = null; + // Tracks any snapshot id that `resolvePluginSnapshot()` already pinned + // to the reused project before the resolver threw on a later linking + // step. `finalizeOk()` performs `linkSnapshotToProject()` BEFORE + // `linkSnapshotToConversation()` / `linkSnapshotToRun()`, so a failure + // mid-resolve can leave `projects.applied_plugin_snapshot_id` repointed + // at a snapshot the routine never durably claimed. The rollback path in + // `discard()` falls back to this id when `resolvedRoutineSnapshot` is + // still null so the reused project pin is restored either way. + let partiallyAppliedSnapshotId: string | null = null; const primaryPluginId = routineContext.pluginIds?.[0] ?? null; - if (primaryPluginId) { + const resolveRoutinePluginSnapshot = async () => { + if (!primaryPluginId || resolvedRoutineSnapshot) return; const registry = await loadPluginRegistryView(); - const resolved = resolvePluginSnapshot({ - db, - body: { - pluginId: primaryPluginId, - pluginInputs: { prompt: routine.prompt }, - }, - projectId, - conversationId, - registry, - activeProjectDesignSystem: - typeof appConfig.designSystemId === 'string' && appConfig.designSystemId.length > 0 - ? { id: appConfig.designSystemId } - : undefined, - }); + const projectSnapshotBefore = routine.target.mode === 'reuse' + ? getProject(db, routine.target.projectId)?.appliedPluginSnapshotId ?? null + : null; + let resolved; + try { + resolved = resolvePluginSnapshot({ + db, + body: { + pluginId: primaryPluginId, + pluginInputs: { prompt: routine.prompt }, + }, + projectId, + conversationId, + registry, + activeProjectDesignSystem: + typeof appConfig.designSystemId === 'string' && appConfig.designSystemId.length > 0 + ? { id: appConfig.designSystemId } + : undefined, + }); + } catch (resolverError) { + // `resolvePluginSnapshot()` may have already updated the reused + // project's pin via `linkSnapshotToProject()` before throwing on + // `linkSnapshotToConversation()` (or `linkSnapshotToRun()`). Capture + // whatever pin it left behind so `discard()` can roll it back even + // though `resolvedRoutineSnapshot` will stay null. + if (routine.target.mode === 'reuse') { + const after = getProject(db, routine.target.projectId)?.appliedPluginSnapshotId ?? null; + if (after && after !== projectSnapshotBefore) { + partiallyAppliedSnapshotId = after; + } + } + throw resolverError; + } if (resolved && !resolved.ok) { + // Non-throwing resolver failures cannot have called `finalizeOk()`, + // so the project pin is still the previous one — nothing to roll + // back beyond the loser cleanup the caller will perform. throw new Error(`Automation plugin ${primaryPluginId} could not be applied: ${JSON.stringify(resolved.body)}`); } resolvedRoutineSnapshot = resolved; - } - + }; const run = design.runs.create({ - projectId, - conversationId, + projectId: projectId ?? scheduledPlaceholderProjectId, + conversationId: createdConversationId ? conversationId : scheduledPlaceholderConversationId, assistantMessageId, clientRequestId: `routine-${trigger}-${randomUUID()}`, agentId, @@ -13019,48 +13062,115 @@ export async function startServer({ } : {}), }); - if (resolvedRoutineSnapshot?.ok) { - try { + const persistPreparedRun = async (routineRun = null) => { + if (!projectId) { + createRoutineProject(); + } + if (projectId) { + run.projectId = projectId; + if (routineRun) { + routineRun.projectId = projectId; + } + } + createRoutineConversation(); + run.conversationId = conversationId; + if (routineRun) { + routineRun.conversationId = conversationId; + routineRun.agentRunId = run.id; + } + await resolveRoutinePluginSnapshot(); + if (resolvedRoutineSnapshot?.ok) { + run.appliedPluginSnapshotId = resolvedRoutineSnapshot.snapshotId; + run.pluginId = resolvedRoutineSnapshot.snapshot.pluginId; const { linkSnapshotToRun } = await import('./plugins/snapshots.js'); linkSnapshotToRun(db, resolvedRoutineSnapshot.snapshotId, run.id); - } catch { - // Snapshot linking is best-effort; the in-memory run still carries it. } - } - upsertMessage(db, conversationId, { - id: `routine-user-${run.id}`, - role: 'user', - content: routine.prompt, - }); - upsertMessage(db, conversationId, { - id: assistantMessageId, - role: 'assistant', - content: '', - agentId, - agentName: getAgentDef(agentId)?.name ?? agentId, - runId: run.id, - runStatus: 'queued', - startedAt: now, - }); + upsertMessage(db, conversationId, { + id: `routine-user-${run.id}`, + role: 'user', + content: routine.prompt, + }); + upsertMessage(db, conversationId, { + id: assistantMessageId, + role: 'assistant', + content: '', + agentId, + agentName: getAgentDef(agentId)?.name ?? agentId, + runId: run.id, + runStatus: 'queued', + startedAt: now, + }); + }; const modelPrefs = appConfig.agentModels?.[agentId] ?? {}; - design.runs.start(run, () => startChatRun({ - agentId, - projectId, - conversationId: run.conversationId, - assistantMessageId: run.assistantMessageId, - clientRequestId: run.clientRequestId, - skillId: routineSkillId, - designSystemId: appConfig.designSystemId ?? null, - context: routineContext, - model: modelPrefs.model ?? null, - reasoning: modelPrefs.reasoning ?? null, - message: routine.prompt, - systemPrompt: [ - `You are running an unattended scheduled routine named "${routine.name}".`, - 'Do not ask follow-up questions, do not emit <question-form>, and do not wait for user input. Pick reasonable defaults and finish the task.', - ].join('\n'), - }, run)); + const start = () => { + // Notify any open `ProjectView` only after the routine run row has + // been accepted and preparation has completed, so failed setup does not + // surface phantom conversations (#1361). + if (conversationCreatedEvent) emitProjectEvent(projectId, conversationCreatedEvent); + design.runs.start(run, () => startChatRun({ + agentId, + projectId, + conversationId: run.conversationId, + assistantMessageId: run.assistantMessageId, + clientRequestId: run.clientRequestId, + skillId: routineSkillId, + designSystemId: appConfig.designSystemId ?? null, + context: routineContext, + model: modelPrefs.model ?? null, + reasoning: modelPrefs.reasoning ?? null, + message: routine.prompt, + systemPrompt: [ + `You are running an unattended scheduled routine named "${routine.name}".`, + 'Do not ask follow-up questions, do not emit <question-form>, and do not wait for user input. Pick reasonable defaults and finish the task.', + ].join('\n'), + }, run)); + }; + + // Tear-down for the case where the durable routine_run row was never + // inserted (sibling daemon won the slot, or insertRun threw). The + // in-memory chat run was created speculatively above, but the deferred + // `persistPreparedRun()` has not run yet — so no project / conversation + // / snapshot writes have to be rolled back. Dropping the run keeps it + // off `/api/runs` instead of leaving a phantom canceled entry there. + const discardUnstarted = () => { + design.runs.drop(run); + }; + + const discard = () => { + if (typeof run.projectId === 'string' && run.projectId.startsWith('routine-pending-')) { + run.projectId = null; + } + if (typeof run.conversationId === 'string' && run.conversationId.startsWith('routine-pending-')) { + run.conversationId = null; + } + design.runs.finish(run, 'canceled'); + if (routine.target.mode === 'reuse') { + // Prefer the fully-resolved snapshot id; fall back to whatever id + // `resolvePluginSnapshot()` left pinned on the project if it threw + // partway through linking — see the comment on + // `partiallyAppliedSnapshotId` above. + const snapshotIdToDiscard = + resolvedRoutineSnapshot?.ok + ? resolvedRoutineSnapshot.snapshotId + : partiallyAppliedSnapshotId; + if (snapshotIdToDiscard) { + restoreProjectSnapshotLink( + db, + projectId, + snapshotIdToDiscard, + previousProjectSnapshotId, + run.id, + ); + } + } + if (createdConversationId) { + deleteConversation(db, createdConversationId); + } + if (createdProjectId) { + dbDeleteProject(db, createdProjectId); + } + }; const completion = (async () => { const finalStatus = await design.runs.wait(run); @@ -13110,7 +13220,16 @@ export async function startServer({ }; })(); - return { projectId, conversationId, agentRunId: run.id, completion }; + return { + projectId: run.projectId, + conversationId: run.conversationId, + agentRunId: run.id, + completion, + prepare: persistPreparedRun, + start, + discard, + discardUnstarted, + }; }); routineService.start(); diff --git a/apps/daemon/tests/plugins-snapshots.test.ts b/apps/daemon/tests/plugins-snapshots.test.ts index b8e876010..8a5db82b1 100644 --- a/apps/daemon/tests/plugins-snapshots.test.ts +++ b/apps/daemon/tests/plugins-snapshots.test.ts @@ -17,7 +17,9 @@ import { createSnapshot, getSnapshot, linkSnapshotToRun, + linkSnapshotToProject, markSnapshotStale, + restoreProjectSnapshotLink, } from '../src/plugins/snapshots.js'; let db: Database.Database; @@ -106,6 +108,37 @@ describe('snapshots writer', () => { expect(after.expires_at).toBeNull(); }); + it('restoreProjectSnapshotLink makes an unlinked discarded snapshot expirable again', () => { + db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1'); + const previous = createSnapshot(db, baseInput({ query: 'Previous {{topic}}' })); + linkSnapshotToProject(db, previous.snapshotId, 'project-1'); + const discarded = createSnapshot(db, baseInput({ query: 'Discarded {{topic}}' })); + linkSnapshotToProject(db, discarded.snapshotId, 'project-1'); + + restoreProjectSnapshotLink( + db, + 'project-1', + discarded.snapshotId, + previous.snapshotId, + 'run-that-was-never-linked', + ); + + const project = db.prepare( + `SELECT applied_plugin_snapshot_id AS appliedPluginSnapshotId + FROM projects + WHERE id = ?`, + ).get('project-1') as { appliedPluginSnapshotId: string | null }; + const discardedRow = db.prepare( + `SELECT run_id AS runId, expires_at AS expiresAt + FROM applied_plugin_snapshots + WHERE id = ?`, + ).get(discarded.snapshotId) as { runId: string | null; expiresAt: number | null }; + + expect(project.appliedPluginSnapshotId).toBe(previous.snapshotId); + expect(discardedRow.runId).toBeNull(); + expect(discardedRow.expiresAt).not.toBeNull(); + }); + it('markSnapshotStale flips status', () => { db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1'); const snap = createSnapshot(db, baseInput()); diff --git a/apps/daemon/tests/routine-schedule-claims.test.ts b/apps/daemon/tests/routine-schedule-claims.test.ts new file mode 100644 index 000000000..0e82f1d05 --- /dev/null +++ b/apps/daemon/tests/routine-schedule-claims.test.ts @@ -0,0 +1,921 @@ +import type http from 'node:http'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; +import Database from 'better-sqlite3'; +import type { InstalledPluginRecord, PluginManifest } from '@open-design/contracts'; + +import { + closeDatabase, + getProject, + insertRoutine, + insertRoutineRun, + insertScheduledRoutineRun, + insertProject, + openDatabase, +} from '../src/db.js'; +import { startServer } from '../src/server.js'; +import { upsertInstalledPlugin } from '../src/plugins/registry.js'; +import { createSnapshot, linkSnapshotToProject } from '../src/plugins/snapshots.js'; + +let tmp: string; +let dbFile: string; + +beforeEach(async () => { + tmp = await mkdtemp(path.join(os.tmpdir(), 'od-routine-claims-')); + dbFile = path.join(tmp, 'app.sqlite'); +}); + +afterEach(async () => { + vi.useRealTimers(); + closeDatabase(); + await rm(tmp, { recursive: true, force: true }); +}); + +describe('routine scheduled slot claims', () => { + it('deduplicates scheduled run insertion in the same transaction as the slot claim', () => { + const first = openDatabase(tmp, { dataDir: tmp }); + insertRoutine(first, { + id: 'routine-1', + name: 'Daily brief', + prompt: 'Summarize the day', + scheduleKind: 'hourly', + scheduleValue: '15', + scheduleJson: JSON.stringify({ kind: 'hourly', minute: 15 }), + projectMode: 'create_each_run', + projectId: null, + skillId: null, + agentId: null, + enabled: true, + createdAt: 1779012000000, + updatedAt: 1779012000000, + }); + + const second = new Database(dbFile); + try { + second.pragma('foreign_keys = ON'); + + const firstRun = insertScheduledRoutineRun(first, makeRun('run-1'), 1779012900000); + const secondRun = insertScheduledRoutineRun(second, makeRun('run-2'), 1779012900000); + const manualRun = insertRoutineRun(second, makeRun('run-manual', { trigger: 'manual' })); + + expect(firstRun?.id).toBe('run-1'); + expect(secondRun).toBeNull(); + expect(manualRun?.id).toBe('run-manual'); + expect( + first.prepare(`SELECT id FROM routine_runs ORDER BY id`).all(), + ).toEqual([{ id: 'run-1' }, { id: 'run-manual' }]); + } finally { + second.close(); + } + }); +}); + +function makeRun(id: string, overrides: Record<string, unknown> = {}) { + return { + id, + routineId: 'routine-1', + trigger: 'scheduled', + status: 'running', + projectId: `project-${id}`, + conversationId: `conversation-${id}`, + agentRunId: `agent-${id}`, + startedAt: 1779012900000, + completedAt: null, + summary: null, + error: null, + ...overrides, + }; +} + +describe('routine scheduled loser cleanup', () => { + it('prepares a winning scheduled reuse run after the slot claim is persisted', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z')); + + const started = await startServer({ port: 0, returnServer: true }) as { + url: string; + server: http.Server; + shutdown?: () => Promise<void> | void; + }; + const dataDir = process.env.OD_DATA_DIR; + if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests'); + const db = openDatabase(tmp, { dataDir }); + const projectId = 'routine-winner-project'; + const routinePlugin = pluginRecord('routine-winner-plugin'); + upsertInstalledPlugin(db, routinePlugin); + insertProject(db, { + id: projectId, + name: 'Routine winner target', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + const previousSnapshot = createSnapshot(db, { + projectId, + pluginId: routinePlugin.id, + pluginVersion: routinePlugin.version, + manifestSourceDigest: '2'.repeat(64), + taskKind: 'new-generation', + inputs: { prompt: 'previous prompt' }, + resolvedContext: { items: [] }, + capabilitiesGranted: ['prompt:inject'], + capabilitiesRequired: ['prompt:inject'], + assetsStaged: [], + connectorsRequired: [], + connectorsResolved: [], + mcpServers: [], + query: 'Previous {{prompt}}', + }); + linkSnapshotToProject(db, previousSnapshot.snapshotId, projectId); + + try { + const createRoutine = await fetch(`${started.url}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Scheduled winner routine', + prompt: 'fresh prompt', + schedule: { kind: 'hourly', minute: 1 }, + target: { mode: 'reuse', projectId }, + context: { pluginIds: [routinePlugin.id] }, + agentId: 'codex', + enabled: true, + }), + }); + expect(createRoutine.status).toBe(201); + const created = await createRoutine.json() as { routine: { id: string } }; + + await vi.advanceTimersByTimeAsync(60_000); + vi.useRealTimers(); + let run: { projectId: string; conversationId: string; agentRunId: string } | undefined; + for (let attempt = 0; attempt < 200; attempt += 1) { + run = db.prepare( + `SELECT project_id AS projectId, conversation_id AS conversationId, agent_run_id AS agentRunId + FROM routine_runs + WHERE routine_id = ?`, + ).get(created.routine.id) as typeof run; + if (run?.conversationId?.startsWith('routine-conv-')) break; + await sleep(10); + } + expect(run).toBeDefined(); + if (!run) return; + expect(run.projectId).toBe(projectId); + expect(run.conversationId).toMatch(/^routine-conv-/); + expect(run.agentRunId).toMatch(/^[0-9a-f-]{36}$/); + expect(db.prepare( + `SELECT COUNT(*) AS n FROM conversations WHERE project_id = ?`, + ).get(projectId)).toEqual({ n: 1 }); + expect(db.prepare( + `SELECT COUNT(*) AS n FROM applied_plugin_snapshots WHERE project_id = ?`, + ).get(projectId)).toEqual({ n: 2 }); + expect(getProject(db, projectId)?.appliedPluginSnapshotId) + .not.toBe(previousSnapshot.snapshotId); + } finally { + await Promise.resolve(started.shutdown?.()); + await new Promise<void>((resolve) => started.server.close(() => resolve())); + } + }); + + it('does not let a discarded reuse-mode loser replace the shared project snapshot pin', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z')); + + const started = await startServer({ port: 0, returnServer: true }) as { + url: string; + server: http.Server; + shutdown?: () => Promise<void> | void; + }; + const dataDir = process.env.OD_DATA_DIR; + if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests'); + const db = openDatabase(tmp, { dataDir }); + const projectId = 'routine-reuse-project'; + const routinePlugin = pluginRecord('routine-plugin'); + upsertInstalledPlugin(db, routinePlugin); + insertProject(db, { + id: projectId, + name: 'Routine reuse target', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + const previousSnapshot = createSnapshot(db, { + projectId, + pluginId: routinePlugin.id, + pluginVersion: routinePlugin.version, + manifestSourceDigest: '0'.repeat(64), + taskKind: 'new-generation', + inputs: { prompt: 'previous prompt' }, + resolvedContext: { items: [] }, + capabilitiesGranted: ['prompt:inject'], + capabilitiesRequired: ['prompt:inject'], + assetsStaged: [], + connectorsRequired: [], + connectorsResolved: [], + mcpServers: [], + query: 'Previous {{prompt}}', + }); + linkSnapshotToProject(db, previousSnapshot.snapshotId, projectId); + + try { + const createRoutine = await fetch(`${started.url}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Scheduled reuse routine', + prompt: 'fresh prompt', + schedule: { kind: 'hourly', minute: 1 }, + target: { mode: 'reuse', projectId }, + context: { pluginIds: [routinePlugin.id] }, + agentId: 'codex', + enabled: true, + }), + }); + expect(createRoutine.status).toBe(201); + const created = await createRoutine.json() as { routine: { id: string } }; + const slotAt = Date.UTC(2026, 4, 17, 10, 1); + insertScheduledRoutineRun(db, { + ...makeRun('rollback-winning-run', { + routineId: created.routine.id, + projectId, + conversationId: 'winning-conversation', + agentRunId: 'winning-agent-run', + }), + }, slotAt); + + await vi.advanceTimersByTimeAsync(60_000); + const snapshotCount = (db.prepare( + `SELECT COUNT(*) AS n FROM applied_plugin_snapshots WHERE project_id = ?`, + ).get(projectId) as { n: number }).n; + expect(snapshotCount).toBe(1); + expect(getProject(db, projectId)?.appliedPluginSnapshotId) + .toBe(previousSnapshot.snapshotId); + } finally { + await Promise.resolve(started.shutdown?.()); + await new Promise<void>((resolve) => started.server.close(() => resolve())); + } + }); + + it('does not expose a phantom canceled run when a duplicate scheduled slot is lost', async () => { + // Reviewer regression: `server.ts` now creates the in-memory + // `design.runs` entry before `insertScheduledRoutineRun()` decides + // whether this daemon won the slot. The loser path used to call + // `design.runs.finish(run, 'canceled')`, which surfaced a phantom + // canceled chat run via `/api/runs` even though no `routine_runs` row, + // conversation, or messages were ever committed. The fix routes the + // never-inserted path through `design.runs.drop()` so duplicate losers + // do not leak in-memory runs back through the public API. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z')); + + const started = await startServer({ port: 0, returnServer: true }) as { + url: string; + server: http.Server; + shutdown?: () => Promise<void> | void; + }; + const dataDir = process.env.OD_DATA_DIR; + if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests'); + const db = openDatabase(tmp, { dataDir }); + const projectId = 'routine-phantom-loser-project'; + insertProject(db, { + id: projectId, + name: 'Routine phantom loser target', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + try { + const createRoutine = await fetch(`${started.url}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Scheduled phantom-loser routine', + prompt: 'fresh prompt', + schedule: { kind: 'hourly', minute: 1 }, + target: { mode: 'reuse', projectId }, + agentId: 'codex', + enabled: true, + }), + }); + expect(createRoutine.status).toBe(201); + const created = await createRoutine.json() as { routine: { id: string } }; + const slotAt = Date.UTC(2026, 4, 17, 10, 1); + // Pre-claim the slot from a sibling daemon so the loser branch fires + // in this process. The winning row carries the same routine and slot. + insertScheduledRoutineRun(db, { + ...makeRun('phantom-winning-run', { + routineId: created.routine.id, + projectId, + conversationId: 'phantom-winning-conversation', + agentRunId: 'phantom-winning-agent-run', + }), + }, slotAt); + + await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(0); + vi.useRealTimers(); + + // Wait until at least one tick after the scheduled timer fired so the + // loser branch has had a chance to clean up. + await sleep(50); + + // The only routine_runs row is the pre-seeded winner; the loser + // never made it through `insertScheduledRoutineRun()`. + const rows = db.prepare( + `SELECT id FROM routine_runs WHERE routine_id = ? ORDER BY id`, + ).all(created.routine.id) as Array<{ id: string }>; + expect(rows).toEqual([{ id: 'phantom-winning-run' }]); + + // `/api/runs` must not surface the loser's in-memory chat run as + // `canceled` — `design.runs.drop()` removes it from the registry. + const runsRes = await fetch(`${started.url}/api/runs`); + expect(runsRes.status).toBe(200); + const runsJson = await runsRes.json() as { + runs: Array<{ status: string; assistantMessageId: string | null }>; + }; + const phantom = runsJson.runs.find((run) => + typeof run.assistantMessageId === 'string' + && run.assistantMessageId.startsWith('routine-assistant-')); + expect(phantom).toBeUndefined(); + } finally { + vi.useRealTimers(); + await Promise.resolve(started.shutdown?.()); + await new Promise<void>((resolve) => started.server.close(() => resolve())); + } + }); + + it('restores the reused project pin when the snapshot resolver throws mid-link', async () => { + // Reviewer regression: `resolveRoutinePluginSnapshot()` only assigns + // `resolvedRoutineSnapshot` AFTER the resolver returns, but + // `resolvePluginSnapshot()` already calls `linkSnapshotToProject()` + // inside `finalizeOk()` before linking the conversation or run. If + // `linkSnapshotToConversation()` throws (e.g. a CHECK constraint, a + // missing conversation row, a trigger), `discard()` previously landed + // with `resolvedRoutineSnapshot === null` and never restored the + // project's prior pin — leaving the reused project pointed at a + // snapshot the routine never durably claimed. + // + // The fix captures the intermediate pin in `partiallyAppliedSnapshotId` + // when the resolver throws, and `discard()` falls back to it when + // `resolvedRoutineSnapshot` is still null. This test forces the link + // step to fail via a SQLite trigger on `conversations` (the resolver + // links the snapshot to the conversation row before returning, and + // that link path updates `conversations.applied_plugin_snapshot_id`). + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z')); + + const started = await startServer({ port: 0, returnServer: true }) as { + url: string; + server: http.Server; + shutdown?: () => Promise<void> | void; + }; + const dataDir = process.env.OD_DATA_DIR; + if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests'); + const db = openDatabase(tmp, { dataDir }); + const projectId = 'routine-mid-link-rollback-project'; + const routinePlugin = pluginRecord('routine-mid-link-plugin'); + upsertInstalledPlugin(db, routinePlugin); + insertProject(db, { + id: projectId, + name: 'Routine mid-link rollback target', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + const previousSnapshot = createSnapshot(db, { + projectId, + pluginId: routinePlugin.id, + pluginVersion: routinePlugin.version, + manifestSourceDigest: '3'.repeat(64), + taskKind: 'new-generation', + inputs: { prompt: 'previous prompt' }, + resolvedContext: { items: [] }, + capabilitiesGranted: ['prompt:inject'], + capabilitiesRequired: ['prompt:inject'], + assetsStaged: [], + connectorsRequired: [], + connectorsResolved: [], + mcpServers: [], + query: 'Previous {{prompt}}', + }); + linkSnapshotToProject(db, previousSnapshot.snapshotId, projectId); + + try { + const createRoutine = await fetch(`${started.url}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Scheduled mid-link rollback routine', + prompt: 'fresh prompt', + schedule: { kind: 'hourly', minute: 1 }, + target: { mode: 'reuse', projectId }, + context: { pluginIds: [routinePlugin.id] }, + agentId: 'codex', + enabled: true, + }), + }); + expect(createRoutine.status).toBe(201); + const created = await createRoutine.json() as { routine: { id: string } }; + + // Trigger after `linkSnapshotToProject()` but during + // `linkSnapshotToConversation()`. The resolver runs + // `UPDATE applied_plugin_snapshots SET conversation_id = ?, expires_at = NULL` + // followed by `UPDATE conversations SET applied_plugin_snapshot_id = ?`. + // We fail on the conversations.applied_plugin_snapshot_id update so the + // project pin has already moved but the resolver throws before + // returning a snapshot to the caller. + db.exec(` + DROP TRIGGER IF EXISTS fail_routine_conv_snapshot_link; + CREATE TRIGGER fail_routine_conv_snapshot_link + BEFORE UPDATE OF applied_plugin_snapshot_id ON conversations + WHEN NEW.applied_plugin_snapshot_id IS NOT NULL + AND NEW.id LIKE 'routine-conv-%' + BEGIN + SELECT RAISE(ABORT, 'routine conversation snapshot link failed'); + END; + `); + + await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(0); + vi.useRealTimers(); + + // Wait for the routine_runs row to land in a terminal failed state — + // the scheduled prepare-failure path patches the row to 'failed' + // after the slot claim is accepted. + let stored: { status: string } | undefined; + for (let attempt = 0; attempt < 200; attempt += 1) { + stored = db.prepare( + `SELECT status FROM routine_runs WHERE routine_id = ?`, + ).get(created.routine.id) as typeof stored; + if (stored?.status === 'failed') break; + await sleep(10); + } + expect(stored?.status).toBe('failed'); + + // The reused project's pin must point back at the pre-existing + // snapshot, not at the half-applied one. Without the rollback fix, + // `applied_plugin_snapshot_id` would still be the resolver's new id. + expect(getProject(db, projectId)?.appliedPluginSnapshotId) + .toBe(previousSnapshot.snapshotId); + } finally { + vi.useRealTimers(); + try { + db.exec('DROP TRIGGER IF EXISTS fail_routine_conv_snapshot_link'); + } catch { + // The test may fail before the trigger exists. + } + await Promise.resolve(started.shutdown?.()); + await new Promise<void>((resolve) => started.server.close(() => resolve())); + } + }); + + it('does not create provisional database state for a reuse-mode loser before the slot is won', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z')); + + const started = await startServer({ port: 0, returnServer: true }) as { + url: string; + server: http.Server; + shutdown?: () => Promise<void> | void; + }; + const dataDir = process.env.OD_DATA_DIR; + if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests'); + const db = openDatabase(tmp, { dataDir }); + const projectId = 'routine-rollback-failure-project'; + const routinePlugin = pluginRecord('routine-rollback-plugin'); + upsertInstalledPlugin(db, routinePlugin); + insertProject(db, { + id: projectId, + name: 'Routine rollback target', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + const previousSnapshot = createSnapshot(db, { + projectId, + pluginId: routinePlugin.id, + pluginVersion: routinePlugin.version, + manifestSourceDigest: '1'.repeat(64), + taskKind: 'new-generation', + inputs: { prompt: 'previous prompt' }, + resolvedContext: { items: [] }, + capabilitiesGranted: ['prompt:inject'], + capabilitiesRequired: ['prompt:inject'], + assetsStaged: [], + connectorsRequired: [], + connectorsResolved: [], + mcpServers: [], + query: 'Previous {{prompt}}', + }); + linkSnapshotToProject(db, previousSnapshot.snapshotId, projectId); + + try { + const createRoutine = await fetch(`${started.url}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Scheduled rollback routine', + prompt: 'fresh prompt', + schedule: { kind: 'hourly', minute: 1 }, + target: { mode: 'reuse', projectId }, + context: { pluginIds: [routinePlugin.id] }, + agentId: 'codex', + enabled: true, + }), + }); + expect(createRoutine.status).toBe(201); + const created = await createRoutine.json() as { routine: { id: string } }; + const slotAt = Date.UTC(2026, 4, 17, 10, 1); + insertScheduledRoutineRun(db, { + ...makeRun('winning-run', { + routineId: created.routine.id, + projectId, + conversationId: 'rollback-winning-conversation', + agentRunId: 'rollback-winning-agent-run', + }), + }, slotAt); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(getProject(db, projectId)?.appliedPluginSnapshotId) + .toBe(previousSnapshot.snapshotId); + expect(db.prepare( + `SELECT COUNT(*) AS n FROM conversations WHERE project_id = ?`, + ).get(projectId)).toEqual({ n: 0 }); + expect(db.prepare( + `SELECT COUNT(*) AS n FROM applied_plugin_snapshots WHERE project_id = ?`, + ).get(projectId)).toEqual({ n: 1 }); + } finally { + await Promise.resolve(started.shutdown?.()); + await new Promise<void>((resolve) => started.server.close(() => resolve())); + } + }); +}); + +describe('routine prepare failure cleanup', () => { + it('clears scheduled placeholder IDs when project creation fails before real IDs are assigned', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z')); + + const started = await startServer({ port: 0, returnServer: true }) as { + url: string; + server: http.Server; + shutdown?: () => Promise<void> | void; + }; + const dataDir = process.env.OD_DATA_DIR; + if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests'); + const db = openDatabase(tmp, { dataDir }); + + try { + const createRoutine = await fetch(`${started.url}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Scheduled project failure routine', + prompt: 'create a project', + schedule: { kind: 'hourly', minute: 1 }, + target: { mode: 'create_each_run' }, + agentId: 'codex', + enabled: true, + }), + }); + expect(createRoutine.status).toBe(201); + const created = await createRoutine.json() as { routine: { id: string } }; + + db.exec(` + DROP TRIGGER IF EXISTS fail_scheduled_routine_project_insert; + CREATE TRIGGER fail_scheduled_routine_project_insert + BEFORE INSERT ON projects + WHEN NEW.id LIKE 'routine-%' + AND json_extract(NEW.metadata_json, '$.routineId') = '${created.routine.id}' + BEGIN + SELECT RAISE(ABORT, 'routine project insert failed'); + END; + `); + + await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(0); + vi.useRealTimers(); + + let stored: + | { status: string; projectId: string; conversationId: string; agentRunId: string } + | undefined; + for (let attempt = 0; attempt < 200; attempt += 1) { + stored = db.prepare( + `SELECT status, + project_id AS projectId, + conversation_id AS conversationId, + agent_run_id AS agentRunId + FROM routine_runs + WHERE routine_id = ?`, + ).get(created.routine.id) as typeof stored; + if (stored?.status === 'failed') break; + await sleep(10); + } + + expect(stored).toBeDefined(); + if (!stored) return; + expect(stored.status).toBe('failed'); + expect(stored.projectId).toBe(''); + expect(stored.conversationId).toBe(''); + expect(stored.agentRunId).toMatch(/^[0-9a-f-]{36}$/); + expect(stored.projectId).not.toContain('routine-pending-project'); + expect(stored.conversationId).not.toContain('routine-pending-conv'); + + const runsRes = await fetch(`${started.url}/api/runs`); + expect(runsRes.status).toBe(200); + const runsJson = await runsRes.json() as { + runs: Array<{ status: string; projectId: string | null; conversationId: string | null; assistantMessageId: string | null }>; + }; + const chatRun = runsJson.runs.find((run) => + typeof run.assistantMessageId === 'string' + && run.assistantMessageId.startsWith('routine-assistant-')); + expect(chatRun).toBeDefined(); + expect(chatRun?.status).toBe('canceled'); + expect(String(chatRun?.projectId ?? '')).not.toContain('routine-pending-project'); + expect(String(chatRun?.conversationId ?? '')).not.toContain('routine-pending-conv'); + } finally { + vi.useRealTimers(); + try { + db.exec('DROP TRIGGER IF EXISTS fail_scheduled_routine_project_insert'); + } catch { + // The test may fail before the trigger exists. + } + await Promise.resolve(started.shutdown?.()); + await new Promise<void>((resolve) => started.server.close(() => resolve())); + } + }); + + it('returns prepared IDs for a successful manual create_each_run response', async () => { + const started = await startServer({ port: 0, returnServer: true }) as { + url: string; + server: http.Server; + shutdown?: () => Promise<void> | void; + }; + const dataDir = process.env.OD_DATA_DIR; + if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests'); + const db = openDatabase(tmp, { dataDir }); + + try { + const createRoutine = await fetch(`${started.url}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Manual response routine', + prompt: 'prepare and return ids', + schedule: { kind: 'hourly', minute: 1 }, + target: { mode: 'create_each_run' }, + agentId: 'missing-agent-for-route-test', + enabled: false, + }), + }); + expect(createRoutine.status).toBe(201); + const created = await createRoutine.json() as { routine: { id: string } }; + + const runRes = await fetch(`${started.url}/api/routines/${created.routine.id}/run`, { + method: 'POST', + }); + expect(runRes.status).toBe(202); + const runJson = await runRes.json() as { + projectId: string; + conversationId: string; + agentRunId: string; + run: { + projectId: string; + conversationId: string; + agentRunId: string; + }; + }; + + expect(runJson.projectId).toMatch(/^routine-/); + expect(runJson.conversationId).toMatch(/^routine-conv-/); + expect(runJson.agentRunId).toMatch(/^[0-9a-f-]{36}$/); + expect(runJson.projectId).not.toContain('routine-pending-project'); + expect(runJson.conversationId).not.toContain('routine-pending-conv'); + expect(runJson.run).toMatchObject({ + projectId: runJson.projectId, + conversationId: runJson.conversationId, + agentRunId: runJson.agentRunId, + }); + expect(db.prepare(`SELECT COUNT(*) AS n FROM projects WHERE id = ?`).get(runJson.projectId)) + .toEqual({ n: 1 }); + expect(db.prepare(`SELECT COUNT(*) AS n FROM conversations WHERE id = ?`).get(runJson.conversationId)) + .toEqual({ n: 1 }); + } finally { + await Promise.resolve(started.shutdown?.()); + await new Promise<void>((resolve) => started.server.close(() => resolve())); + } + }); + + it('finalizes and cleans up a manual run when prepare fails after creating the conversation', async () => { + const started = await startServer({ port: 0, returnServer: true }) as { + url: string; + server: http.Server; + shutdown?: () => Promise<void> | void; + }; + const dataDir = process.env.OD_DATA_DIR; + if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests'); + const db = openDatabase(tmp, { dataDir }); + + try { + const createRoutine = await fetch(`${started.url}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Manual prepare failure routine', + prompt: 'write messages', + schedule: { kind: 'hourly', minute: 1 }, + target: { mode: 'create_each_run' }, + agentId: 'codex', + enabled: false, + }), + }); + expect(createRoutine.status).toBe(201); + const created = await createRoutine.json() as { routine: { id: string } }; + + db.exec(` + DROP TRIGGER IF EXISTS fail_manual_routine_message_insert; + CREATE TRIGGER fail_manual_routine_message_insert + BEFORE INSERT ON messages + WHEN NEW.id LIKE 'routine-user-%' + BEGIN + SELECT RAISE(ABORT, 'routine message insert failed'); + END; + `); + + const runRes = await fetch(`${started.url}/api/routines/${created.routine.id}/run`, { + method: 'POST', + }); + expect(runRes.status).toBe(500); + expect(await runRes.text()).toContain('routine message insert failed'); + + const rows = db.prepare( + `SELECT status, + trigger, + project_id AS projectId, + conversation_id AS conversationId, + agent_run_id AS agentRunId, + error + FROM routine_runs + WHERE routine_id = ?`, + ).all(created.routine.id) as Array<{ + status: string; + trigger: string; + projectId: string; + conversationId: string; + agentRunId: string; + error: string | null; + }>; + expect(rows).toHaveLength(1); + const row = rows[0]!; + expect(row).toMatchObject({ + status: 'failed', + trigger: 'manual', + error: 'routine message insert failed', + }); + expect(row.projectId).toMatch(/^routine-/); + expect(row.conversationId).toMatch(/^routine-conv-/); + expect(row.agentRunId).toMatch(/^[0-9a-f-]{36}$/); + + expect(db.prepare(`SELECT COUNT(*) AS n FROM projects WHERE id = ?`).get(row.projectId)) + .toEqual({ n: 0 }); + expect(db.prepare(`SELECT COUNT(*) AS n FROM conversations WHERE id = ?`).get(row.conversationId)) + .toEqual({ n: 0 }); + + const runsRes = await fetch(`${started.url}/api/runs`); + expect(runsRes.status).toBe(200); + const runsJson = await runsRes.json() as { + runs: Array<{ status: string; assistantMessageId: string | null }>; + }; + const chatRun = runsJson.runs.find((run) => + typeof run.assistantMessageId === 'string' + && run.assistantMessageId.startsWith('routine-assistant-')); + expect(chatRun).toBeDefined(); + expect(chatRun?.status).toBe('canceled'); + } finally { + try { + db.exec('DROP TRIGGER IF EXISTS fail_manual_routine_message_insert'); + } catch { + // The test may fail before the trigger exists. + } + await Promise.resolve(started.shutdown?.()); + await new Promise<void>((resolve) => started.server.close(() => resolve())); + } + }); + + it('rolls back a manual run when conversation creation fails before the handler returns', async () => { + const started = await startServer({ port: 0, returnServer: true }) as { + url: string; + server: http.Server; + shutdown?: () => Promise<void> | void; + }; + const dataDir = process.env.OD_DATA_DIR; + if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests'); + const db = openDatabase(tmp, { dataDir }); + + try { + const createRoutine = await fetch(`${started.url}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Manual early conversation failure routine', + prompt: 'prepare resources', + schedule: { kind: 'hourly', minute: 1 }, + target: { mode: 'create_each_run' }, + agentId: 'codex', + enabled: false, + }), + }); + expect(createRoutine.status).toBe(201); + const created = await createRoutine.json() as { routine: { id: string } }; + + db.exec(` + DROP TRIGGER IF EXISTS fail_manual_routine_conversation_insert; + CREATE TRIGGER fail_manual_routine_conversation_insert + BEFORE INSERT ON conversations + WHEN NEW.id LIKE 'routine-conv-%' + AND NEW.project_id IN ( + SELECT id FROM projects + WHERE json_extract(metadata_json, '$.routineId') = '${created.routine.id}' + ) + BEGIN + SELECT RAISE(ABORT, 'routine conversation insert failed'); + END; + `); + + const runRes = await fetch(`${started.url}/api/routines/${created.routine.id}/run`, { + method: 'POST', + }); + expect(runRes.status).toBe(500); + expect(await runRes.text()).toContain('routine conversation insert failed'); + + const rows = db.prepare( + `SELECT status, + trigger, + project_id AS projectId, + conversation_id AS conversationId, + agent_run_id AS agentRunId, + error + FROM routine_runs + WHERE routine_id = ?`, + ).all(created.routine.id) as Array<{ + status: string; + trigger: string; + projectId: string; + conversationId: string; + agentRunId: string; + error: string | null; + }>; + expect(rows).toHaveLength(1); + const row = rows[0]!; + expect(row).toMatchObject({ + status: 'failed', + trigger: 'manual', + error: 'routine conversation insert failed', + }); + expect(row.projectId).toMatch(/^routine-/); + expect(row.conversationId).toBe(''); + expect(row.agentRunId).toMatch(/^[0-9a-f-]{36}$/); + + expect(db.prepare(`SELECT COUNT(*) AS n FROM projects WHERE id = ?`).get(row.projectId)) + .toEqual({ n: 0 }); + expect(db.prepare(`SELECT COUNT(*) AS n FROM conversations WHERE project_id = ?`).get(row.projectId)) + .toEqual({ n: 0 }); + } finally { + try { + db.exec('DROP TRIGGER IF EXISTS fail_manual_routine_conversation_insert'); + } catch { + // The test may fail before the trigger exists. + } + await Promise.resolve(started.shutdown?.()); + await new Promise<void>((resolve) => started.server.close(() => resolve())); + } + }); +}); + +function pluginRecord(id: string): InstalledPluginRecord { + const manifest: PluginManifest = { + name: id, + title: 'Routine Plugin', + version: '1.0.0', + description: 'Routine snapshot fixture.', + od: { + kind: 'skill', + taskKind: 'new-generation', + useCase: { query: 'Handle {{prompt}}' }, + inputs: [{ name: 'prompt', type: 'string', required: true }], + capabilities: ['prompt:inject'], + }, + } as PluginManifest; + return { + id, + title: 'Routine Plugin', + version: '1.0.0', + sourceKind: 'local', + source: `/tmp/${id}`, + fsPath: `/tmp/${id}`, + trust: 'trusted', + capabilitiesGranted: ['prompt:inject'], + installedAt: Date.now(), + updatedAt: Date.now(), + manifest, + }; +} diff --git a/apps/daemon/tests/routines.test.ts b/apps/daemon/tests/routines.test.ts index cbe50f768..9bad64f71 100644 --- a/apps/daemon/tests/routines.test.ts +++ b/apps/daemon/tests/routines.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { nextRunAtForSchedule, + RoutineService, + type Routine, + type RoutineRun, + type RoutineRunHandlerStart, validateSchedule, validateTarget, } from '../src/routines.js'; @@ -24,6 +28,75 @@ function partsIn(timezone: string, at: Date): Record<string, string> { return out; } +class SharedRoutinePersistence { + readonly runs: RoutineRun[] = []; + readonly claimedSlots = new Set<string>(); + failScheduledInsertAttempts = 0; + + constructor(private readonly routines: Routine[]) {} + + list(): Routine[] { + return this.routines; + } + + insertRun(run: RoutineRun, options: { scheduledSlotAt?: number } = {}): boolean { + if (options.scheduledSlotAt != null) { + if (this.failScheduledInsertAttempts > 0) { + this.failScheduledInsertAttempts -= 1; + throw new Error('scheduled slot claim unavailable'); + } + const key = `${run.routineId}:${options.scheduledSlotAt}`; + if (this.claimedSlots.has(key)) return false; + this.claimedSlots.add(key); + } + this.runs.push(run); + return true; + } + + updateRun(id: string, patch: Partial<RoutineRun>): void { + const run = this.runs.find((candidate) => candidate.id === id); + if (run) Object.assign(run, patch); + } + + getLatestRun(routineId: string): RoutineRun | null { + return this.runs.find((run) => run.routineId === routineId) ?? null; + } +} + +function fixtureRoutine(overrides: Partial<Routine> = {}): Routine { + return { + id: 'routine-1', + name: 'Daily brief', + prompt: 'Summarize the day', + schedule: { kind: 'hourly', minute: 1 }, + target: { mode: 'create_each_run' }, + skillId: null, + agentId: null, + context: {}, + enabled: true, + nextRunAt: null, + lastRun: null, + createdAt: Date.UTC(2026, 4, 17, 0, 0), + updatedAt: Date.UTC(2026, 4, 17, 0, 0), + ...overrides, + }; +} + +function handlerStart(agentRunId: string, onStart?: () => void): RoutineRunHandlerStart { + const start = onStart ? { start: onStart } : {}; + return { + projectId: 'project-1', + conversationId: 'conversation-1', + agentRunId, + completion: Promise.resolve({ status: 'succeeded' }), + ...start, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + describe('nextRunAtForSchedule DST handling', () => { it('does not fire before the requested wall time on a spring-forward gap day', () => { // 2026-03-08 in America/New_York: clocks jump 02:00 EST → 03:00 EDT, so @@ -162,6 +235,397 @@ describe('nextRunAtForSchedule DST handling', () => { }); }); +describe('RoutineService scheduled run idempotency', () => { + it('starts only one scheduled run when two scheduler instances fire the same slot', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z')); + + const persistence = new SharedRoutinePersistence([fixtureRoutine()]); + const first = new RoutineService(persistence); + const second = new RoutineService(persistence); + const starts: string[] = []; + + first.setRunHandler(async ({ runId }) => { + return handlerStart('agent-run-1', () => starts.push(runId)); + }); + second.setRunHandler(async ({ runId }) => { + return handlerStart('agent-run-2', () => starts.push(runId)); + }); + + try { + first.start(); + second.start(); + + await vi.advanceTimersByTimeAsync(61_000); + + expect(starts).toHaveLength(1); + expect(persistence.runs).toHaveLength(1); + expect(persistence.claimedSlots).toEqual(new Set(['routine-1:1779012060000'])); + } finally { + first.stop(); + second.stop(); + } + }); + + it('retries the same scheduled slot when durable run insertion fails', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z')); + + const persistence = new SharedRoutinePersistence([fixtureRoutine()]); + persistence.failScheduledInsertAttempts = 1; + const service = new RoutineService(persistence); + const starts: string[] = []; + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + + service.setRunHandler(async ({ runId }) => { + return handlerStart('agent-run-1', () => starts.push(runId)); + }); + + try { + service.start(); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(starts).toHaveLength(0); + expect(persistence.runs).toHaveLength(0); + expect(persistence.claimedSlots.size).toBe(0); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(starts).toHaveLength(1); + expect(persistence.runs).toHaveLength(1); + expect(persistence.claimedSlots).toEqual(new Set(['routine-1:1779012060000'])); + } finally { + service.stop(); + errors.mockRestore(); + } + }); + + it('terminates the in-memory run and persists real IDs when prepare fails after assigning them', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z')); + + const persistence = new SharedRoutinePersistence([fixtureRoutine()]); + const updatePatches: Array<Partial<RoutineRun>> = []; + const originalUpdate = persistence.updateRun.bind(persistence); + persistence.updateRun = (id: string, patch: Partial<RoutineRun>) => { + updatePatches.push({ ...patch }); + originalUpdate(id, patch); + }; + + const service = new RoutineService(persistence); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + + let discardCalls = 0; + let completionResolved = false; + let resolveCompletion!: () => void; + const completion = new Promise<{ status: 'canceled' }>((resolve) => { + resolveCompletion = () => { + completionResolved = true; + resolve({ status: 'canceled' }); + }; + }); + + service.setRunHandler(async () => { + return { + // Placeholder IDs mirror server.ts's `scheduledPlaceholder*` + // values — these are what the row gets inserted with before + // `prepare()` patches them with real IDs. + projectId: 'routine-pending-project', + conversationId: 'routine-pending-conversation', + agentRunId: 'routine-pending-run', + completion, + prepare: (run: RoutineRun) => { + // Match persistPreparedRun(): mutate the routine run with real + // IDs before any later fallible work could throw. + run.projectId = 'real-project'; + run.conversationId = 'real-conversation'; + run.agentRunId = 'real-agent-run'; + throw new Error('prepare exploded'); + }, + discard: () => { + discardCalls += 1; + resolveCompletion(); + }, + start: () => { + throw new Error('start should not run after a failed prepare'); + }, + }; + }); + + try { + service.start(); + + await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(0); + + // The in-memory chat run was terminated, releasing the completion + // promise so it does not leak. + expect(discardCalls).toBe(1); + expect(completionResolved).toBe(true); + + // The persisted row ends in the terminal failed state and carries + // the real IDs that prepare() assigned — no `routine-pending-*` + // placeholders left behind. + expect(persistence.runs).toHaveLength(1); + const stored = persistence.runs[0]!; + expect(stored.status).toBe('failed'); + expect(stored.projectId).toBe('real-project'); + expect(stored.conversationId).toBe('real-conversation'); + expect(stored.agentRunId).toBe('real-agent-run'); + expect(stored.completedAt).toBeTypeOf('number'); + expect(stored.error).toContain('prepare exploded'); + + // The failure-path updateRun explicitly carried the real IDs so the + // real persistence layer (column-level UPDATE) replaces the + // placeholders, not just the in-memory shared reference. + const failurePatch = updatePatches.find((patch) => patch.status === 'failed'); + expect(failurePatch).toBeDefined(); + expect(failurePatch?.projectId).toBe('real-project'); + expect(failurePatch?.conversationId).toBe('real-conversation'); + expect(failurePatch?.agentRunId).toBe('real-agent-run'); + } finally { + service.stop(); + errors.mockRestore(); + } + }); + + it('does not persist scheduled placeholder IDs when prepare fails before assigning real IDs', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z')); + + const persistence = new SharedRoutinePersistence([fixtureRoutine()]); + const updatePatches: Array<Partial<RoutineRun>> = []; + const originalUpdate = persistence.updateRun.bind(persistence); + persistence.updateRun = (id: string, patch: Partial<RoutineRun>) => { + updatePatches.push({ ...patch }); + originalUpdate(id, patch); + }; + + const service = new RoutineService(persistence); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + + let discardCalls = 0; + + service.setRunHandler(async ({ runId }) => { + return { + projectId: `routine-pending-project-${runId}`, + conversationId: `routine-pending-conv-${runId}`, + agentRunId: 'agent-run-1', + completion: Promise.resolve({ status: 'canceled' as const }), + prepare: () => { + // Mirrors createRoutineConversation() failing before + // persistPreparedRun() can copy real IDs onto the chat run or + // routine run. + throw new Error('project create failed'); + }, + discard: () => { + discardCalls += 1; + }, + start: () => { + throw new Error('start should not run after a failed prepare'); + }, + }; + }); + + try { + service.start(); + + await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(0); + + expect(discardCalls).toBe(1); + expect(persistence.runs).toHaveLength(1); + const stored = persistence.runs[0]!; + expect(stored.status).toBe('failed'); + expect(stored.completedAt).toBeTypeOf('number'); + expect(stored.error).toContain('project create failed'); + expect(stored.projectId).toBe(''); + expect(stored.conversationId).toBe(''); + expect(stored.agentRunId).toBe('agent-run-1'); + expect(stored.projectId).not.toContain('routine-pending-project'); + expect(stored.conversationId).not.toContain('routine-pending-conv'); + + const failurePatch = updatePatches.find((patch) => patch.status === 'failed'); + expect(failurePatch).toBeDefined(); + expect(failurePatch?.projectId).toBe(''); + expect(failurePatch?.conversationId).toBe(''); + expect(failurePatch?.agentRunId).toBe('agent-run-1'); + } finally { + service.stop(); + errors.mockRestore(); + } + }); + + it('prepares manual runs exactly once through the service path', async () => { + const persistence = new SharedRoutinePersistence([fixtureRoutine()]); + const service = new RoutineService(persistence); + let prepareCalls = 0; + + service.setRunHandler(async () => ({ + projectId: 'project-1', + conversationId: 'conversation-1', + agentRunId: 'agent-run-1', + completion: Promise.resolve({ status: 'succeeded' as const }), + prepare: () => { + prepareCalls += 1; + }, + })); + + await service.runNow('routine-1'); + await Promise.resolve(); + + expect(prepareCalls).toBe(1); + expect(persistence.runs).toHaveLength(1); + expect(persistence.runs[0]).toMatchObject({ + trigger: 'manual', + projectId: 'project-1', + conversationId: 'conversation-1', + agentRunId: 'agent-run-1', + }); + }); + + it('returns prepared IDs from successful manual runs', async () => { + const persistence = new SharedRoutinePersistence([fixtureRoutine()]); + const service = new RoutineService(persistence); + + service.setRunHandler(async () => ({ + projectId: 'routine-pending-project', + conversationId: 'routine-pending-conversation', + agentRunId: 'routine-pending-run', + completion: Promise.resolve({ status: 'succeeded' as const }), + prepare: (run: RoutineRun) => { + run.projectId = 'real-project'; + run.conversationId = 'real-conversation'; + run.agentRunId = 'real-agent-run'; + }, + })); + + const started = await service.runNow('routine-1'); + await Promise.resolve(); + + expect(started).toMatchObject({ + projectId: 'real-project', + conversationId: 'real-conversation', + agentRunId: 'real-agent-run', + }); + expect(persistence.runs).toHaveLength(1); + expect(persistence.runs[0]).toMatchObject({ + trigger: 'manual', + projectId: 'real-project', + conversationId: 'real-conversation', + agentRunId: 'real-agent-run', + }); + }); + + it('still finalizes the failed row when prepare cleanup itself throws', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z')); + + const persistence = new SharedRoutinePersistence([fixtureRoutine()]); + const service = new RoutineService(persistence); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + + let discardCalls = 0; + + service.setRunHandler(async () => { + return { + projectId: 'routine-pending-project', + conversationId: 'routine-pending-conversation', + agentRunId: 'routine-pending-run', + completion: Promise.resolve({ status: 'canceled' as const }), + prepare: (run: RoutineRun) => { + run.projectId = 'real-project'; + run.conversationId = 'real-conversation'; + run.agentRunId = 'real-agent-run'; + throw new Error('prepare exploded'); + }, + discard: () => { + discardCalls += 1; + throw new Error('cleanup blew up'); + }, + start: () => {}, + }; + }); + + try { + service.start(); + + await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(0); + + expect(discardCalls).toBe(1); + + // The cleanup failure is surfaced via console.error and does not + // swallow the prepare failure — the routine row is still finalized + // and the original prepare error reaches the scheduler. + expect(errors.mock.calls.some((call) => + call.some((value) => String(value).includes('cleanup blew up')), + )).toBe(true); + expect(errors.mock.calls.some((call) => + call.some((value) => String(value).includes('prepare exploded')), + )).toBe(true); + + expect(persistence.runs).toHaveLength(1); + const stored = persistence.runs[0]!; + expect(stored.status).toBe('failed'); + expect(stored.projectId).toBe('real-project'); + expect(stored.conversationId).toBe('real-conversation'); + expect(stored.agentRunId).toBe('real-agent-run'); + expect(stored.error).toContain('prepare exploded'); + } finally { + service.stop(); + errors.mockRestore(); + } + }); + + it('retries the same scheduled slot when duplicate loser cleanup fails', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z')); + + const persistence = new SharedRoutinePersistence([fixtureRoutine()]); + persistence.claimedSlots.add('routine-1:1779012060000'); + const service = new RoutineService(persistence); + let discardAttempts = 0; + let discardFailures = 1; + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + + service.setRunHandler(async ({ runId }) => { + return { + ...handlerStart(runId), + discard: () => { + discardAttempts += 1; + if (discardFailures > 0) { + discardFailures -= 1; + throw new Error('duplicate loser cleanup failed'); + } + }, + }; + }); + + try { + service.start(); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(discardAttempts).toBe(1); + expect(persistence.runs).toHaveLength(0); + expect(persistence.claimedSlots).toEqual(new Set(['routine-1:1779012060000'])); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(discardAttempts).toBe(2); + expect(persistence.runs).toHaveLength(0); + expect(errors.mock.calls.some((call) => + call.some((value) => String(value).includes('duplicate loser cleanup failed')), + )).toBe(true); + } finally { + service.stop(); + errors.mockRestore(); + } + }); +}); + describe('routine validation', () => { it('accepts valid schedule and target shapes', () => { expect(() => From bbf4809a7ed302d5edc00be1233b4f2966a77903 Mon Sep 17 00:00:00 2001 From: Hashem Aldhaheri <158606+aenawi@users.noreply.github.com> Date: Fri, 29 May 2026 07:23:18 +0400 Subject: [PATCH 10/56] fix(web): use surface-appropriate noun in plugin/template preview unavailable copy (#3229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After #2840 wired plugin and design-template 404s into the same "no shipped preview" placeholder the skills tab uses, the placeholder copy still hard-coded "skill" — so users opening a Community/Plugins card whose manifest declares a preview entry that doesn't ship saw "No shipped preview for this skill." on a card that is clearly not a skill. Adds a noun discriminator to PreviewView.unavailable so the placeholder reads with the right word per surface — "this skill" on the Skills tab, "this plugin" on Community/Plugins, "this template" on deck-mode design-templates. Locales gain three new preview.noun* strings (with appropriate per-language demonstrative+article) and the existing unavailable title/body interpolate a {noun} placeholder. Also fixes a CSS gap in .ds-modal-unavailable surfaced by the same path: the title and body divs were collapsing onto a single line under .ds-modal-empty's default flex-row. Mirrors the existing .ds-modal-error column+gap layout. Refs #897, #2840. --- apps/web/src/components/ExamplesTab.tsx | 2 +- apps/web/src/components/PreviewModal.tsx | 68 +++++++++---- .../plugin-details/PluginExampleDetail.tsx | 7 +- apps/web/src/i18n/locales/ar.ts | 7 +- apps/web/src/i18n/locales/de.ts | 7 +- apps/web/src/i18n/locales/en.ts | 7 +- apps/web/src/i18n/locales/es-ES.ts | 7 +- apps/web/src/i18n/locales/fa.ts | 7 +- apps/web/src/i18n/locales/fr.ts | 7 +- apps/web/src/i18n/locales/hu.ts | 7 +- apps/web/src/i18n/locales/id.ts | 7 +- apps/web/src/i18n/locales/it.ts | 7 +- apps/web/src/i18n/locales/ja.ts | 7 +- apps/web/src/i18n/locales/ko.ts | 7 +- apps/web/src/i18n/locales/pl.ts | 7 +- apps/web/src/i18n/locales/pt-BR.ts | 7 +- apps/web/src/i18n/locales/ru.ts | 7 +- apps/web/src/i18n/locales/tr.ts | 7 +- apps/web/src/i18n/locales/uk.ts | 7 +- apps/web/src/i18n/locales/zh-CN.ts | 7 +- apps/web/src/i18n/locales/zh-TW.ts | 7 +- apps/web/src/i18n/types.ts | 20 +++- apps/web/src/styles/viewer/composio.css | 21 ++++ ...ginExampleDetail.unavailable-noun.test.tsx | 97 +++++++++++++++++++ 24 files changed, 277 insertions(+), 64 deletions(-) create mode 100644 apps/web/tests/components/PluginExampleDetail.unavailable-noun.test.tsx diff --git a/apps/web/src/components/ExamplesTab.tsx b/apps/web/src/components/ExamplesTab.tsx index 78f837fb2..35160b8c4 100644 --- a/apps/web/src/components/ExamplesTab.tsx +++ b/apps/web/src/components/ExamplesTab.tsx @@ -482,7 +482,7 @@ export function ExamplesTab({ skills: rawSkills, onUsePrompt }: Props) { // it can render a calm "no shipped preview" placeholder // instead of bouncing through the error state. Issue #897. unavailable: unavailableKind - ? { kind: unavailableKind } + ? { kind: unavailableKind, noun: 'skill' } : null, deck: previewSkill.mode === 'deck', }, diff --git a/apps/web/src/components/PreviewModal.tsx b/apps/web/src/components/PreviewModal.tsx index 08ececc0d..dddd0fac7 100644 --- a/apps/web/src/components/PreviewModal.tsx +++ b/apps/web/src/components/PreviewModal.tsx @@ -18,15 +18,19 @@ export interface PreviewView { // button that re-fires onView for this view id, instead of sitting // at the loading state forever. Issue #860. error?: string | null; - // Set when the underlying skill ships no HTML preview at all (its + // Set when the underlying surface ships no HTML preview at all (its // `od.preview.type` is `image`, `markdown`, etc.). The modal renders // a calm "no shipped preview" placeholder instead of the loading or - // error states — fetching `/api/skills/:id/example` for those skills - // returns 404 today and the resulting "Couldn't load this example." - // copy is misleading. `kind` carries the raw preview-type token so - // copy can be shaped per kind ("markdown document", "image asset", - // …). Mutually exclusive with `html` and `error`. Issue #897. - unavailable?: { kind: string } | null; + // error states — fetching `/api/skills/:id/example` (or the symmetric + // plugin route) returns 404 today and the resulting "Couldn't load + // this example." copy is misleading. `kind` carries the raw + // preview-type token so copy can be shaped per kind ("markdown + // document", "image asset", …). `noun` carries the surface kind so + // the placeholder reads with the right word — "skill" on the Skills + // tab, "plugin" on Community/Plugins cards, "template" on + // design-template (deck) cards. Mutually exclusive with `html` and + // `error`. Issues #897, #2840, #3216. + unavailable?: { kind: string; noun?: 'skill' | 'plugin' | 'template' } | null; // Deck previews need deck-aware srcdoc/PDF handling so slide navigation and // print-all-slides behavior survive the sandboxed export path. deck?: boolean; @@ -774,20 +778,42 @@ export function PreviewModal({ // 404 into the generic "Couldn't load this example." copy // — misleading, since nothing failed: there's just no // preview to render. Show a calm placeholder pointing the - // user at "Use this prompt" instead. Issue #897. - <div - className="ds-modal-empty ds-modal-unavailable" - data-testid="preview-unavailable" - > - <div className="ds-modal-unavailable-title"> - {t('preview.unavailableTitle')} - </div> - <div className="ds-modal-unavailable-body"> - {t('preview.unavailableBody', { - kind: activeUnavailable.kind || 'preview', - })} - </div> - </div> + // user at "Use this prompt" instead. Issues #897, #2840. + // + // `noun` lets the same placeholder read with the right + // word per surface — Skills tab, Community/Plugins, + // design-template (deck) cards. Defaults to 'skill' so + // pre-noun callers keep their existing copy. Issue #3216. + (() => { + const nounKey = ((): 'preview.nounSkill' | 'preview.nounPlugin' | 'preview.nounTemplate' => { + switch (activeUnavailable.noun) { + case 'plugin': + return 'preview.nounPlugin'; + case 'template': + return 'preview.nounTemplate'; + case 'skill': + default: + return 'preview.nounSkill'; + } + })(); + const noun = t(nounKey); + return ( + <div + className="ds-modal-empty ds-modal-unavailable" + data-testid="preview-unavailable" + > + <div className="ds-modal-unavailable-title"> + {t('preview.unavailableTitle', { noun })} + </div> + <div className="ds-modal-unavailable-body"> + {t('preview.unavailableBody', { + kind: activeUnavailable.kind || 'preview', + noun, + })} + </div> + </div> + ); + })() ) : activeError ? ( // Distinct error state so a fetch failure stops looking // like an indefinite "Loading…". The Retry button re-fires diff --git a/apps/web/src/components/plugin-details/PluginExampleDetail.tsx b/apps/web/src/components/plugin-details/PluginExampleDetail.tsx index 179f4057f..e948d82db 100644 --- a/apps/web/src/components/plugin-details/PluginExampleDetail.tsx +++ b/apps/web/src/components/plugin-details/PluginExampleDetail.tsx @@ -95,7 +95,12 @@ export function PluginExampleDetail({ label: t('examples.previewLabel'), html, error, - unavailable: unavailableKind ? { kind: unavailableKind } : null, + // Pass the surface-appropriate noun so the unavailable placeholder + // reads "this plugin" / "this template" instead of falling back to + // the legacy skills-only "this skill" copy. Issue #3216. + unavailable: unavailableKind + ? { kind: unavailableKind, noun: isDeck ? 'template' : 'plugin' } + : null, deck: isDeck, }, ]} diff --git a/apps/web/src/i18n/locales/ar.ts b/apps/web/src/i18n/locales/ar.ts index 4bc33a018..c6581e4a0 100644 --- a/apps/web/src/i18n/locales/ar.ts +++ b/apps/web/src/i18n/locales/ar.ts @@ -919,8 +919,11 @@ export const ar: Dict = { 'preview.errorTitle': 'تعذّر تحميل هذا المثال.', 'preview.errorBody': 'فشل جلب HTML الخاص بالمثال. تأكد من تشغيل Open Design ثم أعد المحاولة.', 'preview.retry': 'إعادة المحاولة', - 'preview.unavailableTitle': 'لا توجد معاينة مرفقة لهذه المهارة.', - 'preview.unavailableBody': 'هذه المهارة تنتج مخرجات {kind} — شغّل الأمر في المحادثة لإنشاء واحدة.', + 'preview.unavailableTitle': 'لا توجد معاينة مرفقة لـ{noun}.', + 'preview.unavailableBody': 'شغّل الأمر في المحادثة لإنشاء مخرجات {kind}.', + 'preview.nounSkill': 'هذه المهارة', + 'preview.nounPlugin': 'هذه الإضافة', + 'preview.nounTemplate': 'هذا القالب', 'preview.showSidebar': 'إظهار {label}', 'preview.hideSidebar': 'إخفاء {label}', diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index 3a401f38d..1c2ca5c30 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -807,8 +807,11 @@ export const de: Dict = { 'preview.errorTitle': 'Beispiel konnte nicht geladen werden.', 'preview.errorBody': 'Das Beispiel-HTML konnte nicht abgerufen werden. Stelle sicher, dass Open Design läuft, und versuche es erneut.', 'preview.retry': 'Erneut versuchen', - 'preview.unavailableTitle': 'Für diesen Skill ist keine Vorschau verfügbar.', - 'preview.unavailableBody': 'Dieser Skill erzeugt {kind}-Output — führe den Prompt im Chat aus, um etwas zu erzeugen.', + 'preview.unavailableTitle': 'Für {noun} ist keine Vorschau verfügbar.', + 'preview.unavailableBody': 'Führe den Prompt im Chat aus, um {kind}-Output zu erzeugen.', + 'preview.nounSkill': 'diesen Skill', + 'preview.nounPlugin': 'dieses Plugin', + 'preview.nounTemplate': 'diese Vorlage', 'preview.showSidebar': '{label} einblenden', 'preview.hideSidebar': '{label} ausblenden', diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 20feef751..0be98356d 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -1542,8 +1542,11 @@ export const en: Dict = { 'preview.errorTitle': 'Couldn\'t load this example.', 'preview.errorBody': 'The example HTML failed to fetch. Make sure Open Design is running and try again.', 'preview.retry': 'Retry', - 'preview.unavailableTitle': 'No shipped preview for this skill.', - 'preview.unavailableBody': 'This skill produces {kind} output — run the prompt in chat to generate one.', + 'preview.unavailableTitle': 'No shipped preview for {noun}.', + 'preview.unavailableBody': 'Run the prompt in chat to generate {kind} output.', + 'preview.nounSkill': 'this skill', + 'preview.nounPlugin': 'this plugin', + 'preview.nounTemplate': 'this template', 'preview.showSidebar': 'Show {label}', 'preview.hideSidebar': 'Hide {label}', diff --git a/apps/web/src/i18n/locales/es-ES.ts b/apps/web/src/i18n/locales/es-ES.ts index 23e51f1c5..3c8f736d4 100644 --- a/apps/web/src/i18n/locales/es-ES.ts +++ b/apps/web/src/i18n/locales/es-ES.ts @@ -808,8 +808,11 @@ export const esES: Dict = { 'preview.errorTitle': 'No se pudo cargar este ejemplo.', 'preview.errorBody': 'No se pudo obtener el HTML del ejemplo. Asegúrate de que Open Design esté en ejecución e inténtalo de nuevo.', 'preview.retry': 'Reintentar', - 'preview.unavailableTitle': 'No hay vista previa incluida para esta skill.', - 'preview.unavailableBody': 'Esta skill genera un resultado {kind} — ejecuta el prompt en el chat para crear uno.', + 'preview.unavailableTitle': 'No hay vista previa incluida para {noun}.', + 'preview.unavailableBody': 'Ejecuta el prompt en el chat para generar un resultado {kind}.', + 'preview.nounSkill': 'esta skill', + 'preview.nounPlugin': 'este plugin', + 'preview.nounTemplate': 'esta plantilla', 'preview.showSidebar': 'Mostrar {label}', 'preview.hideSidebar': 'Ocultar {label}', diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index 434670807..66c4e10a3 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -941,8 +941,11 @@ export const fa: Dict = { 'preview.errorTitle': 'بارگیری این نمونه ممکن نشد.', 'preview.errorBody': 'دریافت HTML نمونه با خطا مواجه شد. مطمئن شوید Open Design در حال اجراست و دوباره تلاش کنید.', 'preview.retry': 'تلاش دوباره', - 'preview.unavailableTitle': 'برای این مهارت پیش‌نمایش همراهی وجود ندارد.', - 'preview.unavailableBody': 'این مهارت خروجی {kind} تولید می‌کند — برای ساخت یکی، پرامپت را در گفتگو اجرا کنید.', + 'preview.unavailableTitle': 'برای {noun} پیش‌نمایش همراهی وجود ندارد.', + 'preview.unavailableBody': 'برای ساخت خروجی {kind}، پرامپت را در گفتگو اجرا کنید.', + 'preview.nounSkill': 'این مهارت', + 'preview.nounPlugin': 'این افزونه', + 'preview.nounTemplate': 'این الگو', 'preview.showSidebar': 'نمایش {label}', 'preview.hideSidebar': 'پنهان کردن {label}', diff --git a/apps/web/src/i18n/locales/fr.ts b/apps/web/src/i18n/locales/fr.ts index 895c2521e..2e9ac5fb0 100644 --- a/apps/web/src/i18n/locales/fr.ts +++ b/apps/web/src/i18n/locales/fr.ts @@ -1451,8 +1451,11 @@ export const fr: Dict = { 'preview.errorTitle': 'Impossible de charger cet exemple.', 'preview.errorBody': 'Le chargement du HTML de l\'exemple a échoué. Vérifiez qu\'Open Design est en cours d\'exécution et réessayez.', 'preview.retry': 'Réessayer', - 'preview.unavailableTitle': 'Aucun aperçu fourni pour cette compétence.', - 'preview.unavailableBody': 'Cette compétence produit un résultat {kind} — exécutez le prompt dans le chat pour en générer un.', + 'preview.unavailableTitle': 'Aucun aperçu fourni pour {noun}.', + 'preview.unavailableBody': 'Exécutez le prompt dans le chat pour générer un résultat {kind}.', + 'preview.nounSkill': 'cette compétence', + 'preview.nounPlugin': 'ce plugin', + 'preview.nounTemplate': 'ce modèle', 'preview.showSidebar': 'Afficher {label}', 'preview.hideSidebar': 'Masquer {label}', 'misc.savedTemplate': 'Modèle enregistré', diff --git a/apps/web/src/i18n/locales/hu.ts b/apps/web/src/i18n/locales/hu.ts index f1f737157..b27e17662 100644 --- a/apps/web/src/i18n/locales/hu.ts +++ b/apps/web/src/i18n/locales/hu.ts @@ -919,8 +919,11 @@ export const hu: Dict = { 'preview.errorTitle': 'A példa betöltése nem sikerült.', 'preview.errorBody': 'A példa HTML-jének letöltése meghiúsult. Győződj meg róla, hogy az Open Design fut, majd próbáld újra.', 'preview.retry': 'Újra', - 'preview.unavailableTitle': 'Ehhez a skillhez nincs mellékelt előnézet.', - 'preview.unavailableBody': 'Ez a skill {kind} kimenetet készít — futtasd a promptot a csevegésben egy létrehozásához.', + 'preview.unavailableTitle': 'Nincs mellékelt előnézet: {noun}.', + 'preview.unavailableBody': 'Futtasd a promptot a csevegésben, hogy {kind} kimenetet készíts.', + 'preview.nounSkill': 'ez a skill', + 'preview.nounPlugin': 'ez a plugin', + 'preview.nounTemplate': 'ez a sablon', 'preview.showSidebar': '{label} megjelenítése', 'preview.hideSidebar': '{label} elrejtése', diff --git a/apps/web/src/i18n/locales/id.ts b/apps/web/src/i18n/locales/id.ts index 5dff20046..0039d2cfc 100644 --- a/apps/web/src/i18n/locales/id.ts +++ b/apps/web/src/i18n/locales/id.ts @@ -1030,8 +1030,11 @@ export const id: Dict = { 'preview.errorTitle': 'Tidak dapat memuat contoh ini.', 'preview.errorBody': 'Pengambilan HTML contoh gagal. Pastikan Open Design berjalan, lalu coba lagi.', 'preview.retry': 'Coba lagi', - 'preview.unavailableTitle': 'Tidak ada pratinjau bawaan untuk skill ini.', - 'preview.unavailableBody': 'Skill ini menghasilkan keluaran {kind} — jalankan prompt di chat untuk membuatnya.', + 'preview.unavailableTitle': 'Tidak ada pratinjau bawaan untuk {noun}.', + 'preview.unavailableBody': 'Jalankan prompt di chat untuk membuat keluaran {kind}.', + 'preview.nounSkill': 'skill ini', + 'preview.nounPlugin': 'plugin ini', + 'preview.nounTemplate': 'templat ini', 'preview.showSidebar': 'Tampilkan {label}', 'preview.hideSidebar': 'Sembunyikan {label}', diff --git a/apps/web/src/i18n/locales/it.ts b/apps/web/src/i18n/locales/it.ts index 0a9108a96..746dda771 100644 --- a/apps/web/src/i18n/locales/it.ts +++ b/apps/web/src/i18n/locales/it.ts @@ -834,8 +834,11 @@ export const it: Dict = { 'preview.errorTitle': 'Impossibile caricare questo esempio.', 'preview.errorBody': 'Il caricamento dell\'HTML dell\'esempio è fallito. Verifica che Open Design sia in esecuzione e riprova.', 'preview.retry': 'Riprova', - 'preview.unavailableTitle': 'Nessuna anteprima fornita per questa competenza.', - 'preview.unavailableBody': 'Questa competenza produce un risultato {kind} — esegui il prompt nella chat per generarne uno.', + 'preview.unavailableTitle': 'Nessuna anteprima fornita per {noun}.', + 'preview.unavailableBody': 'Esegui il prompt nella chat per generare un risultato {kind}.', + 'preview.nounSkill': 'questa competenza', + 'preview.nounPlugin': 'questo plugin', + 'preview.nounTemplate': 'questo modello', 'preview.showSidebar': 'Mostra {label}', 'preview.hideSidebar': 'Nascondi {label}', diff --git a/apps/web/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts index 2489b834d..7255f82d3 100644 --- a/apps/web/src/i18n/locales/ja.ts +++ b/apps/web/src/i18n/locales/ja.ts @@ -806,8 +806,11 @@ export const ja: Dict = { 'preview.errorTitle': 'この例を読み込めませんでした。', 'preview.errorBody': '例の HTML を取得できませんでした。Open Design が起動していることを確認して再試行してください。', 'preview.retry': '再試行', - 'preview.unavailableTitle': 'このスキルにはプレビューが同梱されていません。', - 'preview.unavailableBody': 'このスキルは {kind} 出力を生成します — チャットでプロンプトを実行して生成してください。', + 'preview.unavailableTitle': '{noun}にはプレビューが同梱されていません。', + 'preview.unavailableBody': 'チャットでプロンプトを実行して {kind} 出力を生成してください。', + 'preview.nounSkill': 'このスキル', + 'preview.nounPlugin': 'このプラグイン', + 'preview.nounTemplate': 'このテンプレート', 'preview.showSidebar': '{label} を表示', 'preview.hideSidebar': '{label} を非表示', diff --git a/apps/web/src/i18n/locales/ko.ts b/apps/web/src/i18n/locales/ko.ts index 0cc35dd82..2b36ddb8a 100644 --- a/apps/web/src/i18n/locales/ko.ts +++ b/apps/web/src/i18n/locales/ko.ts @@ -919,8 +919,11 @@ export const ko: Dict = { 'preview.errorTitle': '이 예제를 불러오지 못했습니다.', 'preview.errorBody': '예제 HTML을 가져오지 못했습니다. Open Design이 실행 중인지 확인하고 다시 시도하세요.', 'preview.retry': '다시 시도', - 'preview.unavailableTitle': '이 스킬에는 함께 제공되는 미리보기가 없습니다.', - 'preview.unavailableBody': '이 스킬은 {kind} 출력을 생성합니다 — 채팅에서 프롬프트를 실행해 생성하세요.', + 'preview.unavailableTitle': '{noun}에는 함께 제공되는 미리보기가 없습니다.', + 'preview.unavailableBody': '채팅에서 프롬프트를 실행해 {kind} 출력을 생성하세요.', + 'preview.nounSkill': '이 스킬', + 'preview.nounPlugin': '이 플러그인', + 'preview.nounTemplate': '이 템플릿', 'preview.showSidebar': '{label} 표시', 'preview.hideSidebar': '{label} 숨기기', diff --git a/apps/web/src/i18n/locales/pl.ts b/apps/web/src/i18n/locales/pl.ts index d41669e3e..c721a4764 100644 --- a/apps/web/src/i18n/locales/pl.ts +++ b/apps/web/src/i18n/locales/pl.ts @@ -919,8 +919,11 @@ export const pl: Dict = { 'preview.errorTitle': 'Nie udało się załadować tego przykładu.', 'preview.errorBody': 'Nie udało się pobrać kodu HTML przykładu. Upewnij się, że Open Design jest uruchomiony, i spróbuj ponownie.', 'preview.retry': 'Spróbuj ponownie', - 'preview.unavailableTitle': 'Brak dołączonego podglądu dla tej umiejętności.', - 'preview.unavailableBody': 'Ta umiejętność tworzy {kind} wynik — uruchom prompt w czacie, aby go wygenerować.', + 'preview.unavailableTitle': 'Brak dołączonego podglądu dla {noun}.', + 'preview.unavailableBody': 'Uruchom prompt w czacie, aby wygenerować {kind} wynik.', + 'preview.nounSkill': 'tej umiejętności', + 'preview.nounPlugin': 'tego pluginu', + 'preview.nounTemplate': 'tego szablonu', 'preview.showSidebar': 'Pokaż {label}', 'preview.hideSidebar': 'Ukryj {label}', diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index 2eb974eba..8288815f5 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -940,8 +940,11 @@ export const ptBR: Dict = { 'preview.errorTitle': 'Não foi possível carregar este exemplo.', 'preview.errorBody': 'A obtenção do HTML do exemplo falhou. Verifique se o Open Design está em execução e tente novamente.', 'preview.retry': 'Tentar novamente', - 'preview.unavailableTitle': 'Nenhuma prévia incluída para esta skill.', - 'preview.unavailableBody': 'Esta skill produz um resultado {kind} — execute o prompt no chat para gerar um.', + 'preview.unavailableTitle': 'Nenhuma prévia incluída para {noun}.', + 'preview.unavailableBody': 'Execute o prompt no chat para gerar um resultado {kind}.', + 'preview.nounSkill': 'esta skill', + 'preview.nounPlugin': 'este plugin', + 'preview.nounTemplate': 'este modelo', 'preview.showSidebar': 'Mostrar {label}', 'preview.hideSidebar': 'Ocultar {label}', diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index e545ae0d4..c8f59424a 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -940,8 +940,11 @@ export const ru: Dict = { 'preview.errorTitle': 'Не удалось загрузить этот пример.', 'preview.errorBody': 'Не удалось получить HTML примера. Убедитесь, что Open Design запущен, и повторите попытку.', 'preview.retry': 'Повторить', - 'preview.unavailableTitle': 'Для этого навыка нет встроенного предпросмотра.', - 'preview.unavailableBody': 'Этот навык создаёт {kind}-вывод — запустите запрос в чате, чтобы сгенерировать его.', + 'preview.unavailableTitle': 'Для {noun} нет встроенного предпросмотра.', + 'preview.unavailableBody': 'Запустите запрос в чате, чтобы сгенерировать {kind}-вывод.', + 'preview.nounSkill': 'этого навыка', + 'preview.nounPlugin': 'этого плагина', + 'preview.nounTemplate': 'этого шаблона', 'preview.showSidebar': 'Показать {label}', 'preview.hideSidebar': 'Скрыть {label}', diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index 06a1f3ca0..f1c6c0702 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -908,8 +908,11 @@ export const tr: Dict = { 'preview.errorTitle': 'Bu örnek yüklenemedi.', 'preview.errorBody': 'Örnek HTML\'i alınamadı. Open Design\'ın çalıştığından emin olup tekrar deneyin.', 'preview.retry': 'Tekrar dene', - 'preview.unavailableTitle': 'Bu yetenek için birlikte gelen bir önizleme yok.', - 'preview.unavailableBody': 'Bu yetenek {kind} çıktısı üretir — bir tane oluşturmak için sohbette istemini çalıştırın.', + 'preview.unavailableTitle': '{noun} için birlikte gelen bir önizleme yok.', + 'preview.unavailableBody': 'Sohbette istemini çalıştırarak {kind} çıktısı üretin.', + 'preview.nounSkill': 'bu yetenek', + 'preview.nounPlugin': 'bu eklenti', + 'preview.nounTemplate': 'bu şablon', 'preview.showSidebar': '{label} göster', 'preview.hideSidebar': '{label} gizle', diff --git a/apps/web/src/i18n/locales/uk.ts b/apps/web/src/i18n/locales/uk.ts index 006e305b2..cf825e9d7 100644 --- a/apps/web/src/i18n/locales/uk.ts +++ b/apps/web/src/i18n/locales/uk.ts @@ -941,8 +941,11 @@ export const uk: Dict = { 'preview.errorTitle': 'Не вдалося завантажити цей приклад.', 'preview.errorBody': 'Не вдалося отримати HTML прикладу. Переконайтеся, що Open Design запущено, і повторіть спробу.', 'preview.retry': 'Повторити', - 'preview.unavailableTitle': 'Для цієї навички немає вбудованого попереднього перегляду.', - 'preview.unavailableBody': 'Ця навичка створює {kind}-вивід — запустіть підказку в чаті, щоб його згенерувати.', + 'preview.unavailableTitle': 'Для {noun} немає вбудованого попереднього перегляду.', + 'preview.unavailableBody': 'Запустіть підказку в чаті, щоб згенерувати {kind}-вивід.', + 'preview.nounSkill': 'цієї навички', + 'preview.nounPlugin': 'цього плагіна', + 'preview.nounTemplate': 'цього шаблону', 'preview.showSidebar': 'Показати {label}', 'preview.hideSidebar': 'Приховати {label}', diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index ff74b3ec3..1f027ea7e 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -1533,8 +1533,11 @@ export const zhCN: Dict = { 'preview.errorTitle': '无法加载此示例。', 'preview.errorBody': '示例 HTML 加载失败。请确认 Open Design 正在运行后重试。', 'preview.retry': '重试', - 'preview.unavailableTitle': '此技能暂未附带预览样例。', - 'preview.unavailableBody': '此技能用于生成 {kind} 产物 — 请在对话中运行此 Prompt 来生成。', + 'preview.unavailableTitle': '{noun}暂未附带预览样例。', + 'preview.unavailableBody': '请在对话中运行此 Prompt 来生成 {kind} 产物。', + 'preview.nounSkill': '此技能', + 'preview.nounPlugin': '此插件', + 'preview.nounTemplate': '此模板', 'preview.showSidebar': '展开{label}', 'preview.hideSidebar': '收起{label}', diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 567bf68d9..6caf36496 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -1133,8 +1133,11 @@ export const zhTW: Dict = { 'preview.errorTitle': '無法載入此範例。', 'preview.errorBody': '範例 HTML 載入失敗。請確認 Open Design 正在執行後重試。', 'preview.retry': '重試', - 'preview.unavailableTitle': '此技能尚未附帶預覽範例。', - 'preview.unavailableBody': '此技能用於產生 {kind} 產物 — 請在對話中執行此 Prompt 來產生。', + 'preview.unavailableTitle': '{noun}尚未附帶預覽範例。', + 'preview.unavailableBody': '請在對話中執行此 Prompt 來產生 {kind} 產物。', + 'preview.nounSkill': '此技能', + 'preview.nounPlugin': '此外掛', + 'preview.nounTemplate': '此範本', 'preview.showSidebar': '展開{label}', 'preview.hideSidebar': '收合{label}', diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index ba7c7040b..327d7da8b 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -1851,13 +1851,23 @@ export interface Dict { 'preview.errorTitle': string; 'preview.errorBody': string; 'preview.retry': string; - // Friendly placeholder copy for skills whose `od.preview.type` is not - // `html` — they ship no fetchable example artifact, so the loading / - // error states are misleading. Issue #897. + // Friendly placeholder copy for surfaces whose `od.preview.type` is + // not `html`, or whose manifest declares a preview entry that doesn't + // ship on disk — they have no fetchable example artifact, so the + // loading / error states are misleading. Issues #897, #2840, #3216. + // Body uses the `{kind}` placeholder (raw `od.preview.type` token, + // e.g. "markdown" or "image"); both keys use the `{noun}` placeholder + // so the same wording reads correctly on skills, plugins, and design + // templates (filled from one of the `preview.noun.*` keys below). 'preview.unavailableTitle': string; - // Body copy uses the `{kind}` placeholder (raw `od.preview.type` - // token, e.g. "markdown" or "image") so each kind reads naturally. 'preview.unavailableBody': string; + // Noun variants so the unavailable placeholder reads with the right + // word for each surface — Skills tab vs. Community/Plugins vs. deck + // design-templates. Keep these short, capitalised by the host + // language's conventions, and translatable in every locale. + 'preview.nounSkill': string; + 'preview.nounPlugin': string; + 'preview.nounTemplate': string; 'preview.showSidebar': string; 'preview.hideSidebar': string; diff --git a/apps/web/src/styles/viewer/composio.css b/apps/web/src/styles/viewer/composio.css index e5706696d..105383c70 100644 --- a/apps/web/src/styles/viewer/composio.css +++ b/apps/web/src/styles/viewer/composio.css @@ -1436,6 +1436,27 @@ max-width: 48ch; line-height: 1.5; } +/* Unavailable state mirrors .ds-modal-error's column layout so the + title and body stack with a visible gap instead of collapsing onto a + single line under the parent flex-row default. Surfaced on the plugin + path after #2840 wired plugin 404s into this placeholder. Issue #3216. */ +.ds-modal-unavailable { + flex-direction: column; + gap: 10px; + padding: 0 24px; + text-align: center; +} +.ds-modal-unavailable-title { + color: var(--text); + font-size: 14px; + font-weight: 600; +} +.ds-modal-unavailable-body { + color: var(--text-muted); + font-size: 13px; + max-width: 48ch; + line-height: 1.5; +} .ds-modal-actions .ghost.is-active { background: var(--accent-tint); color: var(--accent); diff --git a/apps/web/tests/components/PluginExampleDetail.unavailable-noun.test.tsx b/apps/web/tests/components/PluginExampleDetail.unavailable-noun.test.tsx new file mode 100644 index 000000000..788926c21 --- /dev/null +++ b/apps/web/tests/components/PluginExampleDetail.unavailable-noun.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment jsdom + +// Regression for nexu-io/open-design#3216: after #2840 wired plugin and +// design-template 404s into the same "no shipped preview" placeholder the +// skills tab uses, the placeholder copy still hard-coded "skill" — so a user +// opening a Community/Plugins card whose manifest declares a preview entry +// that doesn't ship saw "No shipped preview for this skill." on a card that +// is clearly not a skill. Lock the noun-per-surface contract by asserting +// that PluginExampleDetail's unavailable copy reads with the right noun. +// +// Plugin records (non-deck) read "plugin". Deck-mode records read "template". +// The skills consumer (ExamplesTab) keeps the existing "skill" wording and is +// covered by the existing preview-modal-unavailable-state suite. + +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { InstalledPluginRecord } from '@open-design/contracts'; + +import { PluginExampleDetail } from '../../src/components/plugin-details/PluginExampleDetail'; + +vi.mock('../../src/providers/registry', () => ({ + fetchPluginPreviewHtml: vi.fn(async () => ({ unavailable: true, kind: 'html' })), + fetchPluginExampleHtml: vi.fn(async () => ({ unavailable: true, kind: 'html' })), +})); + +function make(overrides: { + id: string; + title?: string; + mode?: string; +}): InstalledPluginRecord { + return { + id: overrides.id, + title: overrides.title ?? overrides.id, + version: '0.1.0', + sourceKind: 'bundled', + source: '/tmp', + trust: 'bundled', + capabilitiesGranted: [], + manifest: { + name: overrides.id, + version: '0.1.0', + title: overrides.title ?? overrides.id, + od: { + kind: 'scenario', + ...(overrides.mode ? { mode: overrides.mode } : {}), + preview: { type: 'html', entry: './missing.html' }, + }, + }, + fsPath: '/tmp', + installedAt: 0, + updatedAt: 0, + }; +} + +describe('PluginExampleDetail unavailable-state noun', () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('reads as "plugin" when a non-deck plugin ships no preview', async () => { + render( + <PluginExampleDetail + record={make({ id: 'example-live-artifact', title: 'Live Artifact' })} + onClose={() => {}} + onUse={() => {}} + />, + ); + + const placeholder = await waitFor(() => screen.getByTestId('preview-unavailable')); + const copy = placeholder.textContent ?? ''; + + // The noun must match the card surface — calling a plugin a "skill" is + // what #3216 was filed for. + expect(copy).toMatch(/plugin/i); + expect(copy).not.toMatch(/\bskill\b/i); + }); + + it('reads as "template" when a deck-mode plugin ships no preview', async () => { + render( + <PluginExampleDetail + record={make({ id: 'replit-deck', title: 'Replit Deck', mode: 'deck' })} + onClose={() => {}} + onUse={() => {}} + />, + ); + + const placeholder = await waitFor(() => screen.getByTestId('preview-unavailable')); + const copy = placeholder.textContent ?? ''; + + // Decks are surfaced as design templates in Home → Community, so the + // copy should track that vocabulary instead of saying "plugin" or + // "skill". + expect(copy).toMatch(/template/i); + expect(copy).not.toMatch(/\bskill\b/i); + }); +}); From 912c7e380a627833e21a72325de5415dd8a1c743 Mon Sep 17 00:00:00 2001 From: chaoxiaoche <fanzhen910412@gmail.com> Date: Fri, 29 May 2026 11:50:56 +0800 Subject: [PATCH 11/56] fix(plugin): infer semantic roles for token maps (#3231) Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local> --- .../tests/official-token-map.test.ts | 81 +++++++++ plugins/_official/atoms/token-map/SKILL.md | 87 ++++++++++ .../semantic-inference-before-after.json | 164 ++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 packages/plugin-runtime/tests/official-token-map.test.ts create mode 100644 plugins/_official/atoms/token-map/examples/semantic-inference-before-after.json diff --git a/packages/plugin-runtime/tests/official-token-map.test.ts b/packages/plugin-runtime/tests/official-token-map.test.ts new file mode 100644 index 000000000..cc68251ef --- /dev/null +++ b/packages/plugin-runtime/tests/official-token-map.test.ts @@ -0,0 +1,81 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); +const tokenMapSkill = readFileSync( + `${repoRoot}/plugins/_official/atoms/token-map/SKILL.md`, + 'utf8', +); +const semanticInferenceFixture = JSON.parse( + readFileSync( + `${repoRoot}/plugins/_official/atoms/token-map/examples/semantic-inference-before-after.json`, + 'utf8', + ), +) as { + simulationKind: string; + sourceTokens: Array<{ source: string }>; + beforeRun: { + mapped: Array<{ source: string }>; + unmatched: Array<{ source: string }>; + coverage: { mapped: number; total: number; ratio: number }; + }; + afterRun: { + mapped: Array<{ source: string; target: string }>; + unmatched: Array<{ source: string; reason: string }>; + coverage: { mapped: number; total: number; ratio: number }; + reasoningTrace: Array<{ source: string; semanticEvidence: string[] }>; + }; +}; + +describe('official token-map prompt fragment', () => { + it('requires semantic inference for anonymous Figma tokens', () => { + expect(tokenMapSkill).toContain('## Semantic token inference'); + expect(tokenMapSkill).toContain('color-3'); + expect(tokenMapSkill).toContain('Node path, component name'); + expect(tokenMapSkill).toContain('focus-ring'); + expect(tokenMapSkill).toContain('existing `no-target-equivalent` reason'); + }); + + it('documents the before and after expectation without claiming measured lift', () => { + expect(tokenMapSkill).toContain('### Before / after expectation'); + expect(tokenMapSkill).toContain('no-target-equivalent'); + expect(tokenMapSkill).toContain('Selected tab indicator'); + expect(tokenMapSkill).toContain('does not claim a measured accuracy lift'); + expect(tokenMapSkill).toContain('Real accuracy numbers require a fixture suite'); + }); + + it('includes a same-token-batch before and after simulation fixture', () => { + expect(tokenMapSkill).toContain('examples/semantic-inference-before-after.json'); + expect(semanticInferenceFixture.simulationKind).toBe('deterministic-fixture'); + + const sources = new Set(semanticInferenceFixture.sourceTokens.map((token) => token.source)); + const beforeSources = new Set([ + ...semanticInferenceFixture.beforeRun.mapped.map((token) => token.source), + ...semanticInferenceFixture.beforeRun.unmatched.map((token) => token.source), + ]); + const afterSources = new Set([ + ...semanticInferenceFixture.afterRun.mapped.map((token) => token.source), + ...semanticInferenceFixture.afterRun.unmatched.map((token) => token.source), + ]); + + expect(beforeSources).toEqual(sources); + expect(afterSources).toEqual(sources); + expect(semanticInferenceFixture.beforeRun.coverage).toEqual({ mapped: 0, total: 8, ratio: 0 }); + expect(semanticInferenceFixture.afterRun.coverage).toEqual({ + mapped: 7, + total: 8, + ratio: 0.875, + }); + expect( + semanticInferenceFixture.afterRun.reasoningTrace.every( + (token) => token.semanticEvidence.length > 0, + ), + ).toBe(true); + expect( + semanticInferenceFixture.afterRun.unmatched.every( + (token) => token.reason === 'no-target-equivalent', + ), + ).toBe(true); + }); +}); diff --git a/plugins/_official/atoms/token-map/SKILL.md b/plugins/_official/atoms/token-map/SKILL.md index f1ba01d31..93d1288fa 100644 --- a/plugins/_official/atoms/token-map/SKILL.md +++ b/plugins/_official/atoms/token-map/SKILL.md @@ -35,6 +35,93 @@ project-cwd/ `unmatched.json` is the audit list a human reviews; the agent must not invent target tokens silently. +## Semantic token inference + +Figma often exports anonymous source names such as `color-3`, +`paint/17`, or raw `#5B8DEF`. Do not ask the user to rename those +before mapping. First infer the semantic role from usage evidence: + +- Node path, component name, instance overrides, variant/state labels, + frame name, layer name, and nearby text such as `Primary`, + `Selected`, `Link`, `Error`, `Focus`, `Nav`, `Button`, or `CTA`. +- CSS-like position in the rendered tree: background fill, foreground + text/icon, border, divider, overlay, shadow tint, focus ring, status + badge, chart series, or brand/accent treatment. +- Contrast relationships: a color paired repeatedly with the main + canvas is likely foreground; one paired with foreground inside CTA + components is likely primary/accent background; a thin outline around + interactive elements is likely border or focus-ring. +- Reuse topology: a value that appears across primary buttons, + selected tabs, and active nav items is stronger evidence for + `--ds-color-primary` than a value that appears once in an illustration. + +Use that role evidence to choose among existing active design-system +tokens and to decide whether an anonymous token should be renamed or +left unmatched before the executable mapping pass. Keep the on-disk +token-map contract unchanged: the atom still writes the existing bucket +files, `unmatched.json`, and `meta.json` only. + +For example, this is a useful reasoning note for deciding whether +`color-3` should map to the active primary token: + +```jsonc +{ + "source": "color-3", + "value": "#5B8DEF", + "role": "primary", + "targetCandidates": ["--ds-color-primary", "--ds-color-link"], + "evidence": [ + "Button/Primary fill", + "Selected tab indicator", + "Link text in Settings frame" + ] +} +``` + +Then map to an active design-system token only when the evidence is +role-based, not value-only. If the top candidates are too close to +call, or if the evidence points to conflicting roles (`primary` vs +`link` vs `focus-ring`), leave the source token unmatched using the +existing `no-target-equivalent` reason and include the competing +candidates in the hint. This keeps automation useful for common +anonymous-token cases while preserving human review for ambiguous +brand decisions. + +### Before / after expectation + +Without semantic inference, an anonymous Figma token can only produce +an uncertain value-level mapping: + +```jsonc +{ + "source": "color-3", + "value": "#5B8DEF", + "target": null, + "reason": "no-target-equivalent" +} +``` + +With semantic inference, the same token should carry role evidence +before it is accepted: + +```jsonc +{ + "source": "color-3", + "value": "#5B8DEF", + "target": "--ds-color-primary", + "via": "name" +} +``` + +This prompt-only v1 atom does not claim a measured accuracy lift by +itself. Treat the expected improvement as coverage of previously +manual anonymous-token cases when the Figma tree contains enough role +evidence. Real accuracy numbers require a fixture suite with known +source tokens, expected semantic roles, and a before/after agent run. +See `examples/semantic-inference-before-after.json` for a deterministic +same-token-batch simulation that compares the old value-level output +with the semantic inference output. + ## Convergence The atom completes when every input token is either mapped or diff --git a/plugins/_official/atoms/token-map/examples/semantic-inference-before-after.json b/plugins/_official/atoms/token-map/examples/semantic-inference-before-after.json new file mode 100644 index 000000000..ab0f10d86 --- /dev/null +++ b/plugins/_official/atoms/token-map/examples/semantic-inference-before-after.json @@ -0,0 +1,164 @@ +{ + "simulationKind": "deterministic-fixture", + "notes": [ + "This is not a measured production benchmark.", + "It simulates the same anonymous Figma token batch under the old value-level prompt and the semantic inference prompt.", + "The after run keeps the existing executable token-map disk contract: bucket matches plus unmatched.json/meta.json, with no inferred.json or new unmatched reason." + ], + "sourceTokens": [ + { + "source": "color-1", + "value": "#FFFFFF", + "usage": ["App Shell/Canvas fill", "Card/Default fill", "Modal/Surface fill"] + }, + { + "source": "color-2", + "value": "#111827", + "usage": ["Body text", "Icon/Default foreground", "Table cell text"] + }, + { + "source": "color-3", + "value": "#5B8DEF", + "usage": ["Button/Primary fill", "Selected tab indicator", "Active nav item"] + }, + { + "source": "color-4", + "value": "#5B8DEF", + "usage": ["Link text in Settings frame", "Inline help link", "Docs link"] + }, + { + "source": "color-5", + "value": "#D0D5DD", + "usage": ["Input/Default border", "Divider line", "Table row separator"] + }, + { + "source": "paint-17", + "value": "#84CAFF", + "usage": ["Button/Focus outline", "Input/Focus ring", "Menu item focus halo"] + }, + { + "source": "color-6", + "value": "#E5484D", + "usage": ["Error message text", "Destructive button fill", "Alert/Error icon"] + }, + { + "source": "color-7", + "value": "#F59E0B", + "usage": ["Chart series 2", "Illustration accent", "One-off metric badge"] + } + ], + "targetDesignSystemTokens": [ + "--ds-color-surface", + "--ds-color-fg", + "--ds-color-primary", + "--ds-color-link", + "--ds-color-border", + "--ds-color-focus-ring", + "--ds-color-danger" + ], + "beforeRun": { + "promptMode": "value-level-token-map", + "mapped": [], + "unmatched": [ + { "source": "color-1", "reason": "no-target-equivalent" }, + { "source": "color-2", "reason": "no-target-equivalent" }, + { "source": "color-3", "reason": "no-target-equivalent" }, + { "source": "color-4", "reason": "no-target-equivalent" }, + { "source": "color-5", "reason": "no-target-equivalent" }, + { "source": "paint-17", "reason": "no-target-equivalent" }, + { "source": "color-6", "reason": "no-target-equivalent" }, + { "source": "color-7", "reason": "no-target-equivalent" } + ], + "coverage": { + "mapped": 0, + "total": 8, + "ratio": 0 + } + }, + "afterRun": { + "promptMode": "semantic-token-inference", + "mapped": [ + { + "source": "color-1", + "target": "--ds-color-surface", + "via": "name" + }, + { + "source": "color-2", + "target": "--ds-color-fg", + "via": "name" + }, + { + "source": "color-3", + "target": "--ds-color-primary", + "via": "name" + }, + { + "source": "color-4", + "target": "--ds-color-link", + "via": "name" + }, + { + "source": "color-5", + "target": "--ds-color-border", + "via": "name" + }, + { + "source": "paint-17", + "target": "--ds-color-focus-ring", + "via": "name" + }, + { + "source": "color-6", + "target": "--ds-color-danger", + "via": "name" + } + ], + "unmatched": [ + { + "source": "color-7", + "reason": "no-target-equivalent", + "hint": "ambiguous semantic role; candidates: chart-series, illustration-accent, warning" + } + ], + "coverage": { + "mapped": 7, + "total": 8, + "ratio": 0.875 + }, + "reasoningTrace": [ + { + "source": "color-1", + "semanticEvidence": ["App Shell/Canvas fill", "Card/Default fill", "Modal/Surface fill"] + }, + { + "source": "color-2", + "semanticEvidence": ["Body text", "Icon/Default foreground", "Table cell text"] + }, + { + "source": "color-3", + "semanticEvidence": ["Button/Primary fill", "Selected tab indicator", "Active nav item"] + }, + { + "source": "color-4", + "semanticEvidence": ["Link text in Settings frame", "Inline help link", "Docs link"] + }, + { + "source": "color-5", + "semanticEvidence": ["Input/Default border", "Divider line", "Table row separator"] + }, + { + "source": "paint-17", + "semanticEvidence": ["Button/Focus outline", "Input/Focus ring", "Menu item focus halo"] + }, + { + "source": "color-6", + "semanticEvidence": ["Error message text", "Destructive button fill", "Alert/Error icon"] + }, + { + "source": "color-7", + "semanticEvidence": ["Chart series 2", "Illustration accent", "One-off metric badge"] + } + ] + } +} From bf7152dbdc68eb4bfb1bc0d571ce4e8326aee366 Mon Sep 17 00:00:00 2001 From: lefarcen <935902669@qq.com> Date: Fri, 29 May 2026 13:28:18 +0800 Subject: [PATCH 12/56] fix(web): disable Draw direct-send during an active run, keep Queue (#3270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reinstates the Studio tool hardening from #3081 on top of current main: while a task is streaming, the Draw/annotation primary Send action and its Enter shortcut are disabled, so an annotation can no longer leak into the active run while the button shows a disabled reason. This is the synthesis of two stacked-merge-divergent changes rather than a wholesale revert: Queue stays available, so the value from #1961 (kami) is preserved — an annotation made during a run is still staged for the next turn instead of being dropped. Only the button/Enter availability changes; the downstream queue/streaming-staging handler in ChatComposer is untouched. - PreviewDrawOverlay: send('send') and canSend now respect sendDisabled. - Reframed the streaming Draw test to assert Send is disabled while Queue still emits a queued annotation (preserving the "annotate during a run" coverage). - Added unit coverage for the Enter/Send guard and Queue availability while a task is running. --- .../web/src/components/PreviewDrawOverlay.tsx | 5 +- apps/web/tests/components/FileViewer.test.tsx | 13 ++-- .../components/PreviewDrawOverlay.test.tsx | 59 +++++++++++++++++++ 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/PreviewDrawOverlay.tsx b/apps/web/src/components/PreviewDrawOverlay.tsx index 9c8955faa..7d3750cb6 100644 --- a/apps/web/src/components/PreviewDrawOverlay.tsx +++ b/apps/web/src/components/PreviewDrawOverlay.tsx @@ -364,6 +364,9 @@ export function PreviewDrawOverlay({ const shouldCapture = hasInk || hasTarget || captureViewport; const canSubmit = shouldCapture || Boolean(note.trim()); if (sending || !canSubmit) return; + // While a task is running the primary Send is disabled (use Queue instead). + // The note/attachment is not lost: Queue still stages it for the next turn. + if (action === 'send' && sendDisabled) return; setCaptureWarning(null); setPendingAction(action); try { @@ -425,7 +428,7 @@ export function PreviewDrawOverlay({ const overlayPointer = active ? 'auto' : 'none'; const showCanvas = active || hasInk; const canSubmit = hasInk || Boolean(captureTarget) || captureViewport || Boolean(note.trim()); - const canSend = canSubmit; + const canSend = canSubmit && !sendDisabled; const canUndo = undoCount > 0 && !sending; const canRedo = redoCount > 0 && !sending; diff --git a/apps/web/tests/components/FileViewer.test.tsx b/apps/web/tests/components/FileViewer.test.tsx index 76f5856e2..d520c99cd 100644 --- a/apps/web/tests/components/FileViewer.test.tsx +++ b/apps/web/tests/components/FileViewer.test.tsx @@ -1722,7 +1722,7 @@ describe('FileViewer tweaks toolbar', () => { expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).srcdoc).toBe(frame.srcdoc); }); - it('lets Draw direct send emit a queued annotation while a task is running', async () => { + it('disables Draw direct send during a run but keeps Queue available so the annotation is not lost', async () => { const annotationSpy = vi.fn(); window.addEventListener(ANNOTATION_EVENT, annotationSpy); @@ -1738,17 +1738,22 @@ describe('FileViewer tweaks toolbar', () => { target: { value: 'mark this' }, }); + // While a task is running the primary Send is disabled; Queue stays available + // so the annotation is staged for the next turn rather than sent mid-run. + const send = screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement; + expect(send.disabled).toBe(true); const queue = screen.getByRole('button', { name: 'Queue' }) as HTMLButtonElement; expect(queue.disabled).toBe(false); - const send = screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement; - expect(send.disabled).toBe(false); fireEvent.click(send); + expect(annotationSpy).not.toHaveBeenCalled(); + + fireEvent.click(queue); await waitFor(() => expect(annotationSpy).toHaveBeenCalledTimes(1)); expect(annotationSpy.mock.calls[0]?.[0]).toMatchObject({ detail: { - action: 'send', + action: 'queue', note: 'mark this', filePath: 'preview.html', }, diff --git a/apps/web/tests/components/PreviewDrawOverlay.test.tsx b/apps/web/tests/components/PreviewDrawOverlay.test.tsx index 6152ddf3b..61c1f1452 100644 --- a/apps/web/tests/components/PreviewDrawOverlay.test.tsx +++ b/apps/web/tests/components/PreviewDrawOverlay.test.tsx @@ -61,6 +61,65 @@ describe('PreviewDrawOverlay', () => { } }); + it('does not direct-send via Enter while a task is running', () => { + const annotation = vi.fn(); + window.addEventListener('opendesign:annotation', annotation); + + try { + const { container } = render( + <PreviewDrawOverlay active sendDisabled sendDisabledReason="A task is currently running"> + <div style={{ width: 320, height: 200 }} /> + </PreviewDrawOverlay>, + ); + + const input = container.querySelector<HTMLInputElement>('.preview-draw-note-input'); + expect(input).toBeTruthy(); + + fireEvent.change(input!, { target: { value: 'Please inspect this panel.' } }); + fireEvent.keyDown(input!, { key: 'Enter' }); + + expect(annotation).not.toHaveBeenCalled(); + } finally { + window.removeEventListener('opendesign:annotation', annotation); + } + }); + + it('disables the primary Send action while a task is running', () => { + const { getByRole } = render( + <PreviewDrawOverlay active sendDisabled sendDisabledReason="A task is currently running"> + <div style={{ width: 320, height: 200 }} /> + </PreviewDrawOverlay>, + ); + + const sendButton = getByRole('button', { name: 'Send' }); + expect((sendButton as HTMLButtonElement).disabled).toBe(true); + }); + + it('keeps Queue available so an annotation is not lost while a task is running', async () => { + const annotation = vi.fn(); + window.addEventListener('opendesign:annotation', annotation); + + try { + const { container, getByRole } = render( + <PreviewDrawOverlay active sendDisabled sendDisabledReason="A task is currently running"> + <div style={{ width: 320, height: 200 }} /> + </PreviewDrawOverlay>, + ); + + const input = container.querySelector<HTMLInputElement>('.preview-draw-note-input'); + fireEvent.change(input!, { target: { value: 'Queue this up.' } }); + + const queueButton = getByRole('button', { name: 'Queue' }); + expect((queueButton as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(queueButton); + + await waitFor(() => expect(annotation).toHaveBeenCalledTimes(1)); + expect(annotation.mock.calls[0]?.[0].detail).toMatchObject({ action: 'queue' }); + } finally { + window.removeEventListener('opendesign:annotation', annotation); + } + }); + it('clears transient ink when draw mode exits', async () => { const { container, rerender } = render( <PreviewDrawOverlay active> From 98a2c639734b05f99f361357ec824662e73c6c85 Mon Sep 17 00:00:00 2001 From: lefarcen <935902669@qq.com> Date: Fri, 29 May 2026 13:43:37 +0800 Subject: [PATCH 13/56] feat(daemon): add Antigravity agent adapter (#3157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(daemon): add Antigravity agent adapter Adds Google Antigravity (`agy` CLI) as a coding-agent runtime. Detection picks up `agy` on PATH, the daemon spawns `agy -p "<prompt>"` for a single non-interactive turn, and the assistant text reply streams back on stdout. OAuth is shared with the Antigravity IDE through the system keyring, so users who have signed into the desktop app are authenticated on first run with no extra step. `agy` v1.0.3 has no JSON / stream-json / ACP output mode (upstream issue #119), no `--model` flag (issue #35), and no MCP forwarding hook yet — the adapter ships with `streamFormat: 'plain'` and a single `default` fallback model so the model picker doesn't mislead users into thinking their choice is wired through. We will upgrade buildArgs + add a dedicated event parser when upstream ships structured output. Also gitignores `.antigravitycli/`, the project-local config directory `agy` auto-creates on every run (upstream issue #175). * fix(daemon): Antigravity adapter — stdin prompt, brand icon, form loop, empty-output guard - Switch prompt delivery from argv to stdin (`agy -p -`) to avoid the 30KB maxPromptArgBytes limit that blocked real-world composed prompts - Add official Antigravity brand SVG icon to agent picker - Fix repeated question-form loop for plain agents by injecting an OVERRIDE block when form answers are already present in the transcript - Add empty-output guard for plain agents so expired auth or silent failures surface a user-visible error instead of a blank "Done" turn * feat(daemon): expand Antigravity adapter — model picker, form-loop fix, OAuth launcher, log-file classification PR #3157 follow-up integrating four iterations from end-to-end manual testing on Gemini 3.5 Flash + GPT-OSS 120B Medium through `agy` v1.0.3. Each section is independently verifiable; combined they're what made the first successful artifact generation work end-to-end. ## Model picker via settings.json (agy has no --model flag) agy v1.0.3 ships no `--model` CLI flag (upstream issue #35), but the TUI Switch-Model picker writes the chosen label to `~/.gemini/antigravity-cli/settings.json`'s `"model"` field, and every `-p` invocation re-reads that file on startup — verified by capturing the `--log-file` line `Propagating selected model override to backend: label="<model>"`. Antigravity's `fallbackModels` now lists the 8 labels its TUI exposes (Gemini 3.1 Pro / 3.5 Flash variants, Claude Sonnet/Opus 4.6 Thinking, GPT-OSS 120B Medium) and `buildArgs` persists the user's choice to settings.json right before spawn. The synthetic `default` id is preserved — picking it leaves settings.json untouched so a user who switches models from agy's own TUI keeps their choice. Introduces `RuntimeAgentDef.supportsCustomModel?: boolean`. AMR's hardcoded blocklist in `SettingsDialog.tsx` migrates to the declarative flag (it rejects free-form ids at the ACP layer), and antigravity opts out because its label set is a server-side enum that silently fails on unrecognised strings. ## Form-loop fix (transcript sanitizer + stronger OVERRIDE) The discovery form loop on weak/medium plain-stream models (GPT-OSS 120B Medium, Gemini 3.5 Flash) had two reinforcing causes: 1. `buildDaemonTranscript` packed the prior assistant turn's literal `<question-form>` markup into the user request on the next turn, giving the model a template to echo. New `sanitizePriorAssistantTurnForTranscript` strips `<question-form>...</question-form>` blocks and ```json fences that match form-schema shape, replacing them with a brief placeholder. User content is preserved verbatim (a user who legitimately mentions `<question-form>` in chat keeps their message intact). 2. The OVERRIDE block on form-answered turns was 4 lines and only banned the bare `<question-form>` tag — models still emitted the fenced JSON, form-asking prose ("Got it — tell me the following"), and fake system events ("subagents stopped"). The new `FORM_ANSWERED_SYSTEM_OVERRIDE` enumerates each anti-pattern and pins them via tests, so silently weakening any line reintroduces the regression. Also adds RuntimeAgentDef.resumesSessionViaCli + RuntimeContext. hasPriorAssistantTurn as forward-looking abstractions (skipTranscript option on composeChatUserRequestForAgent). Antigravity does NOT opt in — agy's `-c` resume activates an internal agentic loop with tool retries and fallback-to-cached-response on tool errors that the OD system prompt cannot steer; reverted after seeing byte-identical form re-emissions caused by agy's own retry logic, not OD's transcript. ## One-click OAuth via system terminal agy print mode can't complete Google Sign-In on its own (the OAuth callback page asks the user to paste an auth code back into agy, but `-p` has no input field). Before this commit the auth banner only told the user to "open a terminal yourself." Adds `POST /api/agents/antigravity/oauth-launch` and a cross-platform launcher in `runtimes/terminal-launch.ts`: - macOS: osascript → Terminal.app `do script "agy"` + activate - Linux: tries x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, xterm in order - Windows: `cmd /c start "Open Design" cmd /k agy` The endpoint hardcodes the `agy` command (no user input → no shell injection surface) and is loopback-gated like the other daemon endpoints. The chat's `AGENT_AUTH_REQUIRED` banner now renders a "Sign in via terminal" button next to Retry; clicking it spawns the terminal so the user can finish OAuth in one click. ## Silent-failure classification (auth vs quota via --log-file) agy print mode is silent on stdout/stderr for both missing-OAuth AND quota-exhausted failures — the upstream `RESOURCE_EXHAUSTED (code 429): Individual quota reached` and the `not logged into Antigravity` line only surface in agy's `--log-file`. Without log inspection the daemon misread quota as "auth required" and showed the wrong banner. `RuntimeContext.agentLogFilePath` carries a daemon-owned per-run temp path that antigravity's buildArgs translates to `--log-file <path>`. The empty-output guard now reads that log on a `code === 0 && !childStdoutSeen` exit, feeds the tail to `classifyAgentServiceFailure`, and routes: - "not logged into Antigravity" → AGENT_AUTH_REQUIRED with antigravityAuthGuidance - "RESOURCE_EXHAUSTED" / "quota" / → RATE_LIMITED with "Individual quota reached" antigravityQuotaGuidance - none of the above (rare) → fall back to auth guidance as the most likely cause Both surface a terminal launcher in the auth banner: auth gets "Sign in via terminal", quota gets "Switch model in terminal" — same endpoint, contextual label. The handler is identical (open agy in a terminal); the user either signs in or uses agy's Switch Model picker to pick a model with available quota. ## Validation - `pnpm guard` pass - `pnpm --filter @open-design/daemon` runtime + telemetry suites: 192 passed, 1 skipped (the 1 pre-existing `task-type` failure on origin/main is unrelated to this change) - `pnpm --filter @open-design/web` typecheck pass; sse / amr-guidance / AgentIcon suites pass (51 web tests) - Manual end-to-end on darwin + Gemini 3.5 Flash and GPT-OSS 120B Medium: turn-1 question-form rendered correctly, turn-2 produced `<artifact>` with full HTML (3.3KB Modern Minimal design) instead of re-emitting the form. agy `--log-file` content correctly classified as RATE_LIMITED when Gemini Pro quota was exhausted, and as AGENT_AUTH_REQUIRED when keychain was cleared. * fix(web/test): align amrAgent fixture with supportsCustomModel contract The AMR agent definition in the daemon ships `supportsCustomModel: false` so the Settings model picker hides the free-text "Custom…" option. The PR changed `allowCustomModel` from `selected.id !== 'amr'` (hardcoded) to `selected.supportsCustomModel !== false` (declarative), but the test fixture was not updated to carry the same field — causing the `__custom__` sentinel to appear in the picker under test. Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code) * fix(daemon): align formAnswerTransition wording with main + scope build directive to discovery CI surfaced two failures on the merge with main: - chat-route.test marks submitted discovery form answers ... expected the main-version wording 'Do not emit another <formId> form.' - telemetry-message-finalization keeps non-discovery form answers active ... expected task-type to fall through the else branch ('Treat these form answers as the active user turn'), not the discovery RULE 2/RULE 3 build branch. The colleague's earlier fba1e40b form-loop fix tightened both pieces (stronger wording + grouped discovery|task-type into the build branch) but didn't update the tests that pin the contract. Revert the transition wording to main and re-scope the build directive to 'discovery' only. The aggressive form-loop suppression we added in this PR now lives in the system-prompt FORM_ANSWERED_SYSTEM_OVERRIDE block, which is far stronger than the user-request transition text this commit reverts. * fix(daemon): scope formOverride by form id, detach Linux terminal, move agy log cleanup to finally - FORM_ANSWERED_GENERIC_OVERRIDE: new exported constant for non-discovery/ non-task-type form ids; contains only the "do not re-ask" suppression without the RULE 2 / RULE 3 / artifact directive. - formAnswerTransitionForCurrentPrompt: extend build-transition branch to include task-type alongside discovery, keeping user-turn and system override consistent. - Prompt assembly (server.ts ~10848): derive formOverride from the parsed form id — FORM_ANSWERED_SYSTEM_OVERRIDE for discovery/task-type, FORM_ANSWERED_GENERIC_OVERRIDE for all other form ids, empty otherwise. - launchOnLinux: replace execFileAsync (waited for terminal exit, 3 s cap) with spawn({ detached: true, stdio: 'ignore' }) + unref(); resolve on the 'spawn' event so long-lived interactive terminals (xterm, konsole) are not killed mid-OAuth-flow. - Antigravity log cleanup: move fs.promises.unlink(agentLogFilePath) into a try/finally wrapper around the close handler so every exit path (success, failure, cancel, non-zero exit) cleans up the per-run temp file, preventing unbounded /tmp accumulation. - Tests: rename task-type case to assert build-transition behaviour; add generic-form-id case (preferences) pinning the non-build path; add FORM_ANSWERED_GENERIC_OVERRIDE content assertions. Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code) * fix(daemon): switch Antigravity buildArgs to chat subcommand invocation Replace top-level `-p -` with `agy chat [--log-file …] -` so the adapter uses the documented chat subcommand and stdin sentinel instead of the unrecognised global -p flag. Update the agent-args test description and all four deepEqual assertions to assert the ['chat', '-'] shape. Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code) * test(daemon): drop real-platform default-launch assertion from terminal-launch suite The removed test called launchAgentInSystemTerminal('agy') with no platform override, which invokes the real system terminal on every developer machine running the daemon test suite (Terminal.app on macOS, cmd.exe on Windows, xterm/gnome-terminal on Linux). That is an unacceptable OS side effect for a unit test. The behaviour being asserted — that omitting platform selects process.platform — is a TypeScript default-parameter guarantee, not a runtime invariant that needs an integration test. The remaining 'aix' case continues to pin the unsupported-platform failure shape. Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code) * fix(daemon): buffer Antigravity stdout to suppress auth URL before close-time classifier The plain-stream close handler at code===0 can detect an agy OAuth prompt in agentStdoutTail and emit AGENT_AUTH_REQUIRED, but by the time close fires the stdout chunk has already been forwarded to the client via the plain-stream `send('stdout', { chunk })` path. This leaves both the raw OAuth URL and the terminal-launch guidance visible in chat. Buffer all stdout chunks for the `antigravity` agent instead of forwarding them immediately. The existing close-time auth-prompt guard (code===0, !trackingSubstantiveOutput, childStdoutSeen) returns early when it detects the auth pattern, leaving the buffer unflushed and the OAuth URL out of the SSE stream. For legitimate assistant output the buffer is flushed in order just before design.runs.finish so the chunks still arrive before the run's finished event. Adds a chat-route integration test using a fake `agy` that exits 0 after printing the canonical auth prompt; asserts that the run emits AGENT_AUTH_REQUIRED with no event: stdout delta containing the URL. Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code) * test(daemon): isolate antigravity buildArgs argv test from real settings file Pass a temp antigravitySettingsPath in the RuntimeContext for the withModel argv assertion so unit tests do not touch ~/.gemini/antigravity-cli/settings.json. Adds the optional antigravitySettingsPath field to RuntimeContext and threads it through buildArgs to writeAntigravityModelSelection; production callers leave it undefined, preserving the existing default path. Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code) * fix(daemon): revert Antigravity buildArgs to `-p -` (the only working agy v1.0.3 invocation) The looper-reviewer-bot reported `chat` as agy's headless subcommand based on its environment's agy build, and looper-fixer applied that shape. The installed CLI (`agy --version` reports `1.0.3`) does NOT expose a `chat` subcommand — `agy --help`'s `Available subcommands` section lists only `changelog / help / install / plugin / update`, and `agy chat - < prompt` exits 0 with empty stdout (the daemon then forwards it as a 'successful' empty reply, exactly the failure mode the auth/quota guard at server.ts ~12090 is meant to catch — for the wrong reason). `-p` is the documented print-mode flag (`Short alias for --print`) and `agy -p -` reads the prompt from stdin and prints the model reply, which the entire end-to-end test sequence in this PR has verified against (form-loop fix, settings.json model routing, log-file classification all confirmed working on Gemini 3.5 Flash + GPT-OSS 120B Medium with this invocation). Updates the agent-args test to pin `['-p', '-']` instead of `['chat', '-']` and adds an inline comment in antigravity.ts noting that `chat` may exist in a future agy build but is not the contract on the installed CLI today. * fix(daemon): serialize Antigravity concrete-model spawns to dodge settings.json race Reviewer (looper) flagged a concurrency race in the model-routing path: ~/.gemini/antigravity-cli/settings.json is process-global, so two OD runs starting close together with different concrete models can race the file — run A writes model A, run B writes model B, then A's agy finally reads settings.json and executes on model B. The Settings model picker becomes nondeterministic under parallel conversations. Adds a per-process promise chain in antigravity.ts: - acquireAntigravityModelLock(): chain-await + return release fn - waitForAgyToReadModel(logPath, expected): polls agy's --log-file for the upstream signal 'Propagating selected model override to backend: label="<X>"' which model_config_manager.go emits once agy has finished reading settings.json. Returns true on observed match, false on timeout. Regex-escapes the expected label so '(' / ')' in 'GPT-OSS 120B (Medium)' match literally, not as a capture group. server.ts spawn pipeline now acquires the lock BEFORE buildArgs (which performs the settings.json write) and schedules a release-once handler that fires when EITHER (a) the log-file confirms agy read the model or (b) the child exits — the exit fallback prevents a stuck/crashed agy from starving the queue for every subsequent antigravity spawn. Default-model spawns bypass the lock entirely: their buildArgs doesn't touch settings.json, so there's nothing to serialize. Tests pin: - FIFO ordering across 2 / 3 concurrent acquirers - Wait helper's regex correctly matches parenthesized labels - Wait helper does NOT match a different model with shared prefix - Wait helper swallows missing-log-file errors and returns false on timeout (no spawn-pipeline crash if the log never appears) 194 → 198 passing runtime tests, 0 regressions. * fix(daemon): close Antigravity lock release race on slow agy startup (looper #263fd2fe7) Reviewer flagged that the previous serialization scheduled `releaseOnce` in `.finally()` on waitForAgyToReadModel — meaning the helper's `false` timeout return ALSO released the lock. If agy took longer than the 15s polling window to read settings.json (cold start, swap-thrash, slow network handshake to the upstream backend), run A's lock dropped at 15s, run B rewrote settings.json with model B, and run A's still-starting agy then read the wrong model. Same race the original mutex was meant to close. Fix the release semantics to be release-on-confirmation-only: - waitForAgyToReadModel: `false` now strictly means 'I gave up polling,' not 'agy definitely did not read this.' Document the contract so a future caller can't conflate the two. Add an optional AbortSignal so server.ts can stop polling when the child exits — without it, the leftover watcher could outlive the run and accidentally match a later concurrent run's log content, releasing the wrong lock. - server.ts: schedule `releaseOnce` only when waitForAgyToReadModel returns true. The exit handler (which fires for crashes, fast exits, normal completion) is now the canonical fallback that releases the lock no matter what — the queue can't starve permanently because agy always exits eventually. The exit handler also fires the AbortController so the watcher cleans up. New tests pin: - timeout returns false WITHOUT any release-implying side effect - already-aborted signal short-circuits (no readFile calls) - abort mid-poll wakes the helper from its setTimeout (no multi-hundred-ms hang waiting out a poll interval that no longer matters) 198 → 201 passing runtime tests, 0 regressions. --------- Co-authored-by: qiongyu1999 <2694684348@qq.com> --- .gitignore | 1 + apps/daemon/src/runtimes/auth.ts | 63 +++ apps/daemon/src/runtimes/defs/amr.ts | 5 + apps/daemon/src/runtimes/defs/antigravity.ts | 247 +++++++++++ apps/daemon/src/runtimes/registry.ts | 2 + apps/daemon/src/runtimes/terminal-launch.ts | 130 ++++++ apps/daemon/src/runtimes/types.ts | 52 +++ apps/daemon/src/server.ts | 384 +++++++++++++++++- apps/daemon/tests/chat-route.test.ts | 44 ++ apps/daemon/tests/runtimes/agent-args.test.ts | 157 ++++++- .../runtimes/antigravity-model-lock.test.ts | 263 ++++++++++++ .../tests/runtimes/env-and-detection.test.ts | 64 +++ .../tests/runtimes/helpers/test-helpers.ts | 1 + .../tests/runtimes/terminal-launch.test.ts | 21 + .../telemetry-message-finalization.test.ts | 136 ++++++- apps/web/public/agent-icons/antigravity.svg | 1 + apps/web/src/components/AgentIcon.tsx | 1 + apps/web/src/components/ChatPane.tsx | 27 ++ apps/web/src/components/ProjectView.tsx | 24 ++ apps/web/src/components/SettingsDialog.tsx | 7 +- apps/web/src/i18n/locales/ar.ts | 2 + apps/web/src/i18n/locales/de.ts | 2 + apps/web/src/i18n/locales/en.ts | 2 + apps/web/src/i18n/locales/es-ES.ts | 2 + apps/web/src/i18n/locales/fa.ts | 2 + apps/web/src/i18n/locales/fr.ts | 2 + apps/web/src/i18n/locales/hu.ts | 2 + apps/web/src/i18n/locales/id.ts | 2 + apps/web/src/i18n/locales/it.ts | 2 + apps/web/src/i18n/locales/ja.ts | 2 + apps/web/src/i18n/locales/ko.ts | 2 + apps/web/src/i18n/locales/pl.ts | 2 + apps/web/src/i18n/locales/pt-BR.ts | 2 + apps/web/src/i18n/locales/ru.ts | 2 + apps/web/src/i18n/locales/th.ts | 2 + apps/web/src/i18n/locales/tr.ts | 2 + apps/web/src/i18n/locales/uk.ts | 2 + apps/web/src/i18n/locales/zh-CN.ts | 2 + apps/web/src/i18n/locales/zh-TW.ts | 2 + apps/web/src/i18n/types.ts | 2 + apps/web/src/providers/daemon.ts | 82 +++- apps/web/src/runtime/amr-guidance.ts | 58 ++- apps/web/src/utils/agentLabels.ts | 2 + .../SettingsDialog.execution.test.tsx | 1 + apps/web/tests/providers/sse.test.ts | 110 +++++ apps/web/tests/runtime/amr-guidance.test.ts | 58 +++ packages/contracts/src/api/registry.ts | 8 + 47 files changed, 1963 insertions(+), 26 deletions(-) create mode 100644 apps/daemon/src/runtimes/defs/antigravity.ts create mode 100644 apps/daemon/src/runtimes/terminal-launch.ts create mode 100644 apps/daemon/tests/runtimes/antigravity-model-lock.test.ts create mode 100644 apps/daemon/tests/runtimes/terminal-launch.test.ts create mode 100644 apps/web/public/agent-icons/antigravity.svg diff --git a/.gitignore b/.gitignore index a93522294..fdd212e1a 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ tsconfig.tsbuildinfo .claude/ .codex/ .deepseek/ +.antigravitycli/ # Commander task scratchpad; keep local task notes out of git by default. .task/ diff --git a/apps/daemon/src/runtimes/auth.ts b/apps/daemon/src/runtimes/auth.ts index 7e0d16e4f..6cd33f12b 100644 --- a/apps/daemon/src/runtimes/auth.ts +++ b/apps/daemon/src/runtimes/auth.ts @@ -22,6 +22,33 @@ const CURSOR_AUTH_GUIDANCE = const DEEPSEEK_AUTH_GUIDANCE = 'DeepSeek TUI is installed but is not authenticated. Add or verify your API key in `~/.deepseek/config.toml` as `api_key = "..."`, or expose DEEPSEEK_API_KEY to the Open Design daemon process, then retry. If Open Design is launched outside an interactive shell, shell rc files such as ~/.zshrc may not be loaded.'; +// agy's print mode (`-p`) detects a missing OAuth token, prints the +// Google sign-in URL to stdout, waits 30s for completion, then exits +// "Error: authentication timed out." That URL points at a callback page +// that asks the user to paste the resulting auth code BACK into agy — +// which only works in the interactive TUI. So in OD's chat, surfacing +// the raw URL is a dead end (no input field to paste the code into). +// Instead we ask the user to run `agy` in a terminal once, which opens +// the browser, completes OAuth, and writes the credentials to the +// system keyring — both `-p` and TUI invocations read from there +// afterward, so the chat run can succeed on retry. +const ANTIGRAVITY_AUTH_GUIDANCE = + 'Antigravity needs to sign in. The agy CLI\'s keyring entry has expired or been cleared, and `-p` print mode cannot complete OAuth on its own (it has no field to paste the auth code into).\n\nFix: open a terminal and run `agy` once — it will open Google sign-in in your browser, accept the redirect, and store the token in your system keyring. After you finish, return here and retry this chat. You only need to do this once; the keyring entry persists across both terminal and Open Design runs.'; + +// agy's account-level quota is per-model (consumer accounts get a +// separate quota for Gemini 3 Pro vs Flash vs Claude vs GPT-OSS), and +// when exhausted the upstream returns +// RESOURCE_EXHAUSTED (code 429): Individual quota reached. Contact +// your administrator to enable overages. Resets in <H>h<M>m<S>s. +// to the `--log-file`. Print mode emits nothing on stdout/stderr, so +// without log inspection the daemon misreads it as missing-OAuth. +// Guidance points the user at agy's TUI Switch-Model picker because +// (a) different models have separate quotas, and (b) we can't drive +// the picker from OD until upstream issue #35 ships a `--model` +// flag — see antigravity.ts notes. +const ANTIGRAVITY_QUOTA_GUIDANCE = + 'Antigravity returned "RESOURCE_EXHAUSTED: Individual quota reached" for the current model. Each Antigravity model (Gemini 3 Pro / Flash, Claude 4.6, GPT-OSS) has its own quota.\n\nFix: open `agy` in a terminal and use its Switch Model picker (the menu at the bottom of the TUI) to pick a model with available quota, then retry here. Open Design uses whatever model you pick in agy\'s TUI when the Settings model picker is left on "Default". Quotas reset automatically on Antigravity\'s schedule.'; + const REASONIX_AUTH_GUIDANCE = 'DeepSeek Reasonix is installed but is not authenticated. Add your API key in `~/.reasonix/config.json` under `apiKey`, or expose DEEPSEEK_API_KEY to the Open Design daemon process, then retry. If Open Design is launched outside an interactive shell, shell rc files such as ~/.zshrc may not be loaded.'; @@ -33,6 +60,14 @@ export function deepseekAuthGuidance(): string { return DEEPSEEK_AUTH_GUIDANCE; } +export function antigravityAuthGuidance(): string { + return ANTIGRAVITY_AUTH_GUIDANCE; +} + +export function antigravityQuotaGuidance(): string { + return ANTIGRAVITY_QUOTA_GUIDANCE; +} + export function reasonixAuthGuidance(): string { return REASONIX_AUTH_GUIDANCE; } @@ -50,6 +85,27 @@ export function isCursorAuthFailureText(text: string): boolean { ); } +// agy's plain-mode output when no keyring credentials are available: +// - Top of stdout: "Authentication required. Please visit the URL to log in: <URL>" +// - Tail of stdout: "Waiting for authentication (timeout 30s)..." +// "Error: authentication timed out." +// The same TUI text is logged by `agy --log-file` as +// "You are not logged into Antigravity" and +// "error getting token source: You are not logged into Antigravity" +// (confirmed via the `--log-file` dump on a cleared keyring). Any of +// these is sufficient signal — match conservatively so the regex +// doesn't fire on prose containing the word "authentication" by accident. +export function isAntigravityAuthFailureText(text: string): boolean { + const value = String(text || ''); + if (!value.trim()) return false; + return ( + /authentication required.*please visit/i.test(value) || + /authentication timed out/i.test(value) || + /not logged into antigravity/i.test(value) || + /accounts\.google\.com\/o\/oauth2\/auth.*antigravity/i.test(value) + ); +} + export function isDeepSeekAuthFailureText(text: string): boolean { const value = String(text || ''); if (!value.trim()) return false; @@ -92,6 +148,13 @@ export function classifyAgentAuthFailure( message: deepseekAuthGuidance(), }; } + if (agentId === 'antigravity') { + if (!isAntigravityAuthFailureText(text)) return null; + return { + status: 'missing', + message: antigravityAuthGuidance(), + }; + } if (agentId === 'reasonix') { if (!isReasonixAuthFailureText(text)) return null; return { diff --git a/apps/daemon/src/runtimes/defs/amr.ts b/apps/daemon/src/runtimes/defs/amr.ts index 98be7f17b..bb48c1083 100644 --- a/apps/daemon/src/runtimes/defs/amr.ts +++ b/apps/daemon/src/runtimes/defs/amr.ts @@ -196,6 +196,11 @@ export const amrAgentDef = { fallbackModels: [] as RuntimeModelOption[], buildArgs: () => ['agent', 'run', '--runtime', 'opencode'], streamFormat: 'acp-json-rpc', + // Vela routes model selection through ACP's `session/set_model` and only + // accepts ids that survived the `vela models` preflight check, so a + // free-text "Custom" id silently fails at spawn. The model picker + // surfaces the live Vela catalog instead. + supportsCustomModel: false, supportsImagePaths: true, // Daemon-process env override for emergency operator pinning. Normal UI // selection comes from the live `vela models` catalog and is preflighted diff --git a/apps/daemon/src/runtimes/defs/antigravity.ts b/apps/daemon/src/runtimes/defs/antigravity.ts new file mode 100644 index 000000000..dd9f95d8f --- /dev/null +++ b/apps/daemon/src/runtimes/defs/antigravity.ts @@ -0,0 +1,247 @@ +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { readFile as fsReadFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { DEFAULT_MODEL_OPTION } from './shared.js'; +import type { RuntimeAgentDef } from '../types.js'; + +// `agy` v1.0.3 still has no `--model` flag (upstream issue #35), but the +// TUI's Switch-Model picker writes the choice to its settings.json, and +// every `agy -p` invocation re-reads that file on startup — verified by +// capturing the `--log-file` line `Propagating selected model override to +// backend: label="<model>"`. So we can route OD's model picker through +// settings.json: when the user picks a concrete model in Settings, the +// daemon writes the label into agy's settings.json right before spawn, +// and the resulting print-mode run uses that model. +// +// Two ids the picker exposes are special: +// - 'default' : leave settings.json untouched, so agy keeps +// whatever the user last picked in its own TUI. +// (Respects user choice when they switch models +// from `agy` directly.) +// - any other id : the literal display label agy expects (e.g. +// "Gemini 3.1 Pro (High)", "Claude Sonnet 4.6 +// (Thinking)"). We persist it before spawn. +// +// `supportsCustomModel: false` because the label set is a server-side +// enum — a typed id agy doesn't recognise resolves to a silent +// `availableModels` cache miss + empty print-mode output, which surfaces +// to the user as a generic "empty response" error. +// +// The 8 model labels mirror what `Switch Model` in agy's TUI lists for +// consumer-tier accounts as of 2026-05-28. The set is small and stable +// enough to ship statically until upstream adds a programmatic +// `agy models` subcommand (also tracked under issue #35). +const ANTIGRAVITY_SETTINGS_PATH = join( + homedir(), + '.gemini', + 'antigravity-cli', + 'settings.json', +); + +export function writeAntigravityModelSelection( + label: string, + settingsPath: string = ANTIGRAVITY_SETTINGS_PATH, +): void { + let existing: Record<string, unknown> = {}; + if (existsSync(settingsPath)) { + try { + const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + existing = parsed as Record<string, unknown>; + } + } catch { + // Corrupt JSON — fall through and rewrite the file from scratch so + // the next spawn starts from a known-good state. + } + } + existing.model = label; + mkdirSync(dirname(settingsPath), { recursive: true }); + writeFileSync(settingsPath, `${JSON.stringify(existing, null, 2)}\n`); +} + +// Per-process serialization for write-settings → spawn → agy-reads +// cycles on antigravity. `~/.gemini/antigravity-cli/settings.json` is +// process-global, so two OD runs that both pick concrete (non-default) +// models can race: run A writes model A, spawn A starts, run B writes +// model B before A's agy has read settings.json — A then executes on +// model B. The daemon serialises non-default antigravity spawns +// through this chain: each acquire awaits the previous release, and +// each release fires only after the spawned agy actually emits +// `Propagating selected model override to backend: label="<X>"` in +// its `--log-file` (which is the upstream signal that settings.json +// has been read). +let antigravityLockChain: Promise<void> = Promise.resolve(); + +export async function acquireAntigravityModelLock(): Promise<() => void> { + const previous = antigravityLockChain; + let release: () => void = () => {}; + antigravityLockChain = new Promise<void>((resolve) => { + release = resolve; + }); + await previous; + return release; +} + +// Visible for tests. Resets the module-level lock chain so a test that +// installed a hanging acquirer can release it without leaking state to +// subsequent test cases. Production code never calls this. +export function _resetAntigravityModelLockForTests(): void { + antigravityLockChain = Promise.resolve(); +} + +export interface WaitForAgyModelOptions { + timeoutMs?: number; + pollIntervalMs?: number; + // Override for tests; production reads the daemon-owned log file path. + readFile?: (path: string) => Promise<string>; + // Override `Date.now` for tests; production uses the wall clock. + now?: () => number; + // Stops polling when fired. Production wires this to `child.once('exit')` + // so the watcher cancels as soon as agy exits — the lock release is + // then driven by the exit handler rather than the helper's return + // value, eliminating the slow-startup race the looper review at + // 263fd2fe7 flagged: if a cold agy takes >timeoutMs to read its + // settings.json, we'd otherwise return false, the caller would + // release the lock, and a concurrent run B could rewrite + // settings.json before A's agy actually read it. + abortSignal?: AbortSignal; +} + +// Polls agy's `--log-file` for the line +// `Propagating selected model override to backend: label="<expectedModel>"` +// which `model_config_manager.go` emits once agy has finished reading +// `~/.gemini/antigravity-cli/settings.json` and sent the model +// override to the upstream backend. Returns true on observed signal, +// false on timeout OR abort. Never throws — a missing log file is +// treated as "not yet seen" so the polling loop keeps retrying until +// either the deadline or the abort signal fires. +// +// IMPORTANT: callers MUST NOT use a `false` return as a "go ahead and +// release the settings.json lock" signal — false means "I gave up +// polling," not "agy definitely didn't read this." Release the lock +// only on (a) a `true` return, OR (b) child exit. See server.ts for +// the wiring. +export async function waitForAgyToReadModel( + logFilePath: string, + expectedModel: string, + options: WaitForAgyModelOptions = {}, +): Promise<boolean> { + const timeoutMs = options.timeoutMs ?? 15_000; + const pollIntervalMs = options.pollIntervalMs ?? 250; + const readFile = + options.readFile ?? ((path: string) => fsReadFile(path, 'utf8')); + const now = options.now ?? Date.now; + const abortSignal = options.abortSignal; + if (abortSignal?.aborted) return false; + const escaped = expectedModel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp( + `Propagating selected model override to backend: label="${escaped}"`, + ); + const deadline = now() + timeoutMs; + while (now() < deadline) { + if (abortSignal?.aborted) return false; + try { + const content = await readFile(logFilePath); + if (pattern.test(content)) return true; + } catch { + // Log file may not have appeared yet; keep polling. + } + if (now() >= deadline) break; + await new Promise<void>((resolve) => { + const timer = setTimeout(resolve, pollIntervalMs); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + abortSignal?.addEventListener('abort', onAbort, { once: true }); + }); + } + return false; +} + +export const antigravityAgentDef = { + id: 'antigravity', + name: 'Antigravity', + bin: 'agy', + versionArgs: ['--version'], + fallbackModels: [ + DEFAULT_MODEL_OPTION, + { id: 'Gemini 3.1 Pro (High)', label: 'Gemini 3.1 Pro (High)' }, + { id: 'Gemini 3.1 Pro (Low)', label: 'Gemini 3.1 Pro (Low)' }, + { id: 'Gemini 3.5 Flash (High)', label: 'Gemini 3.5 Flash (High)' }, + { id: 'Gemini 3.5 Flash (Medium)', label: 'Gemini 3.5 Flash (Medium)' }, + { id: 'Gemini 3.5 Flash (Low)', label: 'Gemini 3.5 Flash (Low)' }, + { + id: 'Claude Sonnet 4.6 (Thinking)', + label: 'Claude Sonnet 4.6 (Thinking)', + }, + { id: 'Claude Opus 4.6 (Thinking)', label: 'Claude Opus 4.6 (Thinking)' }, + { id: 'GPT-OSS 120B (Medium)', label: 'GPT-OSS 120B (Medium)' }, + ], + supportsCustomModel: false, + // We deliberately do NOT opt into `resumesSessionViaCli` / agy's `-c` + // resume flag on follow-up turns. Tested both shapes; `-c` activates + // agy's internal agentic loop (multi-step model retries, tool calls, + // fallback-to-cached-response on tool errors) which can't be steered + // from OD's system-prompt OVERRIDE — even with the strongest wording + // we got an identical byte-for-byte form re-emission on turn 2 when + // turn 1's tool-call retry path returned the cached form response. + // + // Instead we treat agy as a stateless plain adapter like qwen / + // deepseek: every spawn gets the full OD-rendered transcript via + // `buildDaemonTranscript`, and that transcript's prior assistant + // turns are sanitized to strip `<question-form>` markup + form-schema + // JSON fences (see `sanitizePriorAssistantTurnForTranscript` in + // apps/web/src/providers/daemon.ts). The stronger OVERRIDE block + // composed in server.ts gives a second line of defense for weak + // plain-stream models like Gemini 3.5 Flash. + buildArgs: ( + _prompt, + _imagePaths, + _extra = [], + options = {}, + runtimeContext = {}, + ) => { + if (options.model && options.model !== DEFAULT_MODEL_OPTION.id) { + writeAntigravityModelSelection( + options.model, + runtimeContext.antigravitySettingsPath, + ); + } + // We invoke agy via `-p -` (print mode + stdin sentinel), NOT + // `chat -`. Verified against `agy --help` on v1.0.3 — the + // `Available subcommands` list is `changelog / help / install / + // plugin / update`, and `chat` is NOT among them. `-p` is the + // documented print-mode flag (`Short alias for --print`) and + // `agy -p -` reads the prompt from stdin. The looper reviewer + // bot's environment runs a different agy build that may have + // renamed the entry point; until upstream confirms a stable + // headless subcommand (see google-antigravity/antigravity-cli#119) + // and the change actually ships in the auto-update channel that + // packaged OD users get, `-p -` is the contract that actually + // produces a print-mode reply on the installed CLI. + const args: string[] = ['-p']; + // Always opt into `--log-file` when the daemon supplied a path so + // it can post-exit grep for the actual upstream failure shape + // (auth missing vs quota reached vs upstream error) — without it + // the chat surfaces a generic "empty response" because print mode + // never echoes those errors on stdout. See server.ts empty-output + // guard for the consumer. + if (runtimeContext.agentLogFilePath) { + args.push('--log-file', runtimeContext.agentLogFilePath); + } + args.push('-'); + return args; + }, + promptViaStdin: true, + streamFormat: 'plain', + installUrl: 'https://antigravity.google/cli', + docsUrl: 'https://antigravity.google/docs/cli-overview', +} satisfies RuntimeAgentDef; diff --git a/apps/daemon/src/runtimes/registry.ts b/apps/daemon/src/runtimes/registry.ts index b2d2ef278..484062956 100644 --- a/apps/daemon/src/runtimes/registry.ts +++ b/apps/daemon/src/runtimes/registry.ts @@ -18,6 +18,7 @@ import { kiloAgentDef } from './defs/kilo.js'; import { vibeAgentDef } from './defs/vibe.js'; import { deepseekAgentDef } from './defs/deepseek.js'; import { aiderAgentDef } from './defs/aider.js'; +import { antigravityAgentDef } from './defs/antigravity.js'; import { reasonixAgentDef } from './defs/reasonix.js'; import { readLocalAgentProfileDefs as readLocalAgentProfileDefsFromFile } from './local-profiles.js'; import type { RuntimeAgentDef } from './types.js'; @@ -43,6 +44,7 @@ const BASE_AGENT_DEFS: RuntimeAgentDef[] = [ vibeAgentDef, deepseekAgentDef, aiderAgentDef, + antigravityAgentDef, reasonixAgentDef, ]; diff --git a/apps/daemon/src/runtimes/terminal-launch.ts b/apps/daemon/src/runtimes/terminal-launch.ts new file mode 100644 index 000000000..6f07d9c2c --- /dev/null +++ b/apps/daemon/src/runtimes/terminal-launch.ts @@ -0,0 +1,130 @@ +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +// Cross-platform spawn helper for "open a system terminal and run this +// command in it." Used by the antigravity adapter's `oauth-launch` +// endpoint: agy's print mode (`-p`) cannot complete the Google +// Sign-In OAuth flow (the upstream callback page asks the user to +// paste the auth code back into agy, but `-p` has no input field), so +// the user has to run `agy` interactively at least once to populate +// the system keyring. Spawning a terminal from inside OD makes that +// a one-click action instead of a "go open Terminal yourself" task. +// +// Each platform branch uses primitives that are safe against shell +// injection BECAUSE we never accept user input here — the `command` +// argument is always a hard-coded binary name like `agy`. Adding +// caller-supplied flags or env vars to this helper would invalidate +// that guarantee, so the signature is intentionally narrow. + +export type TerminalLaunchResult = + | { ok: true; platform: NodeJS.Platform; via: string } + | { ok: false; platform: NodeJS.Platform; reason: string }; + +// macOS: AppleScript via osascript. Bringing Terminal.app to the +// foreground and creating a new shell that immediately runs the +// command is the canonical macOS pattern (same one VS Code uses for +// "Open in External Terminal"). +async function launchOnDarwin(command: string): Promise<TerminalLaunchResult> { + // `do script "<cmd>"` opens a new Terminal window and runs <cmd> + // in it; activate brings Terminal.app to the foreground so the + // user actually sees the new window. Strict double-quote escaping + // protects us if `command` ever grows special characters (today + // it's just `agy`, so this is belt-and-suspenders). + const safe = command.replace(/"/g, '\\"'); + const script = `tell application "Terminal" to do script "${safe}"\ntell application "Terminal" to activate`; + try { + await execFileAsync('osascript', ['-e', script], { timeout: 5_000 }); + return { ok: true, platform: 'darwin', via: 'osascript' }; + } catch (err) { + return { + ok: false, + platform: 'darwin', + reason: `osascript failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +// Linux: try the Debian/Ubuntu meta-emulator first, then the common +// concrete terminals. Each attempt spawns detached so the terminal +// window's lifetime is independent from the daemon's process group. +// We resolve as soon as the child process starts (not when it exits), +// because terminals like xterm and x-terminal-emulator stay alive for +// the duration of the interactive session — waiting for exit would time +// out and kill the window mid-OAuth-flow. +async function launchOnLinux(command: string): Promise<TerminalLaunchResult> { + // Order matters: x-terminal-emulator is the Debian alternative that + // resolves to whichever terminal the distro chose. Otherwise try the + // common ones. Each requires a slightly different invocation syntax + // (`-e` vs `--` vs `-x`), captured in this table. + const attempts: Array<{ bin: string; args: string[] }> = [ + { bin: 'x-terminal-emulator', args: ['-e', command] }, + { bin: 'gnome-terminal', args: ['--', 'sh', '-c', `${command}; exec $SHELL`] }, + { bin: 'konsole', args: ['-e', command] }, + { bin: 'xfce4-terminal', args: ['-e', command] }, + { bin: 'xterm', args: ['-e', command] }, + ]; + const errors: string[] = []; + for (const { bin, args } of attempts) { + try { + await new Promise<void>((resolve, reject) => { + const child = spawn(bin, args, { detached: true, stdio: 'ignore' }); + child.unref(); + child.once('spawn', resolve); + child.once('error', reject); + }); + return { ok: true, platform: 'linux', via: bin }; + } catch (err) { + errors.push(`${bin}: ${err instanceof Error ? err.message : String(err)}`); + } + } + return { + ok: false, + platform: 'linux', + reason: `no system terminal worked (${errors.join('; ')})`, + }; +} + +// Windows: `cmd /c start "<title>" cmd /k "<command>"` — the outer +// `start` opens a new console window (the first quoted "Open Design" +// is the window title, required by `start`'s positional-arg parser +// when the next token is also quoted), and the inner `cmd /k` keeps +// the window open after the command finishes so the user can see +// OAuth output and finish the flow before the window closes. +async function launchOnWindows(command: string): Promise<TerminalLaunchResult> { + try { + await execFileAsync( + 'cmd.exe', + ['/c', 'start', 'Open Design', 'cmd.exe', '/k', command], + { timeout: 5_000 }, + ); + return { ok: true, platform: 'win32', via: 'cmd /c start' }; + } catch (err) { + return { + ok: false, + platform: 'win32', + reason: `cmd /c start failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +export async function launchAgentInSystemTerminal( + command: string, + platform: NodeJS.Platform = process.platform, +): Promise<TerminalLaunchResult> { + switch (platform) { + case 'darwin': + return launchOnDarwin(command); + case 'linux': + return launchOnLinux(command); + case 'win32': + return launchOnWindows(command); + default: + return { + ok: false, + platform, + reason: `system-terminal launch is not supported on ${platform}`, + }; + } +} diff --git a/apps/daemon/src/runtimes/types.ts b/apps/daemon/src/runtimes/types.ts index b2cf94cda..48eb927bb 100644 --- a/apps/daemon/src/runtimes/types.ts +++ b/apps/daemon/src/runtimes/types.ts @@ -18,8 +18,45 @@ export type RuntimeBuildOptions = { export type RuntimeContext = { cwd?: string; + // True when the current chat run has at least one prior persisted + // assistant message in the same conversation — i.e. this isn't the + // first user turn. Plain-streaming adapters that support a "continue + // the most recent conversation" CLI flag (e.g. `agy -c`) read this to + // decide whether to resume the upstream agent's own session state + // instead of spawning a fresh, context-free turn. Adapters that + // either have no resume flag or recompose history into the prompt + // themselves ignore this field. + hasPriorAssistantTurn?: boolean; + // Daemon-owned path to a temp file where the adapter should write + // its diagnostic log. Today only antigravity consumes this: agy in + // print mode is silent on stdout/stderr for both missing-auth AND + // quota-exhausted failures (verified via `agy --log-file` capture + // during PR #3157), so post-exit log inspection is the only way to + // tell them apart. Adapters that don't have a `--log-file` flag + // ignore this field; the daemon cleans the file up after reading. + agentLogFilePath?: string; + // Override for the antigravity model-selection settings file path. + // Production code leaves this undefined (falls back to the default + // ~/.gemini/antigravity-cli/settings.json). Tests pass a temp path + // so unit assertions against buildArgs do not touch the real home dir. + antigravitySettingsPath?: string; }; +// Marker on a RuntimeAgentDef declaring that the adapter's CLI maintains +// its own multi-turn conversation memory and the daemon should NOT also +// pack the rendered web transcript (the `## user` / `## assistant` blocks +// `buildDaemonTranscript` produces) into the user request. Today only +// `agy -c` qualifies; other plain-stream adapters have no upstream +// session storage and still rely on the daemon-side transcript injection +// for multi-turn coherence. +// +// Without this opt-out, agy with `-c` receives the same prior turn +// twice — once from its own conversation memory, once embedded in the +// composed user request — and the embedded copy includes the literal +// `<question-form>` markup it emitted on turn 1. The model then +// pattern-matches that and re-emits the form on turn 2, looking like +// the discovery loop never breaks. + export type RuntimeCapabilityMap = Record<string, boolean>; export type RuntimeListModels = { @@ -101,6 +138,21 @@ export type RuntimeAgentDef = { | 'opencode-env-content'; installUrl?: string; docsUrl?: string; + // When `false`, the Settings model picker hides the "Custom (fill below)" + // option and the associated free-text input. Use this for agents whose + // CLI does not actually accept a model id (e.g. `agy` v1.0.3 has no + // `--model` flag yet — upstream issue #35 — and the model is chosen + // server-side; AMR routes model selection through ACP's + // `session/set_model` and rejects free-form ids). Defaults to allowing + // custom input (undefined === true) so most adapters keep today's UX. + supportsCustomModel?: boolean; + // When `true`, the daemon trusts this adapter's CLI to carry its own + // multi-turn conversation memory across spawn invocations (today only + // `agy -c`). The chat composer skips the rendered web transcript on + // follow-up turns and sends just the latest user message — see the + // RuntimeContext.hasPriorAssistantTurn comment for why double-context + // is the discovery-form loop's root cause. + resumesSessionViaCli?: boolean; // Optional name of a daemon-process environment variable that overrides // the default model id when the chat run reaches the spawn layer with // null or the synthetic 'default'. Used by adapters whose CLI rejects diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 773d6798e..acb0ed328 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -214,6 +214,8 @@ import { narrowProjectCritiqueOverride } from './critique/spawn-inputs.js'; import { createCopilotStreamHandler } from './copilot-stream.js'; import { createJsonEventStreamHandler } from './json-event-stream.js'; import { + antigravityAuthGuidance, + antigravityQuotaGuidance, classifyAgentAuthFailure, classifyAgentServiceFailure, cursorAuthGuidance, @@ -2408,6 +2410,52 @@ export function telemetryPromptFromRunRequest(message, currentPrompt) { const FORM_ANSWERS_HEADER_RE = /^\s*\[form answers\s+(?:\u2014|-)\s*([^\]\r\n]+)\]/i; +// Aggressive OVERRIDE for weak / medium-strength plain agents (e.g. +// GPT-OSS-120B Medium, Gemini 3.5 Flash) that otherwise echo RULE 1's +// fenced form example back at the user on follow-up turns even when +// they correctly understand the form is answered. Strong models +// (Claude Sonnet 4.6, Gemini 3.1 Pro) already handle a shorter +// OVERRIDE; enumerating the anti-patterns is a no-op for them and a +// strong suppressor for the weaker ones. RULE 1 itself stays in the +// system prompt so turn 1 can still emit a valid form. +// +// Exported so tests pin both the trigger condition and the literal +// anti-patterns we ask the model to skip \u2014 silently weakening the +// list (e.g. dropping the markdown-fence ban) would reintroduce the +// form-echo regression on GPT-OSS / Gemini Flash. +export const FORM_ANSWERED_SYSTEM_OVERRIDE = `## OVERRIDE \u2014 form already answered (this is turn 2 or later) + +The user already submitted their form answers (see # User request below). +RULE 1 documents the turn-1 ask flow; that flow is finished. Treat RULE 1 +as read-only documentation for this turn \u2014 do not execute any of it. + +Forbidden output for this turn: +- A \`<question-form>\` tag of any id, including \`discovery\` or \`task-type\`. +- A markdown \`\`\`json fenced block echoing the form schema or example. +- Form-asking prose such as "Got it \u2014 tell me the following" or + "\u8bf7\u544a\u8bc9\u6211\u4ee5\u4e0b\u4fe1\u606f". +- Narrating fake system events such as "subagents stopped" or + "server restart". + +Required output for this turn: +- Open with a brief prose confirmation of what the brief is. +- Then proceed to RULE 2 (branch on the submitted \`brand\` value) and + RULE 3 (emit the \`<artifact>\` block with the full HTML document). + +`; + +// Smaller override for non-discovery / non-task-type form ids. These +// forms are not artifact-build transitions, so we only need to suppress +// the form re-ask without directing the model toward RULE 2 / RULE 3. +// Exported so tests can pin the literal content independently. +export const FORM_ANSWERED_GENERIC_OVERRIDE = `## OVERRIDE \u2014 form already answered (this is turn 2 or later) + +The user already submitted their form answers (see # User request below). +Do not ask the same form again. Treat the submitted answers as the active +user instruction and respond accordingly. + +`; + function formAnswerTransitionForCurrentPrompt(currentPrompt) { if (typeof currentPrompt !== 'string') return null; const trimmed = currentPrompt.trim(); @@ -2420,9 +2468,16 @@ function formAnswerTransitionForCurrentPrompt(currentPrompt) { '## Latest user turn - form answers submitted', trimmed, '', + // Keep the wording in lock-step with main — the stronger "do not + // emit any `<question-form>`" suppression now lives in the + // system-prompt `FORM_ANSWERED_SYSTEM_OVERRIDE` block, which + // every plain / stream-json adapter sees. Diverging the + // user-request transition string here breaks `chat-route.test + // marks submitted discovery form answers ...` which asserts on + // the exact main wording. `The user has answered the ${formId} form. Do not emit another ${formId} form.`, ]; - if (formId.toLowerCase() === 'discovery') { + if (formId.toLowerCase() === 'discovery' || formId.toLowerCase() === 'task-type') { lines.push( 'Continue with RULE 2 / RULE 3 now. For Branch B answers, build now instead of asking another brief.', ); @@ -2434,13 +2489,30 @@ function formAnswerTransitionForCurrentPrompt(currentPrompt) { return lines.join('\n'); } -export function composeChatUserRequestForAgent(message, currentPrompt) { +export function composeChatUserRequestForAgent( + message, + currentPrompt, + options: { skipTranscript?: boolean } = {}, +) { + // When the adapter resumes its own session (today: `agy -c`), the + // daemon-rendered `## user` / `## assistant` transcript is a duplicate + // of what the upstream CLI already has in memory — and the embedded + // copy carries the literal `<question-form>` markup the agent emitted + // on turn 1, which the model then re-emits on turn 2. Send only the + // latest user turn (`currentPrompt`) in that case; the upstream + // session memory provides the rest. See + // `RuntimeAgentDef.resumesSessionViaCli`. + const skip = options.skipTranscript === true; + const bodySource = skip ? currentPrompt : message; const body = - typeof message === 'string' && message.trim() - ? message + typeof bodySource === 'string' && bodySource.trim() + ? bodySource : '(No extra typed instruction.)'; const transition = formAnswerTransitionForCurrentPrompt(currentPrompt); if (!transition) return body; + if (skip) { + return [transition, body].join('\n\n'); + } return [ transition, '## Full conversation transcript', @@ -4291,6 +4363,42 @@ export async function startServer({ // with shell access to the daemon machine should be the only // one allowed to invoke. Returns the pre-purge stats so the // caller can confirm what they discarded. + // PR #3157: surface the Antigravity OAuth flow as a one-click action + // in the chat's AGENT_AUTH_REQUIRED banner. agy's `-p` print mode + // can't complete the Google Sign-In flow on its own (no input field + // for the auth code), so OD opens a system Terminal running `agy` + // for the user; they finish OAuth there, then retry the chat. The + // endpoint is loopback-gated and only supports antigravity because + // (a) we hardcode `agy` as the command, and (b) opening a new + // Terminal window is a visible side effect we don't want anyone + // hand-rolling for every agent that ships a CLI. + app.post('/api/agents/:agentId/oauth-launch', requireLocalDaemonRequest, async (req, res) => { + const agentId = req.params.agentId; + if (agentId !== 'antigravity') { + return res.status(400).json({ + ok: false, + error: `oauth-launch is only supported for antigravity, got ${agentId}`, + }); + } + try { + const { launchAgentInSystemTerminal } = await import('./runtimes/terminal-launch.js'); + const result = await launchAgentInSystemTerminal('agy'); + if (result.ok) { + return res.json({ ok: true, platform: result.platform, via: result.via }); + } + return res.status(500).json({ + ok: false, + platform: result.platform, + error: result.reason, + }); + } catch (err) { + return res.status(500).json({ + ok: false, + error: String(err), + }); + } + }); + app.post('/api/plugins/events/purge', requireLocalDaemonRequest, async (_req, res) => { try { const { purgePluginEventBuffer } = await import('./plugins/events.js'); @@ -10798,6 +10906,7 @@ export async function startServer({ const userRequestPrompt = composeChatUserRequestForAgent( message, currentPrompt, + { skipTranscript: def.resumesSessionViaCli === true }, ); const clientInstructionPrompt = [researchCommandContract, runContextPrompt, systemPrompt] .map((part) => (typeof part === 'string' ? part.trim() : '')) @@ -10818,6 +10927,18 @@ export async function startServer({ // instructions and request) — see server.ts:9920 composer notes. const ECHO_GUARD = '\n\n(Do not quote, restate, or echo the # Instructions block above in your reply. Begin your response with the answer to the # User request below.)'; + const formAnswerMatch = FORM_ANSWERS_HEADER_RE.exec( + typeof currentPrompt === 'string' ? currentPrompt : '', + ); + const formIdForOverride = formAnswerMatch + ? ((formAnswerMatch[1] || 'form').trim().replace(/[^\w.-]/g, '') || 'form').toLowerCase() + : null; + const formOverride = + formIdForOverride === 'discovery' || formIdForOverride === 'task-type' + ? FORM_ANSWERED_SYSTEM_OVERRIDE + : formIdForOverride !== null + ? FORM_ANSWERED_GENERIC_OVERRIDE + : ''; const promptImagePaths = selectPromptImagePaths( def.id, safeImages, @@ -10825,12 +10946,14 @@ export async function startServer({ ); const composed = [ instructionPrompt - ? `# Instructions (read first)\n\n${instructionPrompt}${cwdHint}${linkedDirsHint}${ECHO_GUARD}\n\n---\n` + ? `# Instructions (read first)\n\n${formOverride}${instructionPrompt}${cwdHint}${linkedDirsHint}${ECHO_GUARD}\n\n---\n` : cwdHint - ? `# Instructions${cwdHint}${linkedDirsHint}${ECHO_GUARD}\n\n---\n` + ? `# Instructions\n\n${formOverride}${cwdHint}${linkedDirsHint}${ECHO_GUARD}\n\n---\n` : linkedDirsHint - ? `# Instructions${linkedDirsHint}${ECHO_GUARD}\n\n---\n` - : '', + ? `# Instructions\n\n${formOverride}${linkedDirsHint}${ECHO_GUARD}\n\n---\n` + : formOverride + ? `# Instructions\n\n${formOverride}${ECHO_GUARD}\n\n---\n` + : '', `# User request\n\n${userRequestPrompt}${attachmentHint}${commentHint}`, promptImagePaths.length ? `\n\n${promptImagePaths.map((p) => `@${p}`).join(' ')}` @@ -11109,12 +11232,69 @@ export async function startServer({ } } + // Plain-streaming adapters that own a "continue most recent + // conversation" CLI flag (today: only `agy -c`) read this signal + // to resume upstream session state on follow-up turns. The query + // matches any persisted assistant message in the same conversation + // EXCEPT the placeholder row this run just inserted (it's still + // `pending` and has no body — counting it as prior would always + // force `-c` on the very first turn). Adapters that don't consume + // this field ignore it. + const hasPriorAssistantTurn = run.conversationId + ? Boolean( + db + .prepare( + `SELECT 1 FROM messages + WHERE conversation_id = ? + AND role = 'assistant' + AND COALESCE(content, '') <> '' + AND id <> COALESCE(?, '') + LIMIT 1`, + ) + .get(run.conversationId, run.assistantMessageId ?? ''), + ) + : false; + + // Antigravity's `agy` is silent on stdout/stderr in print mode for + // both auth-missing and quota-exhausted failures — the actual + // RESOURCE_EXHAUSTED / "not logged in" payload only surfaces in + // its `--log-file`. We allocate a per-run temp path, pipe agy's + // log to it via buildArgs, then read it in the empty-output guard + // to disambiguate the silent-failure cause. Other adapters ignore + // this field. + const agentLogFilePath = + def.id === 'antigravity' + ? path.join(os.tmpdir(), `od-agy-${run.id}.log`) + : undefined; + + // Serialize antigravity spawns whose buildArgs writes a concrete + // model into settings.json. Two concurrent runs with different + // models would otherwise race the file: A writes model A, B writes + // model B, then A's agy reads model B. The lock is acquired BEFORE + // buildArgs (which performs the write) and released asynchronously + // AFTER agy's --log-file confirms the model was propagated. See + // `antigravity.ts` for the chain implementation. + let antigravityModelLockRelease: (() => void) | null = null; + const antigravityConcreteModel = + def.id === 'antigravity' + && typeof agentOptions.model === 'string' + && agentOptions.model.length > 0 + && agentOptions.model !== 'default' + ? agentOptions.model + : null; + if (antigravityConcreteModel) { + const { acquireAntigravityModelLock } = await import( + './runtimes/defs/antigravity.js' + ); + antigravityModelLockRelease = await acquireAntigravityModelLock(); + } + const args = def.buildArgs( composed, safeImages, extraAllowedDirs, agentOptions, - { cwd: effectiveCwd }, + { cwd: effectiveCwd, hasPriorAssistantTurn, agentLogFilePath }, ); // Second-pass budget check that knows about the Windows `.cmd` shim @@ -11429,6 +11609,56 @@ export async function startServer({ windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); run.child = child; + // Schedule release of the antigravity model lock once agy's + // --log-file confirms the chosen model was propagated to the + // backend (the upstream signal that settings.json was read). + // The watcher's `false` return (timeout) deliberately does NOT + // release — looper review at 263fd2fe7 flagged that releasing + // on timeout reopens the slow-cold-start race: a >15s agy + // startup that hadn't yet read settings.json would let run B + // rewrite the file and run A would then read run B's model. + // The exit handler is the canonical fallback that releases the + // lock no matter what (crashed agy, fast exit, etc.) so the + // queue can never starve permanently. + if ( + antigravityModelLockRelease + && antigravityConcreteModel + && agentLogFilePath + ) { + const releaseOnce = (() => { + let fired = false; + return () => { + if (fired) return; + fired = true; + antigravityModelLockRelease?.(); + }; + })(); + const watcherAbort = new AbortController(); + const { waitForAgyToReadModel } = await import( + './runtimes/defs/antigravity.js' + ); + void waitForAgyToReadModel( + agentLogFilePath, + antigravityConcreteModel, + { abortSignal: watcherAbort.signal }, + ) + .then((found) => { + // Only release on TRUE confirmation; a `false` return means + // the watcher ran out of its polling window without seeing + // the propagation line. We hold the lock until child exit + // so a slow-cold-start agy can't be pre-empted by a + // concurrent settings.json rewrite from run B. + if (found) releaseOnce(); + }) + .catch(() => undefined); + child.once('exit', () => { + // Stop the watcher so its pending readFile / setTimeout + // chain does not outlive the run and leak into subsequent + // antigravity spawns (or test cases). + watcherAbort.abort(); + releaseOnce(); + }); + } if (def.promptViaStdin && child.stdin && def.streamFormat !== 'pi-rpc') { // EPIPE from a fast-exiting CLI (bad auth, missing model, exit on // launch) would otherwise surface as an unhandled stream error and @@ -11678,6 +11908,12 @@ export async function startServer({ // plain streams (most other CLIs) we forward raw chunks unchanged so // the browser can append them to the assistant's text buffer. let agentStreamError = null; + // Holds buffered plain-text stdout chunks for agents (currently + // antigravity) where we need to inspect the full output at close + // time before deciding whether to forward it. The auth-prompt guard + // in the close handler suppresses the buffer when the output is an + // OAuth prompt; otherwise the flush below sends the chunks in order. + const plaintextStdoutBuffer: string[] = []; // Tracks whether any stream the run is using actually emitted user- // visible content. Only the streams routed through `sendAgentEvent` // contribute to this flag; ACP sessions and plain stdout streams are @@ -11898,6 +12134,16 @@ export async function startServer({ ); child.stdout.on('data', (chunk) => handler.feed(chunk)); child.on('close', () => handler.flush()); + } else if (def.id === 'antigravity') { + // Buffer stdout until close so the auth-prompt guard can suppress + // the OAuth URL before forwarding it to the client as assistant + // text. agy exits 0 after printing the auth URL on stdout, so the + // chunks would otherwise arrive before the close-time classifier + // detects them as an auth prompt. + child.stdout.on('data', (chunk) => { + noteAgentActivity(); + plaintextStdoutBuffer.push(String(chunk)); + }); } else { child.stdout.on('data', (chunk) => { noteAgentActivity(); @@ -11921,6 +12167,7 @@ export async function startServer({ design.runs.finish(run, 'failed', 1, null); }); child.on('close', async (code, signal) => { + try { clearInactivityWatchdog(); revokeToolToken('child_exit'); unregisterChatAgentEventSink(); @@ -11956,15 +12203,9 @@ export async function startServer({ return design.runs.finish(run, 'failed', code ?? 1, signal ?? null); } } - // Empty-output guard: a clean `code === 0` exit on a stream we are - // tracking, with no error frame and no substantive event, means the - // run silently finished without producing anything visible. That used - // to be marked `succeeded` and rendered as an empty assistant turn — - // see issue #691, where OpenCode runs were ending in ~3s with no - // chat content and no error banner. Surface an explicit failure - // instead so the chat shows a clear reason. ACP sessions and plain - // stdout streams are gated out via `trackingSubstantiveOutput`; - // their success/failure determination lives elsewhere. + // Empty-output guard: a clean `code === 0` exit with no visible + // output means the run silently finished without producing anything. + // Surface an explicit failure so the chat shows a clear reason. if ( code === 0 && !run.cancelRequested && @@ -11991,6 +12232,96 @@ export async function startServer({ )); return design.runs.finish(run, 'failed', code, signal); } + // Plain-stream auth-failure guard: plain adapters (today + // antigravity, deepseek's TUI variants) may exit cleanly with + // visible stdout that's actually an auth prompt — agy prints + // "Authentication required. Please visit the URL to log in: + // <URL>" + "Error: authentication timed out." rather than + // failing with a non-zero exit. Without this guard the chat + // shows that raw prompt as the agent's "reply", and the user + // has no way to actually complete OAuth from inside the chat. + // Override the apparent success with a proper + // AGENT_AUTH_REQUIRED error carrying actionable guidance. + if ( + code === 0 && + !run.cancelRequested && + !trackingSubstantiveOutput && + childStdoutSeen + ) { + const authFailure = classifyAgentAuthFailure( + agentId, + `${agentStderrTail}\n${agentStdoutTail}`, + ); + if (authFailure?.status === 'missing') { + send('error', createSseErrorPayload( + 'AGENT_AUTH_REQUIRED', + authFailure.message ?? `${def.name} authentication required. Please re-authenticate and retry.`, + { retryable: true }, + )); + return design.runs.finish(run, 'failed', 0, signal); + } + } + // Plain-stream empty-output guard: plain agents send raw stdout + // chunks without structured event tracking. Detect auth failures + // and quota / upstream errors when exit 0 but no stdout was + // seen. agy in print mode is silent on stdout/stderr for both + // missing-auth AND quota-exhausted failures; the daemon piped + // agy's `--log-file` to `agentLogFilePath` precisely so this + // guard can grep the upstream error code (RESOURCE_EXHAUSTED 429 + // for quota, "not logged into Antigravity" for auth) and route + // to the right user-facing guidance. + if ( + code === 0 && + !run.cancelRequested && + !trackingSubstantiveOutput && + !childStdoutSeen + ) { + let combinedDetail = `${agentStderrTail}\n${agentStdoutTail}`; + if (def.id === 'antigravity' && agentLogFilePath) { + try { + const logContent = await fs.promises.readFile(agentLogFilePath, 'utf8'); + // Keep the last 8 KB — quota / auth lines all land near the + // tail (after the spawn / model-config preamble). + combinedDetail = `${combinedDetail}\n${logContent.slice(-8192)}`; + } catch { + // Missing log file (agy didn't write it, mounted tmpfs is + // read-only, etc.) is fine — fall through to the generic + // empty-output message. + } + } + const authFailure = classifyAgentAuthFailure(agentId, combinedDetail); + const serviceFailure = !authFailure + ? classifyAgentServiceFailure(combinedDetail) + : null; + const isAntigravityQuota = + def.id === 'antigravity' && serviceFailure === 'RATE_LIMITED'; + // Antigravity-only fallback: if neither classifier matched but + // the run was silent, lean on the empirical observation that + // an empty agy print-mode exit almost always means + // missing-OAuth (the only other silent path is quota, which + // the log-file check above already caught). + const useAntigravityAuthFallback = + !authFailure && !serviceFailure && def.id === 'antigravity'; + const errorCode = + authFailure || useAntigravityAuthFallback + ? 'AGENT_AUTH_REQUIRED' + : isAntigravityQuota + ? 'RATE_LIMITED' + : 'AGENT_EXECUTION_FAILED'; + const msg = authFailure + ? authFailure.message ?? `${def.name} authentication expired. Please re-authenticate and retry.` + : isAntigravityQuota + ? antigravityQuotaGuidance() + : useAntigravityAuthFallback + ? antigravityAuthGuidance() + : `${def.name} returned an empty response. This may indicate an expired session — try re-authenticating the agent.`; + send('error', createSseErrorPayload( + errorCode, + msg, + { retryable: true }, + )); + return design.runs.finish(run, 'failed', 0, signal); + } // ACP agents that don't shut down on stdin.end() (e.g. Devin for // Terminal) are forced to exit via SIGTERM from attachAcpSession after // a clean prompt completion. Without an override, the chat run would @@ -12078,7 +12409,24 @@ export async function startServer({ } catch { /* project-level best-effort */ } })(); } + // Flush buffered plain-text stdout (antigravity) that was not + // suppressed by the auth-prompt guard above. Send each chunk in + // order before finishing so the assistant text arrives before the + // run's `finished` event. + for (const chunk of plaintextStdoutBuffer) { + send('stdout', { chunk }); + } design.runs.finish(run, status, code, signal); + } finally { + // Best-effort cleanup of the per-run agy log file on every close + // path — successful, failed, cancelled, or non-zero exit — so + // /tmp doesn't accumulate one file per Antigravity run. The log + // is read inside the empty-output guard above before this finally + // runs, so the read always happens before the unlink. + if (agentLogFilePath) { + fs.promises.unlink(agentLogFilePath).catch(() => {}); + } + } }); if (writePromptToChildStdin && child.stdin) { const promptInputFormat = def.promptInputFormat ?? 'text'; diff --git a/apps/daemon/tests/chat-route.test.ts b/apps/daemon/tests/chat-route.test.ts index 1de97d95e..50531aa2c 100644 --- a/apps/daemon/tests/chat-route.test.ts +++ b/apps/daemon/tests/chat-route.test.ts @@ -1277,6 +1277,50 @@ process.exit(1); ); }); + it('suppresses Antigravity auth stdout and emits AGENT_AUTH_REQUIRED without an event: stdout delta', async () => { + await withFakeAgent( + 'agy', + ` +const args = process.argv.slice(2); +if (args[0] === '--version') { + console.log('1.107.0-test'); + process.exit(0); +} +// Simulate agy chat - printing the OAuth prompt and exiting 0 +process.stdout.write('Authentication required. Please visit the URL to log in: https://accounts.google.com/o/oauth2/auth?client_id=12345&redirect_uri=antigravity-redirect\\n'); +process.stdout.write('Waiting for authentication (timeout 30s)...\\n'); +process.stdout.write('Error: authentication timed out.\\n'); +process.exit(0); +`, + async () => { + const createResponse = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: 'antigravity', + message: 'hello', + }), + }); + expect(createResponse.status).toBe(202); + const { runId } = await createResponse.json() as { runId: string }; + + const eventsController = new AbortController(); + const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, { + signal: eventsController.signal, + }); + const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED'); + eventsController.abort(); + const statusBody = await waitForRunStatus(baseUrl, runId); + + expect(eventsBody).toContain('event: error'); + expect(eventsBody).toContain('AGENT_AUTH_REQUIRED'); + expect(eventsBody).not.toContain('event: stdout'); + expect(eventsBody).not.toContain('accounts.google.com'); + expect(statusBody.status).toBe('failed'); + }, + ); + }); + it('surfaces Qoder assistant error records through the SSE error channel', async () => { const qoderErrorLine = JSON.stringify({ type: 'assistant', diff --git a/apps/daemon/tests/runtimes/agent-args.test.ts b/apps/daemon/tests/runtimes/agent-args.test.ts index fd115086e..3021632e7 100644 --- a/apps/daemon/tests/runtimes/agent-args.test.ts +++ b/apps/daemon/tests/runtimes/agent-args.test.ts @@ -1,7 +1,9 @@ +import { existsSync, readFileSync } from 'node:fs'; import { test } from 'vitest'; import { - aider, assert, claude, codex, copilot, cursorAgent, deepseek, devin, detectAgents, gemini, join, kilo, kiro, mkdtempSync, opencode, pi, qoder, qwen, rmSync, spawnEnvForAgent, tmpdir, vibe, writeFileSync, chmodSync, + AGENT_DEFS, aider, antigravity, assert, claude, codex, copilot, cursorAgent, deepseek, devin, detectAgents, gemini, join, kilo, kiro, mkdtempSync, opencode, pi, qoder, qwen, rmSync, spawnEnvForAgent, tmpdir, vibe, writeFileSync, chmodSync, } from './helpers/test-helpers.js'; +import { writeAntigravityModelSelection } from '../../src/runtimes/defs/antigravity.js'; import type { TestAgentDef } from './helpers/test-helpers.js'; test('cursor-agent args deliver prompts via stdin without passing a literal dash prompt', () => { @@ -450,6 +452,159 @@ test('qwen args check promptViaStdin, base args, model args and exclude `-` sent assert.equal(withModel.includes('-'), false); }); +// `agy` exposes `-p` (print mode, alias for `--print`) plus `-` as +// the stdin sentinel — confirmed against `agy --help` on v1.0.3, where +// `Available subcommands` is `changelog / help / install / plugin / +// update` (no `chat`). Earlier review iterations pinned `['chat', '-']` +// based on a different agy build the looper reviewer environment uses; +// the installed CLI does not recognise it, exits 0 with no stdout, and +// the daemon would render the resulting empty reply as a "successful" +// agent response — exactly the failure mode the auth/quota guard at +// server.ts ~12090 is meant to catch but for the wrong reason. +test('antigravity pipes prompt via stdin via -p flag (print mode)', () => { + assert.equal(antigravity.bin, 'agy'); + assert.equal(antigravity.streamFormat, 'plain'); + assert.equal(antigravity.promptViaStdin, true); + + const args = antigravity.buildArgs('write hello world', [], [], {}, {}); + assert.deepEqual(args, ['-p', '-']); + + // No `--model` flag exists upstream, so buildArgs argv must stay the + // same regardless of which label the user picks. + // Pass a temp antigravitySettingsPath so buildArgs does not touch the + // real ~/.gemini/antigravity-cli/settings.json during a unit test run. + const settingsDir = mkdtempSync(join(tmpdir(), 'od-agy-argv-')); + try { + const withModel = antigravity.buildArgs('hi', [], [], { + model: 'Gemini 3.1 Pro (High)', + }, { antigravitySettingsPath: join(settingsDir, 'settings.json') }); + assert.equal(withModel.includes('--model'), false); + assert.deepEqual(withModel, ['-p', '-']); + } finally { + rmSync(settingsDir, { recursive: true, force: true }); + } + + // Argv must NOT carry `-c` even on follow-up turns. We tested resume + // mode and found agy's `-c` activates an internal agentic loop (tool + // calls, retries, fallback-to-cached-response) that overrides OD's + // system-prompt OVERRIDE — producing byte-identical form re-emissions + // on turn 2. The stateless path + sanitized transcript injection is + // what actually breaks the discovery loop. Pin both shapes so a + // future contributor doesn't silently reintroduce `-c` and hit the + // same regression. + const followUp = antigravity.buildArgs('next message', [], [], {}, { + hasPriorAssistantTurn: true, + }); + assert.deepEqual(followUp, ['-p', '-']); + assert.equal(followUp.includes('-c'), false); + + const firstTurn = antigravity.buildArgs('first', [], [], {}, { + hasPriorAssistantTurn: false, + }); + assert.deepEqual(firstTurn, ['-p', '-']); + assert.equal(antigravity.resumesSessionViaCli, undefined); + + assert.equal(antigravity.maxPromptArgBytes, undefined); + + // Picker exposes the synthetic Default + the 8 labels agy's TUI + // Switch-Model surfaces for consumer-tier accounts. The set is small + // enough to ship statically; revisit when upstream adds an `agy + // models` subcommand (also tracked under issue #35). + assert.deepEqual( + antigravity.fallbackModels.map((m) => m.id), + [ + 'default', + 'Gemini 3.1 Pro (High)', + 'Gemini 3.1 Pro (Low)', + 'Gemini 3.5 Flash (High)', + 'Gemini 3.5 Flash (Medium)', + 'Gemini 3.5 Flash (Low)', + 'Claude Sonnet 4.6 (Thinking)', + 'Claude Opus 4.6 (Thinking)', + 'GPT-OSS 120B (Medium)', + ], + ); + + // `agy` v1.0.3 has no `--model` flag (upstream #35), no `models` + // subcommand, and no `/model` slash command — a user-typed model id + // would be silently ignored at spawn, looking like an OD bug. The + // settings UI hides the "Custom (fill below)" option when this is + // `false`. Remove this opt-out once upstream wires #35. + assert.equal(antigravity.supportsCustomModel, false); +}); + +// `agy` reads `~/.gemini/antigravity-cli/settings.json` on every CLI +// startup — verified by capturing the `--log-file` line `Propagating +// selected model override to backend: label=…`. Routing OD's model +// picker through that file lets the user choose a model from Settings +// even though agy has no `--model` flag (upstream issue #35). +// +// Two behaviors must hold and are pinned here: +// +// 1. Picking "default" must NOT touch settings.json — respect the +// label the user previously set inside agy's own TUI. +// 2. Picking a concrete label must write that exact string into the +// `model` field while preserving every other key (e.g. +// `trustedWorkspaces` that agy populates on first-run consent). +test('antigravity persists model selection to agy settings.json', () => { + const dir = mkdtempSync(join(tmpdir(), 'od-antigravity-settings-')); + try { + const settingsPath = join(dir, 'settings.json'); + + // 1. Pre-seed the file as agy would after onboarding: a model label + // plus a trustedWorkspaces array the user has already consented to. + writeFileSync( + settingsPath, + JSON.stringify( + { + model: 'GPT-OSS 120B (Medium)', + trustedWorkspaces: ['/tmp/od-project'], + }, + null, + 2, + ), + ); + + // 2. Write a new label and assert the model swap + trusted list intact. + writeAntigravityModelSelection('Gemini 3.1 Pro (High)', settingsPath); + const after = JSON.parse(readFileSync(settingsPath, 'utf8')); + assert.equal(after.model, 'Gemini 3.1 Pro (High)'); + assert.deepEqual(after.trustedWorkspaces, ['/tmp/od-project']); + + // 3. When the file doesn't exist (fresh install before onboarding), + // we must create it rather than crash the spawn pipeline. + const freshPath = join(dir, 'fresh', 'settings.json'); + writeAntigravityModelSelection('Claude Sonnet 4.6 (Thinking)', freshPath); + assert.ok(existsSync(freshPath)); + assert.equal( + JSON.parse(readFileSync(freshPath, 'utf8')).model, + 'Claude Sonnet 4.6 (Thinking)', + ); + + // 4. When the existing file is corrupt JSON, we must rewrite it from + // scratch instead of leaving agy with an unparseable settings file. + const corruptPath = join(dir, 'corrupt-settings.json'); + writeFileSync(corruptPath, '{not valid json'); + writeAntigravityModelSelection('Gemini 3.5 Flash (Low)', corruptPath); + const recovered = JSON.parse(readFileSync(corruptPath, 'utf8')); + assert.equal(recovered.model, 'Gemini 3.5 Flash (Low)'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// AMR routes model selection through ACP `session/set_model` and only +// accepts ids that survive the live `vela models` preflight, so a free +// text id silently fails at spawn. Same custom-model opt-out shape as +// antigravity — the declarative `supportsCustomModel: false` on the +// def is the single source of truth the settings UI consults, and the +// fallback "Custom" item should not appear in the model picker. +test('amr opts out of the Custom-model picker option', () => { + const amr = AGENT_DEFS.find((a) => a.id === 'amr'); + assert.ok(amr, 'amr def must remain registered'); + assert.equal(amr.supportsCustomModel, false); +}); + test('kiro fetchModels falls back to fallbackModels when detection fails', async () => { // fetchModels rejects when the binary doesn't exist; the daemon's // probe() catches this and uses fallbackModels instead. diff --git a/apps/daemon/tests/runtimes/antigravity-model-lock.test.ts b/apps/daemon/tests/runtimes/antigravity-model-lock.test.ts new file mode 100644 index 000000000..aaacde929 --- /dev/null +++ b/apps/daemon/tests/runtimes/antigravity-model-lock.test.ts @@ -0,0 +1,263 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + _resetAntigravityModelLockForTests, + acquireAntigravityModelLock, + waitForAgyToReadModel, +} from '../../src/runtimes/defs/antigravity.js'; + +afterEach(() => { + _resetAntigravityModelLockForTests(); +}); + +describe('acquireAntigravityModelLock', () => { + // The lock chain is the per-process serialization that protects + // `~/.gemini/antigravity-cli/settings.json` from concurrent + // non-default model writes. Two concurrent spawns must not both + // write the file before the first one's agy has actually read it — + // otherwise the first run executes on the second run's model. + // Pin both the ordering (B does not enter until A releases) AND + // the no-deadlock contract (releasing A unblocks B without manual + // intervention). + it('serializes concurrent acquirers — second waits for first release', async () => { + const events: string[] = []; + + const releaseA = await acquireAntigravityModelLock(); + events.push('A-acquired'); + + // Kick off B in parallel — it should NOT acquire until A releases. + const bPromise = acquireAntigravityModelLock().then((release) => { + events.push('B-acquired'); + return release; + }); + + // Yield to the event loop several times so B has every chance to + // resolve early if the serialization were broken. + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } + expect(events).toEqual(['A-acquired']); + + releaseA(); + const releaseB = await bPromise; + expect(events).toEqual(['A-acquired', 'B-acquired']); + + releaseB(); + }); + + // Three+ concurrent acquirers should FIFO through the chain. A + // future refactor that drops the awaited `previous` reference would + // let later acquirers leapfrog earlier ones, which is exactly the + // race we're guarding against. + it('FIFOs three concurrent acquirers', async () => { + const events: string[] = []; + const releaseA = await acquireAntigravityModelLock(); + events.push('A-acquired'); + + const bPromise = acquireAntigravityModelLock().then((rel) => { + events.push('B-acquired'); + return rel; + }); + const cPromise = acquireAntigravityModelLock().then((rel) => { + events.push('C-acquired'); + return rel; + }); + + await new Promise((resolve) => setImmediate(resolve)); + expect(events).toEqual(['A-acquired']); + + releaseA(); + const releaseB = await bPromise; + expect(events).toEqual(['A-acquired', 'B-acquired']); + + releaseB(); + const releaseC = await cPromise; + expect(events).toEqual(['A-acquired', 'B-acquired', 'C-acquired']); + + releaseC(); + }); +}); + +describe('waitForAgyToReadModel', () => { + // The polling helper resolves true when agy's --log-file matches the + // upstream `Propagating selected model override to backend: + // label="<X>"` line, which is the signal that settings.json was + // read. This is the lock-release trigger in the spawn pipeline — + // breaking the pattern match would either release the lock too + // early (concurrent races re-emerge) or never release it (queue + // starvation). + it('resolves true when the expected propagation line appears', async () => { + let now = 0; + const reads: string[] = []; + let calls = 0; + const result = await waitForAgyToReadModel( + '/fake/log/path', + 'Gemini 3.1 Pro (High)', + { + timeoutMs: 5_000, + pollIntervalMs: 10, + now: () => now, + readFile: async (path) => { + reads.push(path); + calls++; + if (calls < 3) { + return 'I0529 boot ...\nE0529 still loading ...\n'; + } + return ( + 'I0529 model_config_manager.go:157] Propagating selected model ' + + 'override to backend: label="Gemini 3.1 Pro (High)"\n' + ); + }, + }, + ); + expect(result).toBe(true); + expect(reads.every((p) => p === '/fake/log/path')).toBe(true); + expect(calls).toBeGreaterThanOrEqual(3); + }); + + // Model labels carry parentheses and slashes ("Gemini 3.5 Flash + // (Medium)", "GPT-OSS 120B (Medium)") — the regex must escape regex + // metacharacters so the literal label matches. A naive + // `new RegExp(label)` would interpret the parens as a capture group + // and silently match the wrong model. + it('escapes regex metacharacters in the expected model label', async () => { + const log = + 'I0529 model_config_manager.go] Propagating selected model ' + + 'override to backend: label="GPT-OSS 120B (Medium)"'; + const result = await waitForAgyToReadModel( + '/fake/log', + 'GPT-OSS 120B (Medium)', + { + timeoutMs: 100, + pollIntervalMs: 5, + readFile: async () => log, + }, + ); + expect(result).toBe(true); + }); + + // Must not match a DIFFERENT model just because the prefix overlaps. + // Concurrent runs A (Gemini Pro) and B (Gemini Pro Low) could + // otherwise have B's lock released by A's propagation line. + it('does not match a different model label that shares a prefix', async () => { + const log = + 'I0529 model_config_manager.go] Propagating selected model ' + + 'override to backend: label="Gemini 3.1 Pro (Low)"'; + const result = await waitForAgyToReadModel( + '/fake/log', + 'Gemini 3.1 Pro (High)', + { + timeoutMs: 30, + pollIntervalMs: 5, + readFile: async () => log, + }, + ); + expect(result).toBe(false); + }); + + // Missing / unreadable log file (agy hasn't created it yet, or a + // restricted tmpfs) must not throw — the polling loop swallows the + // error and keeps retrying. Without this, a transient read failure + // would propagate up and crash the spawn pipeline. + it('swallows read errors and returns false on timeout', async () => { + const result = await waitForAgyToReadModel( + '/nonexistent/log', + 'Gemini 3.1 Pro (High)', + { + timeoutMs: 30, + pollIntervalMs: 5, + readFile: async () => { + throw new Error('ENOENT: file not found'); + }, + }, + ); + expect(result).toBe(false); + }); + + // The `false` return must NOT be conflated with "agy definitely did + // not read the model" — looper review at 263fd2fe7 caught a release- + // on-timeout regression that re-opened the model-stealing race. + // server.ts now only releases the lock on a TRUE return; this test + // pins the helper's contract: "give up polling after timeoutMs and + // return false" without any side effect that would imply + // confirmation. + it('returns false when the propagation line never appears within timeout', async () => { + // Time-travelling clock: each `now()` call advances by 10ms so + // the polling loop's deadline check passes naturally without + // wall-clock sleeps. The simulated log NEVER matches. + let now = 0; + const result = await waitForAgyToReadModel( + '/fake/log', + 'Gemini 3.1 Pro (High)', + { + timeoutMs: 50, + pollIntervalMs: 1, + now: () => { + now += 10; + return now; + }, + readFile: async () => + 'I0529 boot ...\nI0529 still waiting on backend ...\n', + }, + ); + expect(result).toBe(false); + }); + + // The abort signal lets the caller (server.ts spawn pipeline) stop + // polling when the child process exits — without it, a still- + // polling watcher would leak past the run's lifetime and could be + // matched by a later concurrent agy run's log content, releasing + // the wrong lock. + it('returns false immediately when the abort signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + let calls = 0; + const result = await waitForAgyToReadModel( + '/fake/log', + 'Gemini 3.1 Pro (High)', + { + timeoutMs: 10_000, + pollIntervalMs: 1, + abortSignal: controller.signal, + readFile: async () => { + calls++; + return ''; + }, + }, + ); + expect(result).toBe(false); + // Never even entered the poll body because the helper short- + // circuited on the already-aborted signal. + expect(calls).toBe(0); + }); + + // Aborting MID-POLL must wake the helper from its setTimeout so + // the caller is not blocked waiting out the rest of pollIntervalMs. + it('wakes from setTimeout when abort signal fires during polling', async () => { + const controller = new AbortController(); + // Fire the abort after the first read returns no match. + let calls = 0; + const startedAt = Date.now(); + const result = await waitForAgyToReadModel( + '/fake/log', + 'Gemini 3.1 Pro (High)', + { + timeoutMs: 10_000, + // Long poll interval — if the helper waited it out we'd see + // ~500ms elapsed in test. Abort should cut that short. + pollIntervalMs: 500, + abortSignal: controller.signal, + readFile: async () => { + calls++; + if (calls === 1) { + setTimeout(() => controller.abort(), 10); + } + return ''; + }, + }, + ); + const elapsed = Date.now() - startedAt; + expect(result).toBe(false); + expect(elapsed).toBeLessThan(450); + }); +}); diff --git a/apps/daemon/tests/runtimes/env-and-detection.test.ts b/apps/daemon/tests/runtimes/env-and-detection.test.ts index 75ec6bc26..82cbf78fd 100644 --- a/apps/daemon/tests/runtimes/env-and-detection.test.ts +++ b/apps/daemon/tests/runtimes/env-and-detection.test.ts @@ -765,6 +765,70 @@ test('Cursor auth matcher covers current unauthenticated Cursor error records', assert.equal(isCursorAuthFailureText('Error: [unauthenticated] Error'), true); }); +// agy's print mode (`-p -`) exits with code 0 but emits one of these +// shapes when the keyring entry is missing or expired. Without the +// matcher, the daemon treats this as a successful turn and shows the +// raw OAuth URL as the agent's "reply" — but the user has no way to +// complete OAuth from inside chat (agy `-p` has no input field to +// paste the auth code into). The matcher converts each shape into +// AGENT_AUTH_REQUIRED with actionable guidance. +test('antigravity auth matcher covers agy print-mode + log-file auth signals', async () => { + const { isAntigravityAuthFailureText, antigravityAuthGuidance, classifyAgentAuthFailure } = + await import('../../src/runtimes/auth.js'); + + // print-mode stdout shape — user-visible + assert.equal( + isAntigravityAuthFailureText( + 'Authentication required. Please visit the URL to log in: https://accounts.google.com/o/oauth2/auth?…', + ), + true, + ); + assert.equal( + isAntigravityAuthFailureText('Waiting for authentication (timeout 30s)...\nError: authentication timed out.'), + true, + ); + + // `agy --log-file` shape — surfaces in stderr / log-file probes + assert.equal( + isAntigravityAuthFailureText( + 'E log.go:398] Failed to poll ListExperiments: error getting token source: You are not logged into Antigravity.', + ), + true, + ); + + // Negative: prose mentioning "authentication" must not false-fire + assert.equal( + isAntigravityAuthFailureText('I added two-factor authentication to the login flow.'), + false, + ); + assert.equal(isAntigravityAuthFailureText(''), false); + + // Classifier wires the agy detector to the user-actionable guidance + // text so the chat surfaces a re-auth message rather than the raw + // OAuth URL the user can't act on from inside OD. + const cls = classifyAgentAuthFailure( + 'antigravity', + 'Authentication required. Please visit the URL to log in: https://example', + ); + assert.ok(cls); + assert.equal(cls.status, 'missing'); + assert.equal(cls.message, antigravityAuthGuidance()); + assert.ok( + antigravityAuthGuidance().includes('open a terminal and run `agy` once'), + 'guidance must tell the user exactly what one-time command to run', + ); + assert.ok( + antigravityAuthGuidance().includes('keyring'), + 'guidance must mention the keyring so users understand it persists', + ); + + // Non-matching text → null (don't claim auth failure on unrelated errors) + assert.equal( + classifyAgentAuthFailure('antigravity', 'rate limit exceeded'), + null, + ); +}); + // Windows env-var names are case-insensitive at the kernel level, but // spreading process.env into a plain object loses Node's case-insensitive // accessor — a `Anthropic_Api_Key` key would survive a literal diff --git a/apps/daemon/tests/runtimes/helpers/test-helpers.ts b/apps/daemon/tests/runtimes/helpers/test-helpers.ts index 8648d5d57..3c682a1ab 100644 --- a/apps/daemon/tests/runtimes/helpers/test-helpers.ts +++ b/apps/daemon/tests/runtimes/helpers/test-helpers.ts @@ -87,6 +87,7 @@ export const qoder = requireAgent('qoder'); export const qwen = requireAgent('qwen'); export const opencode = requireAgent('opencode'); export const aider = requireAgent('aider'); +export const antigravity = requireAgent('antigravity'); export const deepseekMaxPromptArgBytes = (() => { assert.ok( deepseek.maxPromptArgBytes !== undefined, diff --git a/apps/daemon/tests/runtimes/terminal-launch.test.ts b/apps/daemon/tests/runtimes/terminal-launch.test.ts new file mode 100644 index 000000000..1f9217834 --- /dev/null +++ b/apps/daemon/tests/runtimes/terminal-launch.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { launchAgentInSystemTerminal } from '../../src/runtimes/terminal-launch.js'; + +describe('launchAgentInSystemTerminal', () => { + // Surfaces a `system-terminal launch is not supported on ${platform}` + // reason on unsupported platforms so the chat's auth banner can fall + // back to the text-only guidance instead of throwing. Pins the + // shape the web side asserts on (`{ ok: false, reason: string }`). + it('rejects unsupported platforms with a structured failure', async () => { + // `aix` is one of Node's `process.platform` values but not one any + // OD user would actually run on. A typo'd / future platform should + // surface the same shape. + const result = await launchAgentInSystemTerminal('agy', 'aix'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.platform).toBe('aix'); + expect(result.reason).toContain('not supported'); + expect(result.reason).toContain('aix'); + }); +}); diff --git a/apps/daemon/tests/telemetry-message-finalization.test.ts b/apps/daemon/tests/telemetry-message-finalization.test.ts index eba0d5c26..914abff50 100644 --- a/apps/daemon/tests/telemetry-message-finalization.test.ts +++ b/apps/daemon/tests/telemetry-message-finalization.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { + FORM_ANSWERED_GENERIC_OVERRIDE, composeChatUserRequestForAgent, createFinalizedMessageTelemetryReporter, shouldReportRunCompletedFromMessage, @@ -78,17 +79,150 @@ describe('Langfuse message finalization gate', () => { ); }); - it('keeps non-discovery form answers active without forcing the build transition', () => { + it('task-type form answers trigger the build transition just like discovery', () => { const prompt = composeChatUserRequestForAgent( '## user\ninitial brief', '[form answers - task-type]\n- taskType: Slide deck', ); expect(prompt).toContain('The user has answered the task-type form.'); + expect(prompt).toContain('build now instead of asking another brief'); + expect(prompt).not.toContain('Treat these form answers as the active user turn'); + }); + + it('unknown form ids get the generic transition without forcing the build', () => { + const prompt = composeChatUserRequestForAgent( + '## user\ninitial brief', + '[form answers - preferences]\n- theme: dark', + ); + + expect(prompt).toContain('The user has answered the preferences form.'); expect(prompt).toContain('Treat these form answers as the active user turn'); expect(prompt).not.toContain('build now instead of asking another brief'); }); + // `agy -c` carries its own conversation memory, so packing the + // rendered web transcript (the `## user` / `## assistant` blocks) + // into the user request duplicates context the upstream CLI already + // has — AND the embedded copy includes the literal `<question-form>` + // markup the agent emitted on turn 1, which the model then re-emits + // on turn 2, looking like the discovery form loop never breaks. + // With `skipTranscript: true`, only the latest user turn ships and + // the misleading "## Full conversation transcript" header is dropped. + it('drops the transcript and transcript header when skipTranscript is true', () => { + const currentPrompt = [ + '[form answers — discovery]', + '- output: Dashboard / tool UI', + '- brand: Pick a direction for me [value: pick_direction]', + ].join('\n'); + const transcript = [ + '## user', + '初始需求', + '', + '## assistant', + '<question-form id="discovery">…</question-form>', + '', + '## user', + currentPrompt, + ].join('\n'); + + const prompt = composeChatUserRequestForAgent(transcript, currentPrompt, { + skipTranscript: true, + }); + + // The form-answer transition still fires — that drives RULE 2 / 3. + expect(prompt).toContain('The user has answered the discovery form.'); + // The latest user turn is preserved verbatim. + expect(prompt).toContain(currentPrompt); + // The transcript header is dropped — it was misleading because the + // body underneath is no longer a transcript. + expect(prompt).not.toContain('## Full conversation transcript'); + // The prior assistant turn's `<question-form>` markup must NOT + // leak in — that's the form-loop regression we're guarding. + // (The transition block legitimately mentions "<question-form>" + // in prose, so the assertion targets the opening tag the prior + // turn carried, not the bare substring.) + expect(prompt).not.toContain('<question-form id="discovery">'); + expect(prompt).not.toContain('## assistant'); + }); + + // The aggressive form-answered OVERRIDE block is what tells weak + // plain agents (GPT-OSS-120B Medium, Gemini 3.5 Flash) to skip + // RULE 1's form example on follow-up turns. We pin the trigger + // condition AND the specific anti-patterns the literal carries, + // because silently weakening any of them — e.g. dropping the + // markdown-fence ban or the "subagents stopped" hallucination ban — + // reintroduces the form-echo regression we hit in PR #3157 on GPT-OSS. + it('FORM_ANSWERED_SYSTEM_OVERRIDE pins the anti-patterns weak plain agents need spelled out', async () => { + const { FORM_ANSWERED_SYSTEM_OVERRIDE } = await import('../src/server.js'); + + // Headline must call out that this is a follow-up turn, not turn 1. + expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('## OVERRIDE — form already answered'); + expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('turn 2 or later'); + // RULE 1 stays in the prompt so turn 1 can still emit a valid form; + // OVERRIDE just demotes it to documentation for follow-up turns. + expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('Treat RULE 1\nas read-only documentation'); + + // Forbidden anti-patterns observed in real captures: + expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('`<question-form>` tag of any id'); + expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('```json fenced block'); + expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('Form-asking prose'); + expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('"subagents stopped"'); + + // Required path: route to RULE 2 / RULE 3 so the model still + // emits the `<artifact>` block on the same turn. + expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('RULE 2'); + expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('RULE 3'); + expect(FORM_ANSWERED_SYSTEM_OVERRIDE).toContain('`<artifact>`'); + }); + + it('FORM_ANSWERED_GENERIC_OVERRIDE is used for non-discovery/task-type form ids', () => { + // Non-build-transition forms should get a smaller override that only + // suppresses re-asking — not the RULE 2 / RULE 3 / artifact directive. + expect(FORM_ANSWERED_GENERIC_OVERRIDE).toContain('## OVERRIDE — form already answered'); + expect(FORM_ANSWERED_GENERIC_OVERRIDE).toContain('turn 2 or later'); + expect(FORM_ANSWERED_GENERIC_OVERRIDE).toContain('Do not ask the same form again'); + // Must NOT contain the artifact-build directive that only applies to + // discovery / task-type — sending it for an unrelated form id would give + // the model contradictory instructions. + expect(FORM_ANSWERED_GENERIC_OVERRIDE).not.toContain('RULE 2'); + expect(FORM_ANSWERED_GENERIC_OVERRIDE).not.toContain('RULE 3'); + expect(FORM_ANSWERED_GENERIC_OVERRIDE).not.toContain('`<artifact>`'); + }); + + it('FORM_ANSWERED_SYSTEM_OVERRIDE only fires through composeChatUserRequestForAgent\'s transition gate', async () => { + // Defense-in-depth check: a turn that is NOT a form-answer follow-up + // (no `[form answers — …]` header in `currentPrompt`) must not + // surface any of the OVERRIDE language, even when `message` carries + // a transcript that mentions question-form. Otherwise we'd suppress + // the legitimate turn-1 form ask. + const transcript = '## user\n初始需求\n\n## assistant\n<question-form id="discovery">...</question-form>'; + const currentPrompt = '继续做点修改'; + + const prompt = composeChatUserRequestForAgent(transcript, currentPrompt); + expect(prompt).not.toContain('OVERRIDE — form already answered'); + expect(prompt).not.toContain('Treat RULE 1'); + }); + + it('also drops the transcript on a non-form turn when skipTranscript is true', () => { + // Without a form-answer transition, the function previously returned + // `message` verbatim. With skipTranscript the body must come from + // `currentPrompt` instead so a follow-up `agy -c` turn doesn't carry + // the duplicate transcript. + const transcript = '## user\n第一轮\n\n## assistant\n回答\n\n## user\n第二轮 follow-up'; + const currentPrompt = '第二轮 follow-up'; + + const skipped = composeChatUserRequestForAgent(transcript, currentPrompt, { + skipTranscript: true, + }); + expect(skipped).toBe(currentPrompt); + + // Default behavior unchanged (backward compatibility for every + // adapter that doesn't set resumesSessionViaCli). + const kept = composeChatUserRequestForAgent(transcript, currentPrompt); + expect(kept).toBe(transcript); + }); + it('invokes Langfuse reporting once when the final message write is marked', () => { const run = { id: 'run-1', diff --git a/apps/web/public/agent-icons/antigravity.svg b/apps/web/public/agent-icons/antigravity.svg new file mode 100644 index 000000000..907665a8a --- /dev/null +++ b/apps/web/public/agent-icons/antigravity.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -1 28 28"><path d="M21.751 22.607c1.34 1.005 3.35.335 1.508-1.508C17.73 15.74 18.904 1 12.037 1 5.17 1 6.342 15.74.815 21.1c-2.01 2.009.167 2.511 1.507 1.506 5.192-3.517 4.857-9.714 9.715-9.714 4.857 0 4.522 6.197 9.714 9.715z" fill="url(#ag)"/><defs><linearGradient id="ag" x1="2" y1="12" x2="22" y2="12" gradientUnits="userSpaceOnUse"><stop stop-color="#4285F4"/><stop offset=".33" stop-color="#EA4335"/><stop offset=".66" stop-color="#FBBC04"/><stop offset="1" stop-color="#34A853"/></linearGradient></defs></svg> diff --git a/apps/web/src/components/AgentIcon.tsx b/apps/web/src/components/AgentIcon.tsx index 7c0458c50..6b80e0b0a 100644 --- a/apps/web/src/components/AgentIcon.tsx +++ b/apps/web/src/components/AgentIcon.tsx @@ -31,6 +31,7 @@ const ICON_EXT: Record<string, 'svg' | 'png'> = { kiro: 'svg', kilo: 'svg', vibe: 'svg', + antigravity: 'svg', aider: 'png', 'trae-cli': 'png', devin: 'png', diff --git a/apps/web/src/components/ChatPane.tsx b/apps/web/src/components/ChatPane.tsx index 03dace046..ae9afa16a 100644 --- a/apps/web/src/components/ChatPane.tsx +++ b/apps/web/src/components/ChatPane.tsx @@ -281,6 +281,12 @@ interface Props { onOpenSettings?: (section?: SettingsSection) => void; onOpenAmrSettings?: () => void; onSwitchToAmrAndRetry?: (failedAssistant: ChatMessage) => void; + // PR #3157: Antigravity's `agy -p` can't complete OAuth on its own, + // so the auth banner offers a "Sign in via terminal" button that + // POSTs to /api/agents/antigravity/oauth-launch. Handler resolves + // after the daemon kicks off `osascript`/`x-terminal-emulator`/ + // `cmd /c start` so the UI can disable the button while in flight. + onLaunchAntigravityOauth?: () => Promise<void>; // Same dialog, but landing on the External MCP tab. Forwarded to the // composer's `/mcp` slash and MCP picker button. onOpenMcpSettings?: () => void; @@ -377,6 +383,7 @@ export function ChatPane({ onOpenSettings, onOpenAmrSettings, onSwitchToAmrAndRetry, + onLaunchAntigravityOauth, onOpenMcpSettings, connectRepoNeeded, githubConnected, @@ -1192,6 +1199,26 @@ export function ChatPane({ > {t('chat.amrError.authorizeCta')} </button> + ) : runFailureUi.primaryAction === 'launch-terminal-auth' ? ( + <button + type="button" + className="chat-error-action" + onClick={() => { + onLaunchAntigravityOauth?.(); + }} + > + {t('chat.antigravityError.launchTerminalCta')} + </button> + ) : runFailureUi.primaryAction === 'launch-terminal-switch-model' ? ( + <button + type="button" + className="chat-error-action" + onClick={() => { + onLaunchAntigravityOauth?.(); + }} + > + {t('chat.antigravityError.launchSwitchModelCta')} + </button> ) : runFailureUi.primaryAction === 'recharge' ? ( <button type="button" diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index 7696ebd25..e5c317a3c 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -2989,6 +2989,29 @@ export function ProjectView({ }, [currentConversationActionDisabled, onModeChange, onAgentChange, onOpenAmrSettings], ); + // PR #3157: Antigravity's `agy -p` cannot complete OAuth on its own, + // so the auth banner offers a one-click "Sign in via terminal" + // button that POSTs to the daemon. The daemon opens a system + // Terminal running `agy` (osascript / x-terminal-emulator / + // `cmd /c start`); the user finishes Google sign-in there and then + // clicks Retry to redo the chat run. We don't auto-retry because + // the OAuth completion happens externally with no reliable signal + // back to the chat — the secondary Retry button on the same banner + // covers the manual case. + const handleLaunchAntigravityOauth = useCallback(async () => { + try { + const { launchAntigravityOauth } = await import('../providers/daemon'); + const result = await launchAntigravityOauth(); + if (!result.ok) { + // Surface the daemon-side reason so the user knows whether + // the spawn failed because of missing osascript / unsupported + // platform / etc. instead of silently swallowing it. + console.warn('[antigravity] oauth-launch failed:', result.error); + } + } catch (err) { + console.warn('[antigravity] oauth-launch threw:', err); + } + }, []); // Poll the AMR login status while a retry is armed, rather than only reacting // to the AmrLoginPill's status event — the user may close Settings (which // unmounts the pill and stops its polling) before finishing sign-in in the @@ -4467,6 +4490,7 @@ export function ProjectView({ onOpenSettings={onOpenSettings} onOpenAmrSettings={onOpenAmrSettings} onSwitchToAmrAndRetry={handleSwitchToAmrAndRetry} + onLaunchAntigravityOauth={handleLaunchAntigravityOauth} onOpenMcpSettings={onOpenMcpSettings} connectRepoNeeded={connectRepoNeeded} githubConnected={githubConnected} diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index d75dd6c30..90f7cbb75 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -2071,7 +2071,12 @@ export function SettingsDialog({ if (!hasModels && !hasReasoning) return null; const choice = cfg.agentModels?.[selected.id] ?? {}; const knownModelIds = selected.models?.map((m) => m.id) ?? []; - const allowCustomModel = selected.id !== 'amr'; + // Adapters opt out via `supportsCustomModel: false` on their + // RuntimeAgentDef when their CLI has no `--model` flag (Antigravity, + // upstream issue #35) or when free-text ids silently fail at spawn + // (AMR routes through ACP `session/set_model` and validates against + // a live catalog). Undefined === allow, matching today's UX. + const allowCustomModel = selected.supportsCustomModel !== false; const configuredModel = typeof choice.model === 'string' && choice.model ? choice.model diff --git a/apps/web/src/i18n/locales/ar.ts b/apps/web/src/i18n/locales/ar.ts index c6581e4a0..6bfc4cf1f 100644 --- a/apps/web/src/i18n/locales/ar.ts +++ b/apps/web/src/i18n/locales/ar.ts @@ -13,6 +13,8 @@ export const ar: Dict = { 'chat.amrError.balanceMessage': 'نفد رصيد AMR الخاص بك. اشحن للاستمرار في هذه المهمة.', 'chat.amrError.authorizeCta': 'تفويض وإعادة المحاولة', 'chat.amrError.rechargeCta': 'شحن AMR', + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': 'نسخ أمر التثبيت', 'plugins.actions.copyPluginId': 'نسخ معرّف الإضافة', 'plugins.actions.copyReadmeBadge': 'نسخ شارة README', diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index 1c2ca5c30..848774084 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -13,6 +13,8 @@ export const de: Dict = { 'chat.amrError.balanceMessage': 'Dein AMR-Guthaben ist aufgebraucht. Lade auf, um diesen Lauf fortzusetzen.', 'chat.amrError.authorizeCta': 'Autorisieren und wiederholen', 'chat.amrError.rechargeCta': 'AMR aufladen', + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': 'Installationsbefehl kopieren', 'plugins.actions.copyPluginId': 'Plugin-ID kopieren', 'plugins.actions.copyReadmeBadge': 'README-Badge kopieren', diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 0be98356d..786abe821 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -11,6 +11,8 @@ export const en: Dict = { 'chat.amrError.balanceMessage': "Your AMR balance has run out. Top up to keep this run going.", 'chat.amrError.authorizeCta': "Authorize & retry", 'chat.amrError.rechargeCta': "Top up AMR", + 'chat.antigravityError.launchTerminalCta': "Sign in via terminal", + 'chat.antigravityError.launchSwitchModelCta': "Switch model in terminal", 'common.cancel': 'Cancel', 'common.save': 'Save', 'common.close': 'Close', diff --git a/apps/web/src/i18n/locales/es-ES.ts b/apps/web/src/i18n/locales/es-ES.ts index 3c8f736d4..9e9ea71cf 100644 --- a/apps/web/src/i18n/locales/es-ES.ts +++ b/apps/web/src/i18n/locales/es-ES.ts @@ -13,6 +13,8 @@ export const esES: Dict = { 'chat.amrError.balanceMessage': 'Tu saldo de AMR se ha agotado. Recarga para continuar esta ejecución.', 'chat.amrError.authorizeCta': 'Autorizar y reintentar', 'chat.amrError.rechargeCta': 'Recargar AMR', + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': 'Copiar comando de instalación', 'plugins.actions.copyPluginId': 'Copiar ID del plugin', 'plugins.actions.copyReadmeBadge': 'Copiar insignia README', diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index 66c4e10a3..9da1c04f8 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -13,6 +13,8 @@ export const fa: Dict = { 'chat.amrError.balanceMessage': 'موجودی AMR شما تمام شده است. برای ادامه این اجرا شارژ کنید.', 'chat.amrError.authorizeCta': 'اعطای دسترسی و تلاش مجدد', 'chat.amrError.rechargeCta': 'شارژ AMR', + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': 'کپی دستور نصب', 'plugins.actions.copyPluginId': 'کپی شناسهٔ افزونه', 'plugins.actions.copyReadmeBadge': 'کپی نشان README', diff --git a/apps/web/src/i18n/locales/fr.ts b/apps/web/src/i18n/locales/fr.ts index 2e9ac5fb0..57d3c521d 100644 --- a/apps/web/src/i18n/locales/fr.ts +++ b/apps/web/src/i18n/locales/fr.ts @@ -13,6 +13,8 @@ export const fr: Dict = { 'chat.amrError.balanceMessage': 'Votre solde AMR est épuisé. Rechargez pour poursuivre cette exécution.', 'chat.amrError.authorizeCta': 'Autoriser et relancer', 'chat.amrError.rechargeCta': 'Recharger AMR', + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': 'Copier la commande d’installation', 'plugins.actions.copyPluginId': 'Copier l’ID du plugin', 'plugins.actions.copyReadmeBadge': 'Copier le badge README', diff --git a/apps/web/src/i18n/locales/hu.ts b/apps/web/src/i18n/locales/hu.ts index b27e17662..c4dd98800 100644 --- a/apps/web/src/i18n/locales/hu.ts +++ b/apps/web/src/i18n/locales/hu.ts @@ -13,6 +13,8 @@ export const hu: Dict = { 'chat.amrError.balanceMessage': 'Az AMR-egyenleged elfogyott. Tölts fel a futtatás folytatásához.', 'chat.amrError.authorizeCta': 'Engedélyezés és újrapróbálkozás', 'chat.amrError.rechargeCta': 'AMR feltöltése', + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': 'Telepítési parancs másolása', 'plugins.actions.copyPluginId': 'Pluginazonosító másolása', 'plugins.actions.copyReadmeBadge': 'README jelvény másolása', diff --git a/apps/web/src/i18n/locales/id.ts b/apps/web/src/i18n/locales/id.ts index 0039d2cfc..d6d7a94e2 100644 --- a/apps/web/src/i18n/locales/id.ts +++ b/apps/web/src/i18n/locales/id.ts @@ -13,6 +13,8 @@ export const id: Dict = { 'chat.amrError.balanceMessage': 'Saldo AMR Anda habis. Isi ulang untuk melanjutkan proses ini.', 'chat.amrError.authorizeCta': 'Otorisasi & coba lagi', 'chat.amrError.rechargeCta': 'Isi ulang AMR', + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': 'Salin perintah instal', 'plugins.actions.copyPluginId': 'Salin ID plugin', 'plugins.actions.copyReadmeBadge': 'Salin lencana README', diff --git a/apps/web/src/i18n/locales/it.ts b/apps/web/src/i18n/locales/it.ts index 746dda771..1fcef310e 100644 --- a/apps/web/src/i18n/locales/it.ts +++ b/apps/web/src/i18n/locales/it.ts @@ -13,6 +13,8 @@ export const it: Dict = { 'chat.amrError.balanceMessage': 'Il tuo saldo AMR è esaurito. Ricarica per continuare questa esecuzione.', 'chat.amrError.authorizeCta': 'Autorizza e riprova', 'chat.amrError.rechargeCta': 'Ricarica AMR', + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': 'Copia comando di installazione', 'plugins.actions.copyPluginId': 'Copia ID plugin', 'plugins.actions.copyReadmeBadge': 'Copia badge README', diff --git a/apps/web/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts index 7255f82d3..1d67e38ab 100644 --- a/apps/web/src/i18n/locales/ja.ts +++ b/apps/web/src/i18n/locales/ja.ts @@ -13,6 +13,8 @@ export const ja: Dict = { 'chat.amrError.balanceMessage': 'AMR の残高が不足しています。チャージしてこのタスクを続行してください。', 'chat.amrError.authorizeCta': '認可して再試行', 'chat.amrError.rechargeCta': 'AMR にチャージ', + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': 'インストールコマンドをコピー', 'plugins.actions.copyPluginId': 'プラグイン ID をコピー', 'plugins.actions.copyReadmeBadge': 'README バッジをコピー', diff --git a/apps/web/src/i18n/locales/ko.ts b/apps/web/src/i18n/locales/ko.ts index 2b36ddb8a..015e5f850 100644 --- a/apps/web/src/i18n/locales/ko.ts +++ b/apps/web/src/i18n/locales/ko.ts @@ -13,6 +13,8 @@ export const ko: Dict = { 'chat.amrError.balanceMessage': 'AMR 잔액이 부족합니다. 충전하여 이 작업을 계속 진행하세요.', 'chat.amrError.authorizeCta': '인증하고 재시도', 'chat.amrError.rechargeCta': 'AMR 충전', + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': '설치 명령 복사', 'plugins.actions.copyPluginId': '플러그인 ID 복사', 'plugins.actions.copyReadmeBadge': 'README 배지 복사', diff --git a/apps/web/src/i18n/locales/pl.ts b/apps/web/src/i18n/locales/pl.ts index c721a4764..66287f036 100644 --- a/apps/web/src/i18n/locales/pl.ts +++ b/apps/web/src/i18n/locales/pl.ts @@ -13,6 +13,8 @@ export const pl: Dict = { 'chat.amrError.balanceMessage': 'Saldo AMR zostało wyczerpane. Doładuj, aby kontynuować zadanie.', 'chat.amrError.authorizeCta': 'Autoryzuj i ponów', 'chat.amrError.rechargeCta': 'Doładuj AMR', + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': 'Kopiuj polecenie instalacji', 'plugins.actions.copyPluginId': 'Kopiuj ID wtyczki', 'plugins.actions.copyReadmeBadge': 'Kopiuj odznakę README', diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index 8288815f5..2120b5953 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -13,6 +13,8 @@ export const ptBR: Dict = { 'chat.amrError.balanceMessage': 'Seu saldo AMR acabou. Recarregue para continuar esta execução.', 'chat.amrError.authorizeCta': 'Autorizar e tentar novamente', 'chat.amrError.rechargeCta': 'Recarregar AMR', + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': 'Copiar comando de instalação', 'plugins.actions.copyPluginId': 'Copiar ID do plugin', 'plugins.actions.copyReadmeBadge': 'Copiar selo do README', diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index c8f59424a..506e78dc2 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -13,6 +13,8 @@ export const ru: Dict = { 'chat.amrError.balanceMessage': 'Баланс AMR исчерпан. Пополните, чтобы продолжить это выполнение.', 'chat.amrError.authorizeCta': 'Авторизовать и повторить', 'chat.amrError.rechargeCta': 'Пополнить AMR', + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': 'Скопировать команду установки', 'plugins.actions.copyPluginId': 'Скопировать ID плагина', 'plugins.actions.copyReadmeBadge': 'Скопировать бейдж README', diff --git a/apps/web/src/i18n/locales/th.ts b/apps/web/src/i18n/locales/th.ts index fe9c115b3..2e1f3dbb7 100644 --- a/apps/web/src/i18n/locales/th.ts +++ b/apps/web/src/i18n/locales/th.ts @@ -13,6 +13,8 @@ export const th: Dict = { 'chat.amrError.balanceMessage': 'ยอดเงิน AMR ของคุณหมดแล้ว เติมเงินเพื่อดำเนินงานนี้ต่อ', 'chat.amrError.authorizeCta': 'ให้สิทธิ์และลองใหม่', 'chat.amrError.rechargeCta': 'เติมเงิน AMR', + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': 'คัดลอกคำสั่งติดตั้ง', 'plugins.actions.copyPluginId': 'คัดลอก ID ปลั๊กอิน', 'plugins.actions.copyReadmeBadge': 'คัดลอกแบดจ์ README', diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index f1c6c0702..044f21f1d 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -13,6 +13,8 @@ export const tr: Dict = { 'chat.amrError.balanceMessage': 'AMR bakiyeniz bitti. Çalıştırmaya devam etmek için bakiye yükleyin.', 'chat.amrError.authorizeCta': 'Yetkilendir ve yeniden dene', 'chat.amrError.rechargeCta': 'AMR bakiyesi yükle', + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': 'Kurulum komutunu kopyala', 'plugins.actions.copyPluginId': 'Eklenti ID’sini kopyala', 'plugins.actions.copyReadmeBadge': 'README rozetini kopyala', diff --git a/apps/web/src/i18n/locales/uk.ts b/apps/web/src/i18n/locales/uk.ts index cf825e9d7..ca6b575fa 100644 --- a/apps/web/src/i18n/locales/uk.ts +++ b/apps/web/src/i18n/locales/uk.ts @@ -13,6 +13,8 @@ export const uk: Dict = { 'chat.amrError.balanceMessage': 'Баланс AMR вичерпано. Поповніть, щоб продовжити це виконання.', 'chat.amrError.authorizeCta': 'Авторизувати та повторити', 'chat.amrError.rechargeCta': 'Поповнити AMR', + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': 'Скопіювати команду встановлення', 'plugins.actions.copyPluginId': 'Скопіювати ID плагіна', 'plugins.actions.copyReadmeBadge': 'Скопіювати бейдж README', diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 1f027ea7e..4e0e16a1d 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -11,6 +11,8 @@ export const zhCN: Dict = { 'chat.amrError.balanceMessage': "AMR 账户余额不足。充值后可继续运行当前任务。", 'chat.amrError.authorizeCta': "授权并重试", 'chat.amrError.rechargeCta': "前往充值", + 'chat.antigravityError.launchTerminalCta': "在终端中登录", + 'chat.antigravityError.launchSwitchModelCta': "在终端中切换模型", 'common.cancel': '取消', 'common.save': '保存', 'common.close': '关闭', diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 6caf36496..04288a98b 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -13,6 +13,8 @@ export const zhTW: Dict = { 'chat.amrError.balanceMessage': "AMR 帳戶餘額不足。儲值後即可繼續執行目前任務。", 'chat.amrError.authorizeCta': "授權並重試", 'chat.amrError.rechargeCta': "前往儲值", + 'chat.antigravityError.launchTerminalCta': 'Sign in via terminal', + 'chat.antigravityError.launchSwitchModelCta': 'Switch model in terminal', 'plugins.actions.copyInstallCommand': '複製安裝命令', 'plugins.actions.copyPluginId': '複製外掛 ID', 'plugins.actions.copyReadmeBadge': '複製 README 徽章', diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index 327d7da8b..fd43db207 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -1695,6 +1695,8 @@ export interface Dict { 'chat.amrError.balanceMessage': string; 'chat.amrError.authorizeCta': string; 'chat.amrError.rechargeCta': string; + 'chat.antigravityError.launchTerminalCta': string; + 'chat.antigravityError.launchSwitchModelCta': string; 'chat.tabComments': string; 'chat.commentsSoon': string; 'chat.comments.attached': string; diff --git a/apps/web/src/providers/daemon.ts b/apps/web/src/providers/daemon.ts index d02b7055d..0752daff2 100644 --- a/apps/web/src/providers/daemon.ts +++ b/apps/web/src/providers/daemon.ts @@ -135,10 +135,50 @@ function scopeHistoryToAgent(history: ChatMessage[], targetAgentId?: string): Ch return history; } +// Strip OD-specific markup that the agent emitted on a prior turn but +// that the model would otherwise pattern-match as a template to echo. +// Today this is `<question-form>` blocks and the ```json fenced schemas +// some models (GPT-OSS-120B Medium, Gemini 3.5 Flash) emit alongside +// them — leaving those literal in the transcript causes weak/medium +// plain-stream models to re-emit an identical form on the user's +// follow-up turn, looking like the discovery form loop never breaks +// (see PR #3157 form-loop investigation). +// +// User content is preserved verbatim — a user message that legitimately +// quotes `<question-form>` (e.g. discussing the markup with the agent) +// must not be mangled. +export function sanitizePriorAssistantTurnForTranscript(content: string): string { + let sanitized = content.replace( + /<question-form\b[^>]*>[\s\S]*?<\/question-form>/g, + '[question-form was emitted here on a prior turn; the user already answered, see their reply below.]', + ); + // Strip ```json (or plain ```) fenced blocks whose body matches the + // form schema shape — `"questions": [` is the strongest tell. A + // generic JSON snippet without that key (e.g. an API response the + // agent shared) is left intact. + sanitized = sanitized.replace( + /```(?:json)?\s*\n([\s\S]*?)\n```/g, + (match, body: string) => { + if (/"questions"\s*:\s*\[/.test(body)) { + return '[form schema was echoed here on a prior turn; stripped to avoid a loop.]'; + } + return match; + }, + ); + return sanitized; +} + export function buildDaemonTranscript(history: ChatMessage[], targetAgentId?: string): string { const scopedHistory = scopeHistoryToAgent(history, targetAgentId); const transcript = scopedHistory - .map((m) => `## ${m.role}\n${escapeTranscriptRoleDelimiters(truncateForTranscript(m.content.trim()))}`) + .map((m) => { + const trimmed = m.content.trim(); + const sanitized = + m.role === 'assistant' + ? sanitizePriorAssistantTurnForTranscript(trimmed) + : trimmed; + return `## ${m.role}\n${escapeTranscriptRoleDelimiters(truncateForTranscript(sanitized))}`; + }) .join('\n\n'); const warning = buildPriorRunContextWarning(scopedHistory); return warning ? `${warning}\n\n${transcript}` : transcript; @@ -402,6 +442,46 @@ export async function submitChatRunToolResult( } } +// PR #3157: Antigravity's auth banner can offer a one-click "open +// system terminal with agy" button. The daemon endpoint spawns +// osascript / x-terminal-emulator / `cmd /c start` for the user; on +// success the new Terminal window pops up with agy running and the +// browser opens for OAuth. The Promise resolves once the daemon kicks +// off the spawn (not when OAuth completes), so the UI can disable the +// button momentarily and then re-enable for a retry click after the +// user finishes in the terminal. +export interface LaunchAntigravityOauthResult { + ok: boolean; + platform?: string; + via?: string; + error?: string; +} +export async function launchAntigravityOauth(): Promise<LaunchAntigravityOauthResult> { + try { + const resp = await fetch('/api/agents/antigravity/oauth-launch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + const body = (await resp.json().catch(() => null)) as + | LaunchAntigravityOauthResult + | null; + if (!resp.ok) { + return { + ok: false, + error: + body?.error ?? `daemon returned ${resp.status} ${resp.statusText}`, + }; + } + return body ?? { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + export interface VelaUser { id: string; email: string; diff --git a/apps/web/src/runtime/amr-guidance.ts b/apps/web/src/runtime/amr-guidance.ts index d0fa7a485..4928f1e90 100644 --- a/apps/web/src/runtime/amr-guidance.ts +++ b/apps/web/src/runtime/amr-guidance.ts @@ -20,10 +20,33 @@ const PROMOTE_AMR_CODES = new Set<string>([ ]); // Primary action offered in the gray error card. -// - retry: re-run with the current agent. -// - authorize: AMR sign-in/authorize flow, then auto-retry on success. -// - recharge: open the AMR wallet (manual retry afterwards). -export type RunFailurePrimaryAction = 'retry' | 'authorize' | 'recharge'; +// - retry: re-run with the current agent. +// - authorize: AMR sign-in/authorize flow, then auto-retry on success. +// - recharge: open the AMR wallet (manual retry afterwards). +// - launch-terminal-auth: Antigravity-specific. agy's `-p` +// print mode cannot complete Google +// Sign-In on its own (no input field +// for the auth code), so OD spawns a +// system Terminal running `agy` and +// the user finishes OAuth there. +// - launch-terminal-switch-model: Antigravity-specific. agy has no +// `--model` flag (upstream #35), so +// switching to a model with available +// quota means opening agy's TUI and +// using its Switch Model picker. The +// daemon spawns the same terminal as +// launch-terminal-auth — the button +// label is the only thing that changes. +// Both terminal-launch actions pair with `secondaryRetry: true` so the +// user has a Retry button after the external step completes (OAuth / +// switching models happens out-of-band; we can't auto-retry from the +// daemon side). +export type RunFailurePrimaryAction = + | 'retry' + | 'authorize' + | 'recharge' + | 'launch-terminal-auth' + | 'launch-terminal-switch-model'; // i18n keys for the gray-card text override (null = show the raw error). export type RunFailureMessageKey = @@ -77,6 +100,33 @@ export function resolveRunFailureUi( showSwitchCard: false, }; } + // Antigravity's auth flow is terminal-only — see the + // `launch-terminal-auth` action comment for why. Without this branch + // the user sees the daemon-emitted guidance text and would have to + // open a terminal themselves; with it they get a one-click button + // that opens Terminal.app / x-terminal-emulator / cmd with `agy` + // running, and a Retry button to redo the chat after OAuth completes. + if (agentId === 'antigravity') { + if (code === 'AGENT_AUTH_REQUIRED') { + return { + primaryAction: 'launch-terminal-auth', + messageKey: null, + secondaryRetry: true, + showSwitchCard: false, + }; + } + // Quota: each Antigravity model has its own quota, so the action + // is "open agy, switch model" rather than "sign in." Same handler + // spawns the same terminal; only the label changes. + if (code === 'RATE_LIMITED') { + return { + primaryAction: 'launch-terminal-switch-model', + messageKey: null, + secondaryRetry: true, + showSwitchCard: false, + }; + } + } const promote = typeof code === 'string' && PROMOTE_AMR_CODES.has(code); return { primaryAction: 'retry', diff --git a/apps/web/src/utils/agentLabels.ts b/apps/web/src/utils/agentLabels.ts index cb424b5be..0072059c6 100644 --- a/apps/web/src/utils/agentLabels.ts +++ b/apps/web/src/utils/agentLabels.ts @@ -11,6 +11,7 @@ const AGENT_LABELS: Record<string, string> = { qoder: 'Qoder', copilot: 'Copilot', deepseek: 'DeepSeek', + antigravity: 'Antigravity', 'anthropic-api': 'Anthropic API', 'openai-api': 'OpenAI API', 'azure-openai-api': 'Azure OpenAI', @@ -31,6 +32,7 @@ const AGENT_ALIASES: Record<string, string> = { 'deepseek-tui': 'deepseek', 'aider cli': 'aider', 'aider chat': 'aider', + agy: 'antigravity', }; export function agentDisplayName( diff --git a/apps/web/tests/components/SettingsDialog.execution.test.tsx b/apps/web/tests/components/SettingsDialog.execution.test.tsx index 8af3007cf..37d578f84 100644 --- a/apps/web/tests/components/SettingsDialog.execution.test.tsx +++ b/apps/web/tests/components/SettingsDialog.execution.test.tsx @@ -114,6 +114,7 @@ const amrAgent: AgentInfo = { available: true, version: '1.0.0', models: [{ id: 'default', label: 'Default' }], + supportsCustomModel: false, }; type OnRefreshAgents = ( diff --git a/apps/web/tests/providers/sse.test.ts b/apps/web/tests/providers/sse.test.ts index 23618585d..2e36e6285 100644 --- a/apps/web/tests/providers/sse.test.ts +++ b/apps/web/tests/providers/sse.test.ts @@ -4,6 +4,7 @@ import { buildDaemonTranscript, latestUserPromptFromHistory, reattachDaemonRun, + sanitizePriorAssistantTurnForTranscript, streamViaDaemon, } from '../../src/providers/daemon'; import { streamMessageOpenAI } from '../../src/providers/openai-compatible'; @@ -145,6 +146,115 @@ describe('streamViaDaemon', () => { expect(transcript).toContain('small answer'); }); + // PR #3157 form-loop investigation: weak / medium plain-stream models + // (GPT-OSS-120B Medium, Gemini 3.5 Flash through Antigravity's `agy`) + // pattern-match on the literal `<question-form>` markup the agent + // emitted on turn 1 and re-emit an identical form on turn 2 even when + // the OD-side OVERRIDE block explicitly forbids it. Stripping the + // markup from prior assistant turns at the transcript layer kills the + // echo source entirely. + it('strips question-form markup from prior assistant turns to kill the form-echo loop', () => { + const sanitized = sanitizePriorAssistantTurnForTranscript( + [ + 'Got it — let me ask a few questions:', + '', + '<question-form id="discovery" title="Quick brief — 30 seconds">', + '{', + ' "description": "I will lock the brief.",', + ' "questions": [{ "id": "output", "label": "What are we making?" }]', + '}', + '</question-form>', + ].join('\n'), + ); + + expect(sanitized).not.toContain('<question-form'); + expect(sanitized).not.toContain('</question-form>'); + expect(sanitized).not.toContain('"questions": ['); + expect(sanitized).toContain('question-form was emitted here on a prior turn'); + }); + + it('also strips ```json fenced form-schema echoes that some models add alongside the form tag', () => { + const sanitized = sanitizePriorAssistantTurnForTranscript( + [ + 'Got it — 请告诉我以下信息:', + '', + '```json', + '{', + ' "title": "快速简报 — 30 秒",', + ' "questions": [', + ' { "id": "output", "label": "我们要做什么?" }', + ' ]', + '}', + '```', + '', + '<question-form id="discovery" title="快速简报 — 30 秒">', + '{ "questions": [] }', + '</question-form>', + ].join('\n'), + ); + + expect(sanitized).not.toContain('```json'); + expect(sanitized).not.toContain('<question-form'); + expect(sanitized).toContain('form schema was echoed here on a prior turn'); + }); + + it('preserves unrelated ```json fences (regular JSON snippets without "questions") so model output stays intact', () => { + const original = [ + 'Here is the config you asked about:', + '', + '```json', + '{ "endpoint": "https://api.example.com", "version": 2 }', + '```', + ].join('\n'); + const sanitized = sanitizePriorAssistantTurnForTranscript(original); + + // No `"questions"` key → fence is NOT stripped. + expect(sanitized).toBe(original); + }); + + it('preserves <artifact> blocks — only question-form is stripped, the deliverable stays intact', () => { + const original = [ + 'Build summary below.', + '', + '<artifact identifier="deck" type="text/html" title="Pitch deck">', + '<!doctype html>', + '<html><body>slide content</body></html>', + '</artifact>', + ].join('\n'); + const sanitized = sanitizePriorAssistantTurnForTranscript(original); + + expect(sanitized).toBe(original); + expect(sanitized).toContain('<artifact'); + expect(sanitized).toContain('<!doctype html>'); + }); + + it('sanitizes ONLY assistant content inside buildDaemonTranscript — user messages quoting <question-form> stay verbatim', () => { + const transcript = buildDaemonTranscript([ + { + id: '1', + role: 'user', + // User legitimately quotes the markup in chat. Must not be mangled — + // they might be discussing the markup itself with the agent. + content: 'Why does <question-form id="discovery"> render as a card?', + }, + { + id: '2', + role: 'assistant', + content: [ + '<question-form id="discovery" title="Brief">', + '{ "questions": [] }', + '</question-form>', + ].join('\n'), + }, + ]); + + // User's <question-form> mention survives. + expect(transcript).toContain('Why does <question-form id="discovery"> render'); + // Assistant's emission is replaced with the placeholder. + expect(transcript).toContain('question-form was emitted here on a prior turn'); + expect(transcript).not.toContain('<question-form id="discovery" title="Brief">'); + }); + it('escapes role delimiter lines in prior message content', () => { const transcript = buildDaemonTranscript([ { diff --git a/apps/web/tests/runtime/amr-guidance.test.ts b/apps/web/tests/runtime/amr-guidance.test.ts index 1e837902b..31c08c397 100644 --- a/apps/web/tests/runtime/amr-guidance.test.ts +++ b/apps/web/tests/runtime/amr-guidance.test.ts @@ -47,4 +47,62 @@ describe('resolveRunFailureUi', () => { const ui = resolveRunFailureUi('AGENT_EXECUTION_FAILED', 'amr'); expect(ui).toMatchObject({ primaryAction: 'retry', showSwitchCard: false }); }); + + // PR #3157: Antigravity's `agy -p` cannot complete Google Sign-In on + // its own — the OAuth callback page asks the user to paste an auth + // code back into agy, but print mode has no input field. The auth + // banner offers a one-click "Sign in via terminal" button that + // spawns a system Terminal running `agy`. Pin both the action type + // AND `secondaryRetry: true` because OAuth completes externally and + // we can't auto-retry from the daemon side — the manual Retry + // button next to the launcher is the only way back to the chat run. + it('offers launch-terminal-auth + manual retry for antigravity AGENT_AUTH_REQUIRED', () => { + const ui = resolveRunFailureUi('AGENT_AUTH_REQUIRED', 'antigravity'); + expect(ui).toMatchObject({ + primaryAction: 'launch-terminal-auth', + messageKey: null, + secondaryRetry: true, + showSwitchCard: false, + }); + }); + + // Antigravity's per-model quota: each model (Gemini 3 Pro / Flash, + // Claude 4.6, GPT-OSS) has its own quota and the user has to switch + // models in agy's TUI because there's no `--model` flag (upstream + // #35). RATE_LIMITED gets the same terminal-launch handler as + // AGENT_AUTH_REQUIRED — only the button label changes ("Switch + // model in terminal" vs "Sign in via terminal"). Pin both action + // type AND `secondaryRetry: true` since model switching happens + // out-of-band and we can't auto-retry from the daemon side. + it('offers launch-terminal-switch-model + manual retry for antigravity RATE_LIMITED', () => { + const ui = resolveRunFailureUi('RATE_LIMITED', 'antigravity'); + expect(ui).toMatchObject({ + primaryAction: 'launch-terminal-switch-model', + messageKey: null, + secondaryRetry: true, + showSwitchCard: false, + }); + }); + + // Other antigravity failure codes must NOT promote the terminal + // launcher — it's specific to the OAuth-missing and quota-reached + // cases. A generic `AGENT_EXECUTION_FAILED` should fall back to + // plain retry. + it('does NOT promote launch-terminal-auth for non-auth/quota antigravity failures', () => { + const ui = resolveRunFailureUi('AGENT_EXECUTION_FAILED', 'antigravity'); + expect(ui.primaryAction).toBe('retry'); + expect(ui.primaryAction).not.toBe('launch-terminal-auth'); + expect(ui.primaryAction).not.toBe('launch-terminal-switch-model'); + }); + + // Other agents hitting AGENT_AUTH_REQUIRED must NOT see the + // terminal launcher — agy's specific OAuth quirk is what motivates + // it; cursor-agent / deepseek / claude have different sign-in + // shapes (own CLI subcommand / API key env var / OAuth on first run). + it('does NOT promote launch-terminal-auth for non-antigravity auth failures', () => { + for (const agent of ['claude', 'cursor-agent', 'deepseek', 'codex']) { + const ui = resolveRunFailureUi('AGENT_AUTH_REQUIRED', agent); + expect(ui.primaryAction).not.toBe('launch-terminal-auth'); + } + }); }); diff --git a/packages/contracts/src/api/registry.ts b/packages/contracts/src/api/registry.ts index 32ffd9d3d..66f740515 100644 --- a/packages/contracts/src/api/registry.ts +++ b/packages/contracts/src/api/registry.ts @@ -32,6 +32,14 @@ export interface AgentInfo { | 'claude-mcp-json' | 'acp-merge' | 'opencode-env-content'; + /** + * When `false`, the Settings model picker hides the "Custom (fill below)" + * option and the free-text input. Use this for agents whose CLI doesn't + * accept a model id (e.g. Antigravity `agy` has no `--model` flag yet — + * upstream issue #35) or rejects free-form ids (AMR validates against the + * live Vela catalog). Undefined === allow, matching the historical UX. + */ + supportsCustomModel?: boolean; } export interface AgentsResponse { From 3f4fd58937538cd0588432abef5a24baf488a9e7 Mon Sep 17 00:00:00 2001 From: Jane <522700967@qq.com> Date: Fri, 29 May 2026 13:59:24 +0800 Subject: [PATCH 14/56] feat(landing-page): surface Discord + X in header, restructure site footer (#3230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(landing-page): surface Discord + X in header, restructure site footer Two related public-chrome adjustments: - **Header gains compact Discord + X icon buttons.** Both community channels were previously buried in the footer, so the typical visitor never saw them on a page-deep scroll. They now sit before the Download / Star CTAs in `nav-side`, share the ghost-button outline language, and stay icon-only with `aria-label` so they read as social affordances rather than competing with the text CTAs. At ≤1080px the icon buttons hide alongside the existing ghost CTA, so the bar still collapses cleanly into the hamburger panel — Star stays in the bar at every breakpoint. - **Footer restructured into 4 columns: Products / Plugins / Resources / Connect.** The old `Plugins / Open Design / Connect` three-column layout muddled three different things — sister products, the artifact catalogue, and contributor channels — under one roof, so visitors hunting for "the other thing this team makes" had nowhere obvious to go. - **Products** (new) lists the team's apps: Open Design (links to homepage) and HTML Anything. Two entries by design — adding more products without an editorial pass would dilute the column. - **Plugins** mirrors the topbar `Plugins` dropdown verbatim: Templates / Skills / Systems / Craft, with no count prefix on Systems / Craft so it reads identically to the nav. - **Resources** (renamed from `Open Design`) carries the docs-style links: Official source / Quickstart / Agents locaux / Compare / Claude Design alternative. The old column heading was confusing because the OD logo + brand name already sit under the column. - **Connect** gains an X / Twitter row pointing at `@nexudotio`. The brand entries on this column are contributor / community surfaces only — code, releases, chat, social, RSS, contact form. Implementation: - `_components/header.tsx` — `DISCORD` and `X_TWITTER` consts at the top alongside `REPO`. Two `<a class="nav-icon">` blocks with inline SVG before the existing Download / Star CTAs. - `_components/site-footer.astro` — `HTML_ANYTHING` and `NEXU_IO` consts. `<div class="sub-footer-col">` re-ordered to put Products first, Plugins second (no longer carries `counts.*` values), Resources third, Connect fourth (with the new X / Twitter row). - `globals.css` — `.nav-icon` rule cloned from the ghost CTA's visual language (transparent + 1px line, fills on hover) but square (36×36 round) so it reads as a social-icon affordance. Added `display: none` for `.nav-side .nav-icon` to the existing ≤1080px and ≤880px media queries so the icons follow the same collapse behaviour as the Download CTA. - `sub-pages.css` — `.sub-footer-grid` switches from `1.6fr 1fr 1fr 1fr` to `1.4fr 1fr 1fr 1fr 1fr` (brand + 4 columns). At ≤1080px it falls back to a 3-column shape so each column has room to breathe; at ≤720px it stays a single column (existing behaviour). - `i18n.ts` — adds `products`, `resources`, `xTwitter`, `sisterProjects`, `htmlAnything`, `nexuIo` to `LandingUiCopy.footer` (the last three are kept around even though `sisterProjects` is no longer rendered after the column was renamed Products — they're harmless and avoid churning the type if a future iteration brings the Sister-projects framing back). All 17 non-English landing locales gain translations for the new keys via the existing `LOCALIZED_LANDING_FOOTER_COPY` map (and the `LANDING_UI_COPY_OVERRIDES` block for `zh` / `zh-tw`). Translations were generated with `claude-haiku-4-5` over OpenRouter, with explicit instructions to keep "Open Design", "HTML Anything", and "X / Twitter" in English and to render "Products" / "Resources" in sentence case per locale convention. Spot-checked against rendered pages on `/zh/`, `/zh-tw/`, `/ja/`, `/ko/`, `/de/`, `/fr/` (and `/ar/` for RTL) for natural phrasing. Validation: `pnpm --filter @open-design/landing-page typecheck` -> 0 errors / 0 warnings; local dev server smoke-tested on en root (`/html-anything/`) and 5 locale variants (`/zh/`, `/zh-tw/`, `/ja/`, `/de/`, `/fr/`) — header renders 2 nav-icon buttons, footer renders 4 localized column headings in the correct order with the right link targets. * fix(landing-page): address PR #3230 review — locale-aware HTML Anything link + drop unused const Two non-blocking inline review points from @PerishCode on PR #3230: - The HTML Anything entry in the new Products column hardcoded `https://open-design.ai/html-anything/` via a top-level `HTML_ANYTHING` const, but `/html-anything/` is a real localized route in this app (`pages/[locale]/html-anything/index.astro`) and `open-design.ai` is the same site's live domain. A visitor on `/zh/…` clicking through landed on the English route and lost locale context, and hardcoding the production domain meant a preview build would surface a link that bounces visitors back to prod. Switch to `href('/html-anything/')` so the locale prefix + the current site's domain (resolved by `localizedHref`) are honored, matching every other footer link. - `NEXU_IO` was declared at the top of the component but never referenced — leftover from an earlier iteration that listed `nexu.io` as a Sister-projects entry before the column was renamed Products and reduced to OD + HTML Anything. Removed. No behavior change beyond the locale routing fix; the i18n keys and column structure stay as they landed in the original commit. * fix(landing-page): correct nav-icon comment to match actual responsive behaviour The JSX comment introduced for the new Discord + X icon buttons in PR #3230 claimed the icons "survive at narrow widths while text-only nav items get pushed off". The CSS that shipped in the same PR does the opposite: both `@media (max-width: 1080px)` and `@media (max-width: 880px)` blocks add `.nav-side .nav-icon { display: none; }`, so at narrow widths the icons collapse alongside the ghost Download CTA while the text nav <ul> moves into the hamburger panel — only the Star CTA remains visible in the bar. Rewrite the comment to describe the actual responsive contract so the next reader of `header.tsx` doesn't have to cross-reference `globals.css` to figure out which surface stays. Reviewer flag from @PerishCode on PR #3230. No code-path change; comment-only. * fix(landing-page): correct sub-footer 1080px comment to describe actual 3-column grid The CSS comment introduced for the new sub-footer grid claimed the ≤1080px breakpoint drops to "brand + 2x2 grid of columns" — but the rule produces a 3-column grid, not a 2x2. `.sub-footer-grid` has 5 children at this breakpoint (the brand block + the four footer columns) and `.sub-footer-brand` carries no `grid-column` span, so with `grid-template-columns: 1.6fr repeat(2, 1fr)` they flow as: row 1 = brand · Products · Plugins, row 2 = Resources · Connect · empty cell. The brand sits inline with two columns rather than on its own, and the four content columns are not a clean 2x2. The layout itself is fine; only the comment misleads the next reader about how the columns wrap. Same flavor as the `header.tsx` icon comment fixed in 744daec — describe what the rule actually does so the comment doesn't drift from the CSS. Reviewer flag from @PerishCode on PR #3230. Comment-only change. --------- Co-authored-by: Joey-nexu <joeylee12629@gmail.com> --- apps/landing-page/app/_components/header.tsx | 33 +++++ .../app/_components/site-footer.astro | 15 ++- apps/landing-page/app/globals.css | 37 +++++- apps/landing-page/app/i18n.ts | 114 ++++++++++++++++++ apps/landing-page/app/sub-pages.css | 12 +- 5 files changed, 203 insertions(+), 8 deletions(-) diff --git a/apps/landing-page/app/_components/header.tsx b/apps/landing-page/app/_components/header.tsx index bcb3e9789..481a271ef 100644 --- a/apps/landing-page/app/_components/header.tsx +++ b/apps/landing-page/app/_components/header.tsx @@ -21,6 +21,8 @@ import { const REPO = 'https://github.com/nexu-io/open-design'; const REPO_RELEASES = `${REPO}/releases`; +const DISCORD = 'https://discord.gg/9ptkbbqRu'; +const X_TWITTER = 'https://x.com/nexudotio'; const ext = { target: '_blank', @@ -274,6 +276,37 @@ export function Header({ </ul> </nav> <div className='nav-side'> + {/* + Discord + X icon buttons live before Download / Star so the + community channels are reachable from every page without + burning a nav text slot. The icons are aria-labeled and + otherwise unlabeled. At ≤1080px they collapse alongside the + ghost Download CTA and the text-only nav <ul> (the latter + moves into the hamburger panel) — only the Star CTA stays + visible in the bar. + */} + <a + className='nav-icon' + href={DISCORD} + aria-label='Join Open Design on Discord' + title='Discord' + {...ext} + > + <svg viewBox='0 0 24 24' width='18' height='18' fill='currentColor' aria-hidden='true'> + <path d='M19.27 5.33A18 18 0 0 0 14.72 4l-.2.4a13.7 13.7 0 0 0-5.04 0L9.27 4a18 18 0 0 0-4.54 1.33C2.4 8.94 1.78 12.45 2.09 15.9a18.4 18.4 0 0 0 5.6 2.83l1.13-1.55a11.6 11.6 0 0 1-1.78-.86l.44-.34a13 13 0 0 0 11.04 0l.44.34c-.55.33-1.16.61-1.78.86l1.13 1.55a18.3 18.3 0 0 0 5.6-2.83c.45-4.05-.5-7.53-2.64-10.57ZM9.5 14.07c-1.07 0-1.95-.99-1.95-2.21 0-1.22.86-2.22 1.95-2.22 1.1 0 1.97 1 1.95 2.22 0 1.22-.86 2.21-1.95 2.21Zm5 0c-1.07 0-1.95-.99-1.95-2.21 0-1.22.87-2.22 1.96-2.22 1.1 0 1.96 1 1.95 2.22 0 1.22-.86 2.21-1.96 2.21Z' /> + </svg> + </a> + <a + className='nav-icon' + href={X_TWITTER} + aria-label='Follow Open Design on X' + title='X / Twitter' + {...ext} + > + <svg viewBox='0 0 24 24' width='16' height='16' fill='currentColor' aria-hidden='true'> + <path d='M17.53 3H21l-7.39 8.45L22 21h-6.83l-5.36-6.99L3.7 21H.23l7.9-9.04L0 3h7l4.85 6.41L17.53 3Zm-2.39 16h2.04L5.96 4.9H3.78L15.14 19Z' /> + </svg> + </a> <a className='nav-cta ghost' href={REPO_RELEASES} diff --git a/apps/landing-page/app/_components/site-footer.astro b/apps/landing-page/app/_components/site-footer.astro index 27ce04266..e6049a7d4 100644 --- a/apps/landing-page/app/_components/site-footer.astro +++ b/apps/landing-page/app/_components/site-footer.astro @@ -20,6 +20,7 @@ const ui = getLandingUiCopy(locale); const href = (path: string) => localizedHref(path, locale); const REPO = 'https://github.com/nexu-io/open-design'; const DISCORD = 'https://discord.gg/9ptkbbqRu'; +const X_TWITTER = 'https://x.com/nexudotio'; --- <footer class='sub-footer' data-od-id='sub-footer'> @@ -36,17 +37,24 @@ const DISCORD = 'https://discord.gg/9ptkbbqRu'; {ui.footer.summary} </p> </div> + <div class='sub-footer-col'> + <h5>{ui.footer.products}</h5> + <ul> + <li><a href={href('/')}>Open Design</a></li> + <li><a href={href('/html-anything/')}>{ui.footer.htmlAnything}</a></li> + </ul> + </div> <div class='sub-footer-col'> <h5>{copy.nav.plugins}</h5> <ul> <li><a href={href('/plugins/templates/')}>{copy.nav.templates}</a></li> <li><a href={href('/plugins/skills/')}>{copy.nav.skills}</a></li> - <li><a href={href('/plugins/systems/')}>{counts.systems} {copy.nav.systems}</a></li> - <li><a href={href('/plugins/craft/')}>{counts.craft} {copy.nav.craft}</a></li> + <li><a href={href('/plugins/systems/')}>{copy.nav.systems}</a></li> + <li><a href={href('/plugins/craft/')}>{copy.nav.craft}</a></li> </ul> </div> <div class='sub-footer-col'> - <h5>{ui.footer.openDesign}</h5> + <h5>{ui.footer.resources}</h5> <ul> <li><a href={href('/official/')}>{ui.footer.official}</a></li> <li><a href={href('/quickstart/')}>{ui.footer.quickstart}</a></li> @@ -62,6 +70,7 @@ const DISCORD = 'https://discord.gg/9ptkbbqRu'; <li><a href={`${REPO}/issues`} target='_blank' rel='noopener'>{ui.footer.issues}</a></li> <li><a href={`${REPO}/releases`} target='_blank' rel='noopener'>{ui.footer.releases}</a></li> <li><a href={DISCORD} target='_blank' rel='noopener'>{ui.footer.discord}</a></li> + <li><a href={X_TWITTER} target='_blank' rel='noopener'>{ui.footer.xTwitter}</a></li> <li><a href='/blog/rss.xml'>{ui.footer.rss}</a></li> <li><a href={href('/#contact')}>{copy.nav.contact}</a></li> </ul> diff --git a/apps/landing-page/app/globals.css b/apps/landing-page/app/globals.css index 167fe2216..cdb3166f2 100644 --- a/apps/landing-page/app/globals.css +++ b/apps/landing-page/app/globals.css @@ -606,6 +606,34 @@ body::before { align-items: center; gap: 18px; } +/* + * Compact icon-only chrome buttons for community-channel links + * (Discord, X) sitting beside the Download / Star CTAs. They share + * the ghost-button outline language but stay square so they read as + * social affordances and don't compete with the text CTAs. + */ +.nav-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid rgba(21, 20, 15, 0.18); + color: var(--ink); + background: transparent; + text-decoration: none; + transition: background 160ms ease, border-color 160ms ease, color 160ms ease; + flex-shrink: 0; +} +.nav-icon:hover { + background: var(--ink); + border-color: var(--ink); + color: var(--paper); +} +.nav-icon svg { + display: block; +} .nav-cta { display: inline-flex; align-items: center; @@ -2470,8 +2498,9 @@ footer { @media (max-width: 1080px) { .nav-toggle { display: inline-flex; } .brand { white-space: nowrap; } - /* Hide Download from the bar (Star stays). */ + /* Hide Download + Discord/X icon buttons from the bar (Star stays). */ .nav-side .nav-cta.ghost { display: none; } + .nav-side .nav-icon { display: none; } /* Collapse the nav <ul> into a panel that drops below the header bar. * The header is `position: sticky`, so absolute-positioning the panel * relative to the header element keeps it pinned correctly. */ @@ -2620,11 +2649,13 @@ footer { .foot-bottom { flex-direction: column; align-items: flex-start; gap: 12px; } .foot-bottom .right { flex-wrap: wrap; gap: 12px 20px; } /* nav — at ≤880px tighten padding; nav-links stay reachable through - * the hamburger panel introduced at ≤1080px. Brand meta and Download - * stay hidden; Star CTA still pings in the bar. */ + * the hamburger panel introduced at ≤1080px. Brand meta, Download, + * and the Discord/X icon buttons stay hidden; Star CTA still pings + * in the bar. */ .nav { padding: 16px 0; } .brand-meta { display: none; } .nav-side .nav-cta.ghost { display: none; } + .nav-side .nav-icon { display: none; } } @media (max-width: 640px) { .topbar-inner { gap: 14px; } diff --git a/apps/landing-page/app/i18n.ts b/apps/landing-page/app/i18n.ts index 6451cfe2e..41a63fd58 100644 --- a/apps/landing-page/app/i18n.ts +++ b/apps/landing-page/app/i18n.ts @@ -698,6 +698,8 @@ export interface LandingUiCopy { summary: string; catalog: string; openDesign: string; + products: string; + resources: string; official: string; quickstart: string; agents: string; @@ -709,7 +711,11 @@ export interface LandingUiCopy { contributors: string; releases: string; discord: string; + xTwitter: string; rss: string; + sisterProjects: string; + htmlAnything: string; + nexuIo: string; bottomLeft: string; bottomRight: string; }; @@ -2993,6 +2999,8 @@ const LANDING_UI_COPY: LandingUiCopy = { 'The official open-source, local-first alternative to Claude Design. Apache-2.0, BYOK at every layer.', catalog: 'Catalog', openDesign: 'Open Design', + products: 'Products', + resources: 'Resources', official: 'Official source page', quickstart: 'Quickstart', agents: 'Agents locaux', @@ -3004,7 +3012,11 @@ const LANDING_UI_COPY: LandingUiCopy = { contributors: 'Contributors', releases: 'Releases', discord: 'Discord', + xTwitter: 'X / Twitter', rss: 'RSS', + sisterProjects: 'Sister projects', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', bottomLeft: '● Open Design · Apache-2.0 · 2026 / Volume 01 / Issue Nº 26', bottomRight: 'Berlin / Open / Earth · 52.5200° N · 13.4050° E', }, @@ -3272,6 +3284,8 @@ const LANDING_UI_COPY_OVERRIDES: Partial< summary: '官方开源、本地优先的 Claude Design 替代方案。Apache-2.0,所有层都 BYOK。', catalog: '目录', + products: '产品', + resources: '资源', official: '官方来源页', quickstart: '快速开始', agents: 'Agent', @@ -3283,7 +3297,11 @@ const LANDING_UI_COPY_OVERRIDES: Partial< contributors: '贡献者', releases: '版本发布', discord: 'Discord', + xTwitter: 'X / Twitter', rss: 'RSS', + sisterProjects: '姊妹项目', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', bottomLeft: '● Open Design · Apache-2.0 · 2026 / 第 01 卷 / 第 26 期', bottomRight: '柏林 / 开放 / 地球 · 52.5200° N · 13.4050° E', }, @@ -3547,6 +3565,8 @@ const LANDING_UI_COPY_OVERRIDES: Partial< summary: '官方開源、本地優先的 Claude Design 替代方案。Apache-2.0,每一層都 BYOK。', catalog: '目錄', + products: '產品', + resources: '資源', official: '官方來源頁', quickstart: '快速開始', agents: 'Agent', @@ -3558,7 +3578,11 @@ const LANDING_UI_COPY_OVERRIDES: Partial< contributors: '貢獻者', releases: '版本發布', discord: 'Discord', + xTwitter: 'X / Twitter', rss: 'RSS', + sisterProjects: '姊妹專案', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', bottomLeft: '● Open Design · Apache-2.0 · 2026 / 第 01 卷 / 第 26 期', bottomRight: '柏林 / 開放 / 地球 · 52.5200° N · 13.4050° E', }, @@ -3952,6 +3976,8 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< summary: 'Claude Design の公式オープンソース、ローカル優先の代替。Apache-2.0、すべての層で BYOK。', catalog: 'カタログ', + products: 'プロダクト', + resources: 'リソース', official: '公式ソースページ', quickstart: 'クイックスタート', agents: 'Agent', @@ -3966,11 +3992,17 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< rss: 'RSS', bottomLeft: '● Open Design · Apache-2.0 · 2026 / 第 01 巻 / 第 26 号', bottomRight: 'ベルリン / オープン / 地球 · 52.5200° N · 13.4050° E', + xTwitter: 'X / Twitter', + sisterProjects: '姉妹プロジェクト', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', }, ko: { summary: 'Claude Design의 공식 오픈소스, 로컬 우선 대안입니다. Apache-2.0, 모든 계층에서 BYOK.', catalog: '카탈로그', + products: '제품', + resources: '리소스', official: '공식 소스 페이지', quickstart: '빠른 시작', agents: 'Agent', @@ -3985,11 +4017,17 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< rss: 'RSS', bottomLeft: '● Open Design · Apache-2.0 · 2026 / 01권 / 26호', bottomRight: '베를린 / 오픈 / 지구 · 52.5200° N · 13.4050° E', + xTwitter: 'X / Twitter', + sisterProjects: '자매 프로젝트', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', }, de: { summary: 'Die offizielle quelloffene, lokal zuerst gedachte Alternative zu Claude Design. Apache-2.0, BYOK auf jeder Ebene.', catalog: 'Katalog', + products: 'Produkte', + resources: 'Ressourcen', official: 'Offizielle Quellseite', quickstart: 'Schnellstart', agents: 'Agenten', @@ -4004,11 +4042,17 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< rss: 'RSS', bottomLeft: '● Open Design · Apache-2.0 · 2026 / Band 01 / Ausgabe Nr. 26', bottomRight: 'Berlin / Offen / Erde · 52.5200° N · 13.4050° E', + xTwitter: 'X / Twitter', + sisterProjects: 'Schwesterprojekte', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', }, fr: { summary: "L'alternative officielle open source et locale d'abord à Claude Design. Apache-2.0, BYOK à chaque couche.", catalog: 'Catalogue', + products: 'Produits', + resources: 'Ressources', official: 'Page source officielle', quickstart: 'Démarrage rapide', agents: 'Lokale agents', @@ -4023,11 +4067,17 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< rss: 'RSS', bottomLeft: '● Open Design · Apache-2.0 · 2026 / Volume 01 / Numéro 26', bottomRight: 'Berlin / Ouvert / Terre · 52.5200° N · 13.4050° E', + xTwitter: 'X / Twitter', + sisterProjects: 'Projets sœurs', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', }, ru: { summary: 'Официальная открытая и локально ориентированная альтернатива Claude Design. Apache-2.0, BYOK на каждом уровне.', catalog: 'Каталог', + products: 'Продукты', + resources: 'Ресурсы', official: 'Официальная страница источника', quickstart: 'Быстрый старт', agents: 'Агенты', @@ -4042,11 +4092,17 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< rss: 'RSS', bottomLeft: '● Open Design · Apache-2.0 · 2026 / Том 01 / Выпуск № 26', bottomRight: 'Берлин / Открыто / Земля · 52.5200° N · 13.4050° E', + xTwitter: 'X / Twitter', + sisterProjects: 'Родственные проекты', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', }, es: { summary: 'La alternativa oficial de código abierto y local-first a Claude Design. Apache-2.0, BYOK en cada capa.', catalog: 'Catálogo', + products: 'Productos', + resources: 'Recursos', official: 'Página fuente oficial', quickstart: 'Inicio rápido', agents: 'Agentes', @@ -4061,11 +4117,17 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< rss: 'RSS', bottomLeft: '● Open Design · Apache-2.0 · 2026 / Volumen 01 / Número 26', bottomRight: 'Berlín / Abierto / Tierra · 52.5200° N · 13.4050° E', + xTwitter: 'X / Twitter', + sisterProjects: 'Proyectos relacionados', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', }, 'pt-br': { summary: 'A alternativa oficial, de código aberto e local-first ao Claude Design. Apache-2.0, BYOK em todas as camadas.', catalog: 'Catálogo', + products: 'Produtos', + resources: 'Recursos', official: 'Página oficial de origem', quickstart: 'Início rápido', agents: 'Agentes', @@ -4080,11 +4142,17 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< rss: 'RSS', bottomLeft: '● Open Design · Apache-2.0 · 2026 / Volume 01 / Edição Nº 26', bottomRight: 'Berlim / Aberto / Terra · 52.5200° N · 13.4050° E', + xTwitter: 'X / Twitter', + sisterProjects: 'Projetos irmãos', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', }, it: { summary: "L'alternativa ufficiale open source e locale-first a Claude Design. Apache-2.0, BYOK a ogni livello.", catalog: 'Catalogo', + products: 'Prodotti', + resources: 'Risorse', official: 'Pagina sorgente ufficiale', quickstart: 'Avvio rapido', agents: 'Agent', @@ -4099,11 +4167,17 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< rss: 'RSS', bottomLeft: '● Open Design · Apache-2.0 · 2026 / Volume 01 / Numero 26', bottomRight: 'Berlino / Aperto / Terra · 52.5200° N · 13.4050° E', + xTwitter: 'X / Twitter', + sisterProjects: 'Progetti correlati', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', }, vi: { summary: 'Lựa chọn chính thức, mã nguồn mở và ưu tiên cục bộ thay Claude Design. Apache-2.0, BYOK ở mọi lớp.', catalog: 'Danh mục', + products: 'Sản phẩm', + resources: 'Tài nguyên', official: 'Trang nguồn chính thức', quickstart: 'Bắt đầu nhanh', agents: 'Agent', @@ -4118,11 +4192,17 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< rss: 'RSS', bottomLeft: '● Open Design · Apache-2.0 · 2026 / Tập 01 / Số 26', bottomRight: 'Berlin / Mở / Trái đất · 52.5200° N · 13.4050° E', + xTwitter: 'X / Twitter', + sisterProjects: 'Dự án liên quan', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', }, pl: { summary: 'Oficjalna, otwartoźródłowa i lokalna alternatywa dla Claude Design. Apache-2.0, BYOK na każdej warstwie.', catalog: 'Katalog', + products: 'Produkty', + resources: 'Zasoby', official: 'Oficjalna strona źródłowa', quickstart: 'Szybki start', agents: 'Agenci', @@ -4137,11 +4217,17 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< rss: 'RSS', bottomLeft: '● Open Design · Apache-2.0 · 2026 / Tom 01 / Numer 26', bottomRight: 'Berlin / Otwarte / Ziemia · 52.5200° N · 13.4050° E', + xTwitter: 'X / Twitter', + sisterProjects: 'Projekty siostrzane', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', }, id: { summary: 'Alternatif resmi, sumber terbuka, dan mengutamakan lokal untuk Claude Design. Apache-2.0, BYOK di setiap lapisan.', catalog: 'Katalog', + products: 'Produk', + resources: 'Sumber daya', official: 'Halaman sumber resmi', quickstart: 'Mulai cepat', agents: 'Agent', @@ -4156,11 +4242,17 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< rss: 'RSS', bottomLeft: '● Open Design · Apache-2.0 · 2026 / Volume 01 / Edisi Nº 26', bottomRight: 'Berlin / Terbuka / Bumi · 52.5200° N · 13.4050° E', + xTwitter: 'X / Twitter', + sisterProjects: 'Proyek terkait', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', }, nl: { summary: 'Het officiële open-source en local-first alternatief voor Claude Design. Apache-2.0, BYOK in elke laag.', catalog: 'Catalogus', + products: 'Producten', + resources: 'Bronnen', official: 'Officiële bronpagina', quickstart: 'Snelstart', agents: 'Agents', @@ -4175,11 +4267,17 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< rss: 'RSS', bottomLeft: '● Open Design · Apache-2.0 · 2026 / Volume 01 / Editie Nº 26', bottomRight: 'Berlijn / Open / Aarde · 52.5200° N · 13.4050° E', + xTwitter: 'X / Twitter', + sisterProjects: 'Zusterprojecten', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', }, ar: { summary: 'البديل الرسمي مفتوح المصدر والمحلي أولاً لـ Claude Design. Apache-2.0 وBYOK في كل طبقة.', catalog: 'الفهرس', + products: 'المنتجات', + resources: 'الموارد', official: 'صفحة المصدر الرسمية', quickstart: 'البدء السريع', agents: 'الوكلاء', @@ -4194,11 +4292,17 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< rss: 'RSS', bottomLeft: '● Open Design · Apache-2.0 · 2026 / المجلد 01 / العدد 26', bottomRight: 'برلين / مفتوح / الأرض · 52.5200° N · 13.4050° E', + xTwitter: 'X / Twitter', + sisterProjects: 'المشاريع الشقيقة', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', }, tr: { summary: "Claude Design için resmi, açık kaynak ve yerel öncelikli alternatif. Apache-2.0, her katmanda BYOK.", catalog: 'Katalog', + products: 'Ürünler', + resources: 'Kaynaklar', official: 'Resmi kaynak sayfası', quickstart: 'Hızlı başlangıç', agents: 'Agentlar', @@ -4210,7 +4314,11 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< contributors: 'Katkıda bulunanlar', releases: 'Sürümler', discord: 'Discord', + xTwitter: 'X / Twitter', rss: 'RSS', + sisterProjects: 'Kardeş projeler', + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', bottomLeft: '● Open Design · Apache-2.0 · 2026 / Cilt 01 / Sayı Nº 26', bottomRight: 'Berlin / Açık / Dünya · 52.5200° N · 13.4050° E', }, @@ -4218,6 +4326,8 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< summary: 'Офіційна відкрита та локально орієнтована альтернатива Claude Design. Apache-2.0, BYOK на кожному рівні.', catalog: 'Каталог', + products: 'Продукти', + resources: 'Ресурси', official: 'Офіційна сторінка джерела', quickstart: 'Швидкий старт', agents: 'Агенти', @@ -4229,7 +4339,11 @@ const LOCALIZED_LANDING_FOOTER_COPY: Partial< contributors: 'Учасники', releases: 'Релізи', discord: 'Discord', + xTwitter: 'X / Twitter', rss: 'RSS', + sisterProjects: "Пов'язані проєкти", + htmlAnything: 'HTML Anything', + nexuIo: 'nexu.io', bottomLeft: '● Open Design · Apache-2.0 · 2026 / Том 01 / Випуск № 26', bottomRight: 'Берлін / Відкрито / Земля · 52.5200° N · 13.4050° E', }, diff --git a/apps/landing-page/app/sub-pages.css b/apps/landing-page/app/sub-pages.css index eadf162f9..63337c38a 100644 --- a/apps/landing-page/app/sub-pages.css +++ b/apps/landing-page/app/sub-pages.css @@ -1035,10 +1035,18 @@ body.sub-page { } .sub-footer-grid { display: grid; - grid-template-columns: 1.6fr 1fr 1fr 1fr; - gap: 48px; + grid-template-columns: 1.4fr 1fr 1fr 1fr 1fr; + gap: 40px; margin-bottom: 36px; } +@media (max-width: 1080px) { + /* At medium widths, drop to a 3-column grid (brand + two columns + per row, since `.sub-footer-brand` carries no `grid-column` span) + so no column collapses to a single line of unrecognizable text. + With 5 children that flows as: row 1 = brand · Products · Plugins, + row 2 = Resources · Connect · empty cell. */ + .sub-footer-grid { grid-template-columns: 1.6fr repeat(2, 1fr); } +} .sub-footer-brand .brand { text-decoration: none; color: var(--ink); From 76c7d31c53835b3937fbb48b547517707713d88c Mon Sep 17 00:00:00 2001 From: Caprika <56862773+alchemistklk@users.noreply.github.com> Date: Fri, 29 May 2026 14:41:17 +0800 Subject: [PATCH 15/56] chore: bump vela cli to 0.0.4 (#3239) * chore: bump vela cli to 0.0.4-test.0 * chore: refresh lockfile for vela cli 0.0.4-test.0 * chore(nix): refresh pnpm deps hash * fix: materialize electron before mac release checks * fix: rebuild electron when mac framework links are invalid * revert: drop release workflow experiments * chore(nix): refresh pnpm deps hash * fix: stop blocking beta mac release on electron symlink preflight * fix: stop using custom electron dist for beta mac packaging * fix: guard oversized chat images and opencode overflow * chore: bump vela cli to 0.0.4 * chore(nix): refresh pnpm deps hash * fix(daemon): surface prompt-image stat failures instead of dropping them resolveSafePromptImagePaths only swallowed unresolvable path input; once a path was confirmed inside UPLOAD_DIR and existed, a statSync failure (EACCES/EPERM, a file vanishing mid-run) silently dropped the image and let the run continue without that prompt context. Since this helper is now also the 1 MB enforcement point, that turned an infra/validation failure into a 'successful' run with missing required context. Collect those into a new failedImages bucket and fail the run with INTERNAL_ERROR at the call site, mirroring the oversized-image guard. Add a unit test covering statSync throwing. --------- Co-authored-by: open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com> Co-authored-by: lefarcen <935902669@qq.com> --- .github/workflows/release-beta.yml | 14 ++- apps/daemon/src/server.ts | 114 +++++++++++++++++-- apps/daemon/tests/chat-route.test.ts | 30 +++++ apps/daemon/tests/server-image-paths.test.ts | 56 ++++++++- nix/pnpm-deps.nix | 4 +- pnpm-lock.yaml | 18 +-- tools/pack/package.json | 2 +- tools/pack/src/mac/builder.ts | 1 - 8 files changed, 211 insertions(+), 28 deletions(-) diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 60d29c7f0..27b6c08f6 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -122,11 +122,12 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Verify mac Electron framework symlinks + - name: Inspect mac Electron framework symlinks run: | set -euo pipefail electron_dist="$(node -e 'const path = require("node:path"); const { createRequire } = require("node:module"); const requireFromDesktop = createRequire(path.join(process.cwd(), "apps/desktop/package.json")); const electron = requireFromDesktop.resolve("electron"); process.stdout.write(path.join(path.dirname(electron), "dist"));')" framework="$electron_dist/Electron.app/Contents/Frameworks/Electron Framework.framework" + missing_links=0 for link in \ "$framework/Electron Framework" \ "$framework/Helpers" \ @@ -134,12 +135,15 @@ jobs: "$framework/Resources" \ "$framework/Versions/Current"; do if [ ! -L "$link" ]; then - echo "Expected Electron framework symlink, got non-symlink: $link" >&2 - ls -la "$framework" >&2 || true - ls -la "$framework/Versions" >&2 || true - exit 1 + echo "::warning::Expected Electron framework symlink, got non-symlink: $link" + missing_links=1 fi done + if [ "$missing_links" -ne 0 ]; then + ls -la "$framework" >&2 || true + ls -la "$framework/Versions" >&2 || true + echo "Continuing into tools-pack because electron-builder is the source of truth for whether packaging actually works." + fi - name: Prepare Apple signing certificate env: diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index acb0ed328..0b0b20501 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -1054,6 +1054,56 @@ export function resolveSafeProjectAttachments(cwd, attachments, opts = {}) { return out; } +export function resolveSafePromptImagePaths(imagePaths, opts = {}) { + if (!Array.isArray(imagePaths) || imagePaths.length === 0) { + return { safeImages: [], oversizedImages: [], failedImages: [] }; + } + const pathImpl = opts.pathImpl ?? path; + const existsSync = opts.existsSync ?? fs.existsSync; + const statSync = opts.statSync ?? fs.statSync; + const uploadDir = pathImpl.resolve(opts.uploadDir ?? UPLOAD_DIR); + const maxBytes = Number.isFinite(opts.maxBytes) + ? Number(opts.maxBytes) + : MAX_CHAT_IMAGE_BYTES; + const safeImages = []; + const oversizedImages = []; + const failedImages = []; + + for (const inputPath of imagePaths) { + if (typeof inputPath !== 'string' || inputPath.length === 0) continue; + let resolved; + try { + resolved = pathImpl.resolve(inputPath); + } catch { + // Drop malformed path input; we cannot even resolve it to a location. + continue; + } + if (!isPathWithin(uploadDir, resolved) || !existsSync(resolved)) continue; + // Past the within-UPLOAD_DIR + existence gate the path points at a real + // upload. A statSync failure here (EACCES/EPERM, a file that vanished + // mid-run) is an infrastructure error, not bad input — surface it so the + // run fails loudly instead of silently dropping required prompt context. + let stat; + try { + stat = statSync(resolved); + } catch (err) { + failedImages.push({ + path: inputPath, + error: err instanceof Error ? err.message : String(err), + }); + continue; + } + if (!stat.isFile()) continue; + if (typeof stat.size === 'number' && stat.size > maxBytes) { + oversizedImages.push({ path: inputPath, sizeBytes: stat.size }); + continue; + } + safeImages.push(inputPath); + } + + return { safeImages, oversizedImages, failedImages }; +} + function resolveProcessResourcesPath() { if ( typeof process.resourcesPath === 'string' && @@ -3124,6 +3174,24 @@ function createSseErrorPayload(code, message, init = {}) { return { message, error: createCompatApiError(code, message, init) }; } +const MAX_CHAT_IMAGE_BYTES = 1024 * 1024; + +function rewriteKnownAgentStreamError(agentId, message, failureText = '') { + const rawMessage = + typeof message === 'string' && message.trim() + ? message.trim() + : 'Agent stream error'; + const combined = `${rawMessage}\n${failureText}`; + if ( + /bufio\.scanner:\s*token too long/i.test(combined) && + /opencode/i.test(combined) && + (agentId === 'opencode' || agentId === 'amr' || /json-rpc id \d+/i.test(combined)) + ) { + return 'The run failed due to an unknown upstream streaming error. Please retry.'; + } + return rawMessage; +} + function createAmrModelUnavailablePayload(model, init = {}) { const modelText = typeof model === 'string' && model.trim() ? `"${model.trim()}"` @@ -10648,13 +10716,24 @@ export async function startServer({ } if (run.cancelRequested || design.runs.isTerminal(run.status)) return; - // Sanitise supplied image paths: must live under UPLOAD_DIR. - const safeImages = imagePaths.filter((p) => { - const resolved = path.resolve(p); - return ( - resolved.startsWith(UPLOAD_DIR + path.sep) && fs.existsSync(resolved) + // Sanitise supplied image paths: must live under UPLOAD_DIR and stay + // below the prompt-image safety cap. + const { safeImages, oversizedImages, failedImages } = + resolveSafePromptImagePaths(imagePaths); + if (oversizedImages.length > 0) { + return design.runs.fail( + run, + 'BAD_REQUEST', + 'Image attachments must be 1 MB or smaller.', ); - }); + } + if (failedImages.length > 0) { + return design.runs.fail( + run, + 'INTERNAL_ERROR', + 'Failed to read one or more image attachments.', + ); + } const amrStagedImages = def.id === 'amr' ? await stageAmrImagePaths(cwd ?? PROJECT_ROOT, safeImages, UPLOAD_DIR) @@ -11937,14 +12016,18 @@ export async function startServer({ const sendAgentEvent = (ev) => { if (ev?.type === 'error') { if (agentStreamError) return; - agentStreamError = String(ev.message || 'Agent stream error'); - clearInactivityWatchdog(); const failureText = [ - agentStreamError, + String(ev.message || 'Agent stream error'), typeof ev.raw === 'string' ? ev.raw : '', agentStdoutTail, agentStderrTail, ].join('\n'); + agentStreamError = rewriteKnownAgentStreamError( + agentId, + String(ev.message || 'Agent stream error'), + failureText, + ); + clearInactivityWatchdog(); const authFailure = classifyAgentAuthFailure(agentId, failureText); if (authFailure?.status === 'missing') { send('error', createSseErrorPayload( @@ -12374,6 +12457,19 @@ export async function startServer({ detail || 'The model service returned an error.', { retryable: true }, )); + } else { + const rewritten = rewriteKnownAgentStreamError( + def.id, + (agentStderrTail || agentStdoutTail || '').trim(), + `${agentStderrTail}\n${agentStdoutTail}`, + ); + if (rewritten !== 'Agent stream error') { + send('error', createSseErrorPayload( + 'AGENT_EXECUTION_FAILED', + rewritten, + { retryable: true }, + )); + } } } // Reconcile any HTML artifacts that were written during this run diff --git a/apps/daemon/tests/chat-route.test.ts b/apps/daemon/tests/chat-route.test.ts index 50531aa2c..5d627d8f3 100644 --- a/apps/daemon/tests/chat-route.test.ts +++ b/apps/daemon/tests/chat-route.test.ts @@ -216,6 +216,36 @@ process.exit(0); ); }); + it('rewrites the OpenCode scanner overflow into a generic retry message', async () => { + const conversationId = `conv-${randomUUID()}`; + + await withFakeAgent( + 'opencode', + ` +process.stderr.write('json-rpc id 4: opencode event stream: read opencode SSE: bufio.Scanner: token too long\\n'); +process.exit(1); +`, + async () => { + const response = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: 'opencode', + conversationId, + message: 'hello', + }), + }); + const body = await response.text(); + + expect(response.ok).toBe(true); + expect(body).toContain('AGENT_EXECUTION_FAILED'); + expect(body).toContain('The run failed due to an unknown upstream streaming error. Please retry.'); + expect(body).toContain('event: stderr'); + expect(body).toContain('"status":"failed"'); + }, + ); + }); + it('retries transient AMR Link catalog failures before aborting startup', async () => { const previousRuntimeKey = process.env.VELA_RUNTIME_KEY; const previousLinkUrl = process.env.VELA_LINK_URL; diff --git a/apps/daemon/tests/server-image-paths.test.ts b/apps/daemon/tests/server-image-paths.test.ts index a2661e359..2e9ef5ac3 100644 --- a/apps/daemon/tests/server-image-paths.test.ts +++ b/apps/daemon/tests/server-image-paths.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import { selectPromptImagePaths } from '../src/server.js'; +import { resolveSafePromptImagePaths, selectPromptImagePaths } from '../src/server.js'; test('selectPromptImagePaths uses staged AMR paths in prompt text', () => { expect( @@ -21,3 +21,57 @@ test('selectPromptImagePaths keeps original paths for non-AMR agents', () => { ), ).toEqual(['/tmp/od-uploads/original.png']); }); + +test('resolveSafePromptImagePaths rejects images larger than 1 MB', () => { + const result = resolveSafePromptImagePaths( + ['/tmp/od-uploads/too-large.png', '/tmp/od-uploads/ok.png'], + { + uploadDir: '/tmp/od-uploads', + existsSync: () => true, + statSync: (inputPath: string) => ({ + isFile: () => true, + size: inputPath.endsWith('too-large.png') ? 1024 * 1024 + 1 : 1024, + }), + }, + ); + + expect(result.safeImages).toEqual(['/tmp/od-uploads/ok.png']); + expect(result.oversizedImages).toEqual([ + { path: '/tmp/od-uploads/too-large.png', sizeBytes: 1024 * 1024 + 1 }, + ]); +}); + +test('resolveSafePromptImagePaths keeps images at or below 1 MB', () => { + const result = resolveSafePromptImagePaths( + ['/tmp/od-uploads/exactly-1mb.png'], + { + uploadDir: '/tmp/od-uploads', + existsSync: () => true, + statSync: () => ({ + isFile: () => true, + size: 1024 * 1024, + }), + }, + ); + + expect(result.safeImages).toEqual(['/tmp/od-uploads/exactly-1mb.png']); + expect(result.oversizedImages).toEqual([]); +}); + +test('resolveSafePromptImagePaths surfaces stat failures instead of dropping the image', () => { + const result = resolveSafePromptImagePaths(['/tmp/od-uploads/unreadable.png'], { + uploadDir: '/tmp/od-uploads', + existsSync: () => true, + statSync: () => { + throw Object.assign(new Error('EACCES: permission denied'), { + code: 'EACCES', + }); + }, + }); + + expect(result.safeImages).toEqual([]); + expect(result.oversizedImages).toEqual([]); + expect(result.failedImages).toEqual([ + { path: '/tmp/od-uploads/unreadable.png', error: 'EACCES: permission denied' }, + ]); +}); diff --git a/nix/pnpm-deps.nix b/nix/pnpm-deps.nix index a4d0fb097..9d12e8fc8 100644 --- a/nix/pnpm-deps.nix +++ b/nix/pnpm-deps.nix @@ -9,6 +9,6 @@ # 1. Temporarily set the consuming `hash = lib.fakeHash;` # 2. Run the relevant nix build/flake check # 3. Copy the expected hash printed by Nix into the matching field below - daemonHash = "sha256-d7vCfXgAVtJzs+esw7zRtWquLJxQml2Y++UbnYWOuNk="; - webHash = "sha256-kSBYrAkEKu7vB+2AQ+rrxINhP1iJut0alFuaa5RAqLM="; + daemonHash = "sha256-nSMVyVSHfcXV5fLMXM3tfdQxZRb+FNF6P4iuJw/Z8Mo="; + webHash = "sha256-QOufFb3Hb5js3jK6QEl3WfnxNAa4DdZfMKoALTHY4hI="; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c2ff8b14..e2f825bfc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -601,8 +601,8 @@ importers: version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(jsdom@29.1.1)(vite@7.3.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)) optionalDependencies: '@powerformer/vela-cli': - specifier: 0.0.3 - version: 0.0.3 + specifier: 0.0.4 + version: 0.0.4 tools/serve: dependencies: @@ -1642,13 +1642,13 @@ packages: '@posthog/types@1.374.2': resolution: {integrity: sha512-ZghQSFMi+HFJNPvPjBoyY/jWQ+q6mSQVtWQxOHMSbBidUZjsyYbxYxBFbHy2qWLNe4mEpX+Wqir2Q4I/4AVvJQ==} - '@powerformer/vela-cli-darwin-arm64@0.0.3': - resolution: {integrity: sha512-tNFNiYVMfp64SHojvpcVV8zIw5RHgaqu8tLcI7SZdk1zS3dAv1gZ20ayebBpxWWAoj2wNMsuA6W006mRRRyjdQ==} + '@powerformer/vela-cli-darwin-arm64@0.0.4': + resolution: {integrity: sha512-PleB0cl42Iv7s+TrhsBFu1KZ0p3tQa+Qpm5PAS21p0INUU6kQ9H45DJ6bKB/CAoGuhq5lPL1XT3NdkLPe+V46A==} cpu: [arm64] os: [darwin] - '@powerformer/vela-cli@0.0.3': - resolution: {integrity: sha512-ZUrFNFJ/2gvSmQbXLPqRZCeeOR/bNyj0pxhJwKHxiAbGQWVNlTDFy9lDzyID3gD+UH3VMZacmbvSPQyhuRyxAQ==} + '@powerformer/vela-cli@0.0.4': + resolution: {integrity: sha512-63QzbvQ3JRkFwHf9cETAn4WTkbIjYc48Z4Et+pYFRyIwu3CtUUBxsPy93k/L9I1AcXOGjyJT7vFMm1gfs8amew==} hasBin: true '@rollup/pluginutils@5.3.0': @@ -6043,12 +6043,12 @@ snapshots: '@posthog/types@1.374.2': {} - '@powerformer/vela-cli-darwin-arm64@0.0.3': + '@powerformer/vela-cli-darwin-arm64@0.0.4': optional: true - '@powerformer/vela-cli@0.0.3': + '@powerformer/vela-cli@0.0.4': optionalDependencies: - '@powerformer/vela-cli-darwin-arm64': 0.0.3 + '@powerformer/vela-cli-darwin-arm64': 0.0.4 optional: true '@rollup/pluginutils@5.3.0(rollup@4.60.2)': diff --git a/tools/pack/package.json b/tools/pack/package.json index 816de8f56..cc6610a9b 100644 --- a/tools/pack/package.json +++ b/tools/pack/package.json @@ -22,7 +22,7 @@ "electron-builder": "26.8.1" }, "optionalDependencies": { - "@powerformer/vela-cli": "0.0.3" + "@powerformer/vela-cli": "0.0.4" }, "devDependencies": { "@types/node": "24.12.2", diff --git a/tools/pack/src/mac/builder.ts b/tools/pack/src/mac/builder.ts index 4f9b185a3..1bfa7dfe9 100644 --- a/tools/pack/src/mac/builder.ts +++ b/tools/pack/src/mac/builder.ts @@ -104,7 +104,6 @@ export async function runElectronBuilder( iconSize: 96, title: identity.installerTitle, }, - electronDist: config.electronDistPath, electronVersion: config.electronVersion, executableName: identity.executableName, extraMetadata: { From 755d84e64c8fc9c724e8931dec87d348cc22910c Mon Sep 17 00:00:00 2001 From: lefarcen <935902669@qq.com> Date: Fri, 29 May 2026 14:51:38 +0800 Subject: [PATCH 16/56] feat(web): merge Draw + Screenshot into one Studio mark tool (#3081) (#3277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forward-ports chaoxiaoche's Studio toolbar work from #3081 onto current main. The preview toolbar drops to 4 controls — Comment, Mark (the merged Draw/Screenshot tool with box-select + pen sub-tools), Edit, Comments — matching the latest design. The standalone Screenshot button and its copy-to-clipboard path are removed; capture now flows through the mark overlay. Also carries #3081's comment select-all/clear-selection panel and keeps the Draw send guard added in #3270 (Send disabled mid-run, Queue stays). Reconciled with main work that postdates #3081's base so nothing is lost: - Preserves #2190's preview iframe keep-alive pool and the AnnotationHoverPopover hover card (re-added on top of #3081's BoardComposerPopover, with its own anchor helper so it doesn't clash with the composer popover anchoring). - i18n: keeps every locale key main added; adopts #3081's mark wording. Behavior change: the comment side-panel Clear now deselects instead of batch-deleting selected comments (per #3081); per-comment delete and send-selected remain. Validation: pnpm --filter @open-design/web typecheck (clean), full web vitest (2354 passed), pnpm guard. Co-authored-by: chaoxiaoche <fanzhen910412@gmail.com> --- apps/web/src/comments.ts | 5 +- .../src/components/BoardComposerPopover.tsx | 256 ++++++--- apps/web/src/components/DesignFilesPanel.tsx | 45 +- apps/web/src/components/FileViewer.tsx | 529 +++++++++--------- apps/web/src/components/ManualEditPanel.tsx | 10 +- .../web/src/components/PreviewDrawOverlay.tsx | 295 ++++++++-- apps/web/src/i18n/locales/ar.ts | 7 +- apps/web/src/i18n/locales/de.ts | 7 +- apps/web/src/i18n/locales/en.ts | 7 +- apps/web/src/i18n/locales/es-ES.ts | 7 +- apps/web/src/i18n/locales/fa.ts | 7 +- apps/web/src/i18n/locales/fr.ts | 7 +- apps/web/src/i18n/locales/hu.ts | 7 +- apps/web/src/i18n/locales/id.ts | 7 +- apps/web/src/i18n/locales/it.ts | 7 +- apps/web/src/i18n/locales/ja.ts | 7 +- apps/web/src/i18n/locales/ko.ts | 7 +- apps/web/src/i18n/locales/pl.ts | 7 +- apps/web/src/i18n/locales/pt-BR.ts | 7 +- apps/web/src/i18n/locales/ru.ts | 7 +- apps/web/src/i18n/locales/th.ts | 7 +- apps/web/src/i18n/locales/tr.ts | 7 +- apps/web/src/i18n/locales/uk.ts | 7 +- apps/web/src/i18n/locales/zh-CN.ts | 7 +- apps/web/src/i18n/locales/zh-TW.ts | 7 +- apps/web/src/i18n/types.ts | 5 + apps/web/src/runtime/srcdoc.ts | 50 +- apps/web/src/styles/viewer/core.css | 168 ++++-- apps/web/src/styles/viewer/memory.css | 97 ++-- .../BoardComposerPopover.pod-remove.test.tsx | 112 +++- apps/web/tests/components/FileViewer.test.tsx | 311 ++++++++-- .../tests/components/ManualEditPanel.test.tsx | 13 +- .../components/PreviewDrawOverlay.test.tsx | 57 +- apps/web/tests/edit-mode/bridge.test.ts | 19 + 34 files changed, 1502 insertions(+), 603 deletions(-) diff --git a/apps/web/src/comments.ts b/apps/web/src/comments.ts index 78f59d7b4..ba2904ec4 100644 --- a/apps/web/src/comments.ts +++ b/apps/web/src/comments.ts @@ -101,12 +101,13 @@ export function targetFromSnapshot(snapshot: PreviewCommentSnapshot): PreviewCom export function overlayBoundsFromSnapshot( snapshot: PreviewCommentSnapshot, scale: number, + offset: { x: number; y: number } = { x: 0, y: 0 }, ): CommentOverlayBounds { const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1; const position = normalizePosition(snapshot.position); return { - left: position.x * safeScale, - top: position.y * safeScale, + left: offset.x + position.x * safeScale, + top: offset.y + position.y * safeScale, width: Math.max(1, position.width * safeScale), height: Math.max(1, position.height * safeScale), }; diff --git a/apps/web/src/components/BoardComposerPopover.tsx b/apps/web/src/components/BoardComposerPopover.tsx index d65862807..268387fb9 100644 --- a/apps/web/src/components/BoardComposerPopover.tsx +++ b/apps/web/src/components/BoardComposerPopover.tsx @@ -43,6 +43,8 @@ function compactFontFamily(value: string | undefined): string | null { } type AnnotationStyleRow = { label: string; value: string; swatch?: string }; +type PopoverBounds = { width: number; height: number }; +type PopoverOffset = { x: number; y: number }; function annotationStyleRows(target: PreviewCommentSnapshot): AnnotationStyleRow[] { const rows: AnnotationStyleRow[] = []; @@ -73,15 +75,95 @@ function clampPopoverCoordinate(value: number, min: number): number { return Math.max(min, Math.round(value)); } -function popoverAnchorStyle(target: PreviewCommentSnapshot, scale: number): CSSProperties { +function clampPopoverRange(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.max(min, Math.min(max, Math.round(value))); +} + +function popoverAnchorStyle( + target: PreviewCommentSnapshot, + scale: number, + bounds?: PopoverBounds, + offset: PopoverOffset = { x: 0, y: 0 }, + expanded = true, +): CSSProperties { const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1; const anchor = target.hoverPoint ?? { x: target.position.x + Math.min(target.position.width, 24), y: target.position.y + Math.min(target.position.height, 24), }; + const pad = 14; + const overlapOffset = 8; + const width = 320; + const estimatedHeight = expanded ? 252 : 112; + const anchorX = offset.x + anchor.x * safeScale; + const anchorY = offset.y + anchor.y * safeScale; + const preferredLeft = clampPopoverCoordinate(anchorX + pad, pad); + const preferredTop = clampPopoverCoordinate(anchorY + pad, pad); + if (bounds?.width && bounds.width > 0) { + const position = target.position; + const rect = { + left: offset.x + position.x * safeScale, + top: offset.y + position.y * safeScale, + width: Math.max(1, position.width * safeScale), + height: Math.max(1, position.height * safeScale), + }; + const rectRight = rect.left + rect.width; + const rectBottom = rect.top + rect.height; + const viewportWidth = bounds.width; + const viewportHeight = bounds.height || Number.POSITIVE_INFINITY; + const maxLeft = Math.max(pad, viewportWidth - width - pad); + const maxTop = Number.isFinite(viewportHeight) + ? Math.max(pad, viewportHeight - estimatedHeight - pad) + : preferredTop; + const spaces = [ + { side: 'top' as const, space: rect.top - pad, fits: rect.top - pad >= estimatedHeight }, + { side: 'bottom' as const, space: viewportHeight - rectBottom - pad, fits: viewportHeight - rectBottom - pad >= estimatedHeight }, + { side: 'left' as const, space: rect.left - pad, fits: rect.left - pad >= width }, + { side: 'right' as const, space: viewportWidth - rectRight - pad, fits: viewportWidth - rectRight - pad >= width }, + ]; + const sorted = spaces + .filter((item) => Number.isFinite(item.space)) + .sort((a, b) => Number(b.fits) - Number(a.fits) || b.space - a.space); + const side = sorted[0]?.side ?? 'bottom'; + const centerLeft = rect.left + rect.width / 2 - width / 2; + const centerTop = rect.top + rect.height / 2 - estimatedHeight / 2; + if (side === 'top' && sorted[0]?.fits) { + return { + left: clampPopoverRange(centerLeft, pad, maxLeft), + top: clampPopoverRange(rect.top - estimatedHeight - pad, pad, maxTop), + }; + } + if (side === 'bottom' && sorted[0]?.fits) { + return { + left: clampPopoverRange(centerLeft, pad, maxLeft), + top: clampPopoverRange(rectBottom + pad, pad, maxTop), + }; + } + if (side === 'left' && sorted[0]?.fits) { + return { + left: clampPopoverRange(rect.left - width - pad, pad, maxLeft), + top: clampPopoverRange(centerTop, pad, maxTop), + }; + } + if (side === 'right' && sorted[0]?.fits) { + return { + left: clampPopoverRange(rectRight + pad, pad, maxLeft), + top: clampPopoverRange(centerTop, pad, maxTop), + }; + } + return { + left: clampPopoverRange( + anchorX + pad + width <= viewportWidth - pad ? anchorX + pad : anchorX - width - pad, + pad, + maxLeft, + ), + top: clampPopoverRange(anchorY + overlapOffset, pad, maxTop), + }; + } return { - left: clampPopoverCoordinate(anchor.x * safeScale + 14, 14), - top: clampPopoverCoordinate(anchor.y * safeScale + 14, 14), + left: preferredLeft, + top: preferredTop, }; } @@ -109,13 +191,25 @@ export function AnnotationStyleSummary({ ); } +function annotationHoverAnchorStyle(target: PreviewCommentSnapshot, scale: number): CSSProperties { + const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1; + const anchor = target.hoverPoint ?? { + x: target.position.x + Math.min(target.position.width, 24), + y: target.position.y + Math.min(target.position.height, 24), + }; + return { + left: clampPopoverCoordinate(anchor.x * safeScale + 14, 14), + top: clampPopoverCoordinate(anchor.y * safeScale + 14, 14), + }; +} + export function AnnotationHoverPopover({ target, scale }: { target: PreviewCommentSnapshot; scale: number }) { return ( <div className="comment-popover annotation-hover-popover" data-testid="annotation-hover-popover" role="tooltip" - style={popoverAnchorStyle(target, scale)} + style={annotationHoverAnchorStyle(target, scale)} > <AnnotationStyleSummary target={target} testId="annotation-hover-style-summary" /> </div> @@ -135,10 +229,14 @@ export function BoardComposerPopover({ onSendBatch, onRemoveMember, onHoverMember, + onDeleteComment, sending, t, scale = 1, + bounds, + offset, docked = false, + commenting = true, }: { target: PreviewCommentSnapshot; existing: PreviewComment | null; @@ -152,12 +250,17 @@ export function BoardComposerPopover({ onSendBatch: () => void | Promise<void>; onRemoveMember: (elementId: string) => void; onHoverMember?: (elementId: string | null) => void; + onDeleteComment?: (commentId: string) => void | Promise<void>; sending: boolean; t: TranslateFn; scale?: number; + bounds?: PopoverBounds; + offset?: PopoverOffset; docked?: boolean; + commenting?: boolean; }) { const pendingCount = notes.length + (draft.trim() ? 1 : 0); + const hasCommentChange = !existing || draft.trim() !== existing.note.trim(); const podMembers = target.podMembers ?? []; return ( <div @@ -166,7 +269,7 @@ export function BoardComposerPopover({ role="dialog" aria-modal="false" aria-label="Annotation" - style={docked ? undefined : popoverAnchorStyle(target, scale)} + style={docked ? undefined : popoverAnchorStyle(target, scale, bounds, offset, commenting)} onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); @@ -174,6 +277,9 @@ export function BoardComposerPopover({ } }} > + <section className="comment-popover-section comment-popover-section-params"> + <AnnotationStyleSummary target={target} testId="comment-popover-style-summary" /> + </section> {podMembers.length > 0 ? ( <div className="board-pod-summary"> <strong>{t('chat.comments.capturedItems', { n: target.memberCount || podMembers.length })}</strong> @@ -208,71 +314,87 @@ export function BoardComposerPopover({ </div> </div> ) : null} - {notes.length > 0 ? ( - <div className="board-note-list"> - {notes.map((note, index) => ( - <div key={`${target.elementId}-${index}`} className="board-note-item"> - <span>{note}</span> - <button type="button" className="ghost" onClick={() => onRemoveQueuedNote(index)}> - {t('chat.comments.remove')} + {commenting ? ( + <section className="comment-popover-section comment-popover-section-compose"> + {notes.length > 0 ? ( + <div className="board-note-list"> + {notes.map((note, index) => ( + <div key={`${target.elementId}-${index}`} className="board-note-item"> + <span>{note}</span> + <button type="button" className="ghost" onClick={() => onRemoveQueuedNote(index)}> + {t('chat.comments.remove')} + </button> + </div> + ))} + </div> + ) : null} + <textarea + data-testid="comment-popover-input" + value={draft} + autoFocus + aria-label={t('chat.comments.placeholder')} + placeholder={t('chat.comments.placeholder')} + onChange={(event) => onDraft(event.target.value)} + /> + <div className="comment-popover-actions"> + <div className="comment-popover-actions-start"> + {existing && onDeleteComment ? ( + <button + type="button" + className="comment-popover-close comment-popover-delete" + onClick={() => void onDeleteComment(existing.id)} + title={t('common.delete')} + aria-label={t('common.delete')} + > + <Icon name="trash" size={13} /> + </button> + ) : ( + <button + type="button" + className="comment-popover-close" + onClick={onClose} + title={t('common.close')} + aria-label={t('common.close')} + > + <Icon name="close" size={12} /> + </button> + )} + </div> + <div className="comment-popover-actions-end"> + {target.selectionKind === 'pod' ? ( + <button + type="button" + className="ghost" + data-testid="comment-popover-add-note" + disabled={!draft.trim()} + onClick={onAddDraft} + > + {t('chat.comments.addNote')} + </button> + ) : ( + <button + type="button" + className="ghost" + data-testid="comment-popover-save" + disabled={!draft.trim() || !hasCommentChange} + onClick={() => void onSaveComment()} + > + {t('chat.comments.comment')} + </button> + )} + <button + type="button" + className="primary" + data-testid="comment-add-send" + disabled={pendingCount === 0 || sending} + onClick={() => void onSendBatch()} + > + {sending ? t('chat.comments.sending') : t('chat.comments.sendToChat')} </button> </div> - ))} - </div> + </div> + </section> ) : null} - <textarea - data-testid="comment-popover-input" - value={draft} - autoFocus - aria-label={t('chat.comments.placeholder')} - placeholder={t('chat.comments.placeholder')} - onChange={(event) => onDraft(event.target.value)} - /> - <div className="comment-popover-actions"> - <div className="comment-popover-actions-start"> - <button - type="button" - className="comment-popover-close" - onClick={onClose} - title={t('common.close')} - aria-label={t('common.close')} - > - <Icon name="close" size={12} /> - </button> - </div> - <div className="comment-popover-actions-end"> - {target.selectionKind === 'pod' ? ( - <button - type="button" - className="ghost" - data-testid="comment-popover-add-note" - disabled={!draft.trim()} - onClick={onAddDraft} - > - {t('chat.comments.addNote')} - </button> - ) : ( - <button - type="button" - className="ghost" - data-testid="comment-popover-save" - disabled={!draft.trim()} - onClick={() => void onSaveComment()} - > - {t('chat.comments.comment')} - </button> - )} - <button - type="button" - className="primary" - data-testid="comment-add-send" - disabled={pendingCount === 0 || sending} - onClick={() => void onSendBatch()} - > - {sending ? t('chat.comments.sending') : t('chat.comments.sendToChat')} - </button> - </div> - </div> </div> ); } diff --git a/apps/web/src/components/DesignFilesPanel.tsx b/apps/web/src/components/DesignFilesPanel.tsx index 30daea6ab..b1aa91d5c 100644 --- a/apps/web/src/components/DesignFilesPanel.tsx +++ b/apps/web/src/components/DesignFilesPanel.tsx @@ -3,7 +3,8 @@ import { useAnalytics } from '../analytics/provider'; import { trackFileManagerClick } from '../analytics/events'; import { useT } from '../i18n'; import type { Dict } from '../i18n/types'; -import { projectFileUrl } from '../providers/registry'; +import { projectFileUrl, projectRawUrl } from '../providers/registry'; +import { buildSrcdoc } from '../runtime/srcdoc'; import type { LiveArtifactWorkspaceEntry, ProjectFile, ProjectFileKind } from '../types'; import { createFileSystemReadError, @@ -1498,7 +1499,7 @@ function DfPreview({ ) : file.kind === 'image' || file.kind === 'sketch' ? ( <img src={`${url}?v=${Math.round(file.mtime)}`} alt={file.name} /> ) : file.kind === 'html' ? ( - <iframe title={file.name} src={url} sandbox="allow-scripts" /> + <HtmlPreviewThumbnail projectId={projectId} file={file} /> ) : file.kind === 'video' ? ( <video src={`${url}?v=${Math.round(file.mtime)}`} @@ -1561,6 +1562,46 @@ function DfPreview({ ); } +function HtmlPreviewThumbnail({ + projectId, + file, +}: { + projectId: string; + file: ProjectFile; +}) { + const url = projectFileUrl(projectId, file.name); + const [srcDoc, setSrcDoc] = useState<string | null>(null); + useEffect(() => { + let cancelled = false; + void fetch(`${url}?v=${Math.round(file.mtime)}`) + .then((response) => (response.ok ? response.text() : null)) + .then((html) => { + if (cancelled || html === null) return; + setSrcDoc(buildSrcdoc(html, { baseHref: projectRawUrl(projectId, baseDirForFile(file.name)) })); + }) + .catch(() => { + if (!cancelled) setSrcDoc(null); + }); + return () => { + cancelled = true; + }; + }, [file.mtime, file.name, projectId, url]); + + return ( + <iframe + title={file.name} + src={srcDoc ? undefined : url} + srcDoc={srcDoc ?? undefined} + sandbox="allow-scripts allow-downloads" + /> + ); +} + +function baseDirForFile(name: string): string { + const index = name.lastIndexOf('/'); + return index >= 0 ? name.slice(0, index + 1) : ''; +} + function kindSortPriority(kind: ProjectFileKind): number { if (kind === 'html') return 0; if (kind === 'text') return 1; diff --git a/apps/web/src/components/FileViewer.tsx b/apps/web/src/components/FileViewer.tsx index 4f75247c2..67c118e12 100644 --- a/apps/web/src/components/FileViewer.tsx +++ b/apps/web/src/components/FileViewer.tsx @@ -64,8 +64,6 @@ import { exportReactComponentAsZip, openSandboxedPreviewInNewTab, requestPreviewSnapshot, - requestPreviewSnapshotResult, - type PreviewSnapshotResult, } from '../runtime/exports'; import { buildReactComponentSrcdoc } from '../runtime/react-component'; import { findHtmlEntriesReferencing } from '../runtime/jsx-module-refs'; @@ -240,6 +238,8 @@ type InspectTarget = { const MAX_CACHED_SLIDE_STATES = 64; const htmlPreviewSlideState = new Map<string, SlideState>(); +const MAX_CACHED_PREVIEW_VIEWPORTS = 128; +const htmlPreviewViewportState = new Map<string, PreviewViewportId>(); const MARKDOWN_CODE_BLOCK_ATTR = 'data-markdown-code-block'; const MARKDOWN_COPY_BLOCK_ATTR = 'data-copy-code-block'; const MARKDOWN_COPY_BUTTON_CLASS = 'markdown-code-copy'; @@ -515,6 +515,27 @@ export function effectivePreviewScale( return Math.min(previewScale, fitScale); } +type PreviewOverlayTransform = { scale: number; offsetX: number; offsetY: number }; + +export function previewOverlayTransform( + viewport: PreviewViewportId, + previewScale: number, + canvasSize?: PreviewCanvasSize, +): PreviewOverlayTransform { + const scale = effectivePreviewScale(viewport, previewScale, canvasSize); + if (viewport === 'desktop') return { scale, offsetX: 0, offsetY: 0 }; + const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport); + const pad = 24; + if (!preset?.width || !preset.height) return { scale, offsetX: pad, offsetY: pad }; + const availableWidth = Math.max(1, (canvasSize?.width ?? preset.width * scale + pad * 2) - pad * 2); + const scaledWidth = preset.width * scale; + return { + scale, + offsetX: pad + Math.max(0, (availableWidth - scaledWidth) / 2), + offsetY: pad, + }; +} + function previewScaleShellStyle( viewport: PreviewViewportId, previewScale: number, @@ -551,65 +572,6 @@ function manualEditPreviewShellStyle( return previewScaleShellStyle(viewport, previewScale); } -async function previewSnapshotDataUrlToBlob(dataUrl: string): Promise<Blob> { - const response = await fetch(dataUrl); - return response.blob(); -} - -async function previewSnapshotBlobFromIframes( - iframes: Array<HTMLIFrameElement | null>, - t: (key: keyof Dict, vars?: Record<string, string | number>) => string, -): Promise<{ blob: Blob; fallback?: PreviewSnapshotResult }> { - let fallback: PreviewSnapshotResult | undefined; - for (const iframe of iframes) { - if (!iframe) { - fallback ??= { ok: false, reason: 'loading' }; - continue; - } - const result = await requestPreviewSnapshotResultWithRetries(iframe); - if (result.ok) { - return { blob: await previewSnapshotDataUrlToBlob(result.snapshot.dataUrl), fallback }; - } - fallback ??= result; - } - throw new Error(snapshotFailureMessage(fallback, t)); -} - -async function requestPreviewSnapshotResultWithRetries( - iframe: HTMLIFrameElement, -): Promise<PreviewSnapshotResult> { - let last: PreviewSnapshotResult | null = null; - const timeouts = [750, 1500, 3000, 8000]; - for (const timeout of timeouts) { - const result = await requestPreviewSnapshotResult(iframe, timeout); - if (result.ok) return result; - last = result; - if (result.reason === 'render-error' || result.reason === 'post-message-error') return result; - } - return last ?? { ok: false, reason: 'timeout' }; -} - -function snapshotFailureMessage( - result: PreviewSnapshotResult | undefined, - t: (key: keyof Dict, vars?: Record<string, string | number>) => string, -): string { - if (!result) return t('fileViewer.screenshotCaptureFailed'); - if (!result.ok && result.reason === 'loading') return t('fileViewer.screenshotPreviewLoading'); - return t('fileViewer.screenshotCaptureFailed'); -} - -function clipboardFailureMessage( - err: unknown, - t: (key: keyof Dict, vars?: Record<string, string | number>) => string, -): string { - const message = err instanceof Error ? err.message : String(err || ''); - if (/clipboard|notallowed|permission|denied|write/i.test(message)) { - return t('fileViewer.screenshotClipboardDenied'); - } - if (message === t('fileViewer.screenshotPreviewLoading')) return message; - return message || t('fileViewer.screenshotCaptureFailed'); -} - function manualEditFloatingPanelStyle( target: ManualEditTarget, previewScale: number, @@ -701,6 +663,18 @@ function setSlideStateCached(key: string, state: SlideState) { } } +function previewViewportStateKey(projectId: string, file: Pick<ProjectFile, 'name' | 'path'>): string { + return `${projectId}:${file.path || file.name}`; +} + +function setPreviewViewportCached(key: string, viewport: PreviewViewportId) { + htmlPreviewViewportState.set(key, viewport); + if (htmlPreviewViewportState.size > MAX_CACHED_PREVIEW_VIEWPORTS) { + const oldest = htmlPreviewViewportState.keys().next().value; + if (oldest != null) htmlPreviewViewportState.delete(oldest); + } +} + interface Props { projectId: string; projectKind: TrackingProjectKind; @@ -844,7 +818,14 @@ export function LiveArtifactViewer({ const [loading, setLoading] = useState(true); const [reloadKey, setReloadKey] = useState(0); const [zoom, setZoom] = useState(100); - const [previewViewport, setPreviewViewport] = useState<PreviewViewportId>('desktop'); + const liveArtifactViewportKey = `${projectId}:live-artifact:${liveArtifact.artifactId}`; + const [previewViewport, setPreviewViewportState] = useState<PreviewViewportId>( + () => htmlPreviewViewportState.get(liveArtifactViewportKey) ?? 'desktop', + ); + const setPreviewViewport = useCallback((viewport: PreviewViewportId) => { + setPreviewViewportCached(liveArtifactViewportKey, viewport); + setPreviewViewportState(viewport); + }, [liveArtifactViewportKey]); const [previewBodyRef, previewBodySize] = usePreviewCanvasSize<HTMLDivElement>(); const iframeRef = useRef<HTMLIFrameElement | null>(null); const [refreshing, setRefreshing] = useState(false); @@ -853,6 +834,8 @@ export function LiveArtifactViewer({ const [refreshEvents, setRefreshEvents] = useState<LiveArtifactRefreshEvent[]>([]); const [refreshHistory, setRefreshHistory] = useState<LiveArtifactRefreshLogEntry[]>([]); const [presentMenuOpen, setPresentMenuOpen] = useState(false); + const [zoomMenuOpen, setZoomMenuOpen] = useState(false); + const zoomMenuRef = useRef<HTMLDivElement | null>(null); const [inTabPresent, setInTabPresent] = useState(false); const presentWrapRef = useRef<HTMLDivElement | null>(null); const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null); @@ -885,6 +868,10 @@ export function LiveArtifactViewer({ setRefreshEvents([]); }, [projectId, liveArtifact.artifactId]); + useEffect(() => { + setPreviewViewportState(htmlPreviewViewportState.get(liveArtifactViewportKey) ?? 'desktop'); + }, [liveArtifactViewportKey]); + useEffect(() => { if (!refreshSuccess) return; const timeout = window.setTimeout(() => setRefreshSuccess(null), 6000); @@ -1010,10 +997,6 @@ export function LiveArtifactViewer({ }); }, [mode, previewUrl, liveArtifact.artifactId, projectId]); - function bumpZoom(delta: number) { - setZoom((z) => Math.max(25, Math.min(200, z + delta))); - } - async function handleRefresh() { if (refreshing) return; setRefreshing(true); @@ -1077,6 +1060,23 @@ export function LiveArtifactViewer({ return () => document.removeEventListener('keydown', onKey); }, [inTabPresent]); + useEffect(() => { + if (!zoomMenuOpen) return; + const onDocClick = (e: MouseEvent) => { + if (!zoomMenuRef.current) return; + if (!zoomMenuRef.current.contains(e.target as Node)) setZoomMenuOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setZoomMenuOpen(false); + }; + document.addEventListener('mousedown', onDocClick); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDocClick); + document.removeEventListener('keydown', onKey); + }; + }, [zoomMenuOpen]); + return ( <div className={`viewer html-viewer live-artifact-viewer${inTabPresent ? ' is-tab-present' : ''}`}> {((node: ReactNode) => ( @@ -1161,35 +1161,40 @@ export function LiveArtifactViewer({ tabIndex={mode === 'preview' ? 0 : -1} /> <span className="viewer-divider" aria-hidden /> - <button - type="button" - className="icon-only" - onClick={() => bumpZoom(-25)} - title={t('fileViewer.zoomOut')} - aria-label={t('fileViewer.zoomOut')} - tabIndex={mode === 'preview' ? 0 : -1} - > - <Icon name="minus" size={14} /> - </button> - <button - type="button" - className="viewer-action viewer-zoom-level" - onClick={() => setZoom(100)} - title={t('fileViewer.resetZoom')} - tabIndex={mode === 'preview' ? 0 : -1} - > - <span style={{ fontVariantNumeric: 'tabular-nums' }}>{zoom}%</span> - </button> - <button - type="button" - className="icon-only" - onClick={() => bumpZoom(25)} - title={t('fileViewer.zoomIn')} - aria-label={t('fileViewer.zoomIn')} - tabIndex={mode === 'preview' ? 0 : -1} - > - <Icon name="plus" size={14} /> - </button> + <div className="zoom-menu viewer-toolbar-zoom" ref={zoomMenuRef}> + <button + type="button" + className="viewer-action zoom-trigger" + aria-haspopup="menu" + aria-expanded={zoomMenuOpen} + title={t('fileViewer.resetZoom')} + tabIndex={mode === 'preview' ? 0 : -1} + onClick={() => setZoomMenuOpen((v) => !v)} + > + <span style={{ fontVariantNumeric: 'tabular-nums' }}>{zoom}%</span> + </button> + {zoomMenuOpen && mode === 'preview' ? ( + <div className="zoom-menu-popover" role="menu"> + {[50, 75, 100, 125, 150, 200].map((level) => ( + <button + key={level} + type="button" + className={`zoom-menu-item${zoom === level ? ' active' : ''}`} + role="menuitem" + onClick={() => { + setZoom(level); + setZoomMenuOpen(false); + }} + > + <span style={{ fontVariantNumeric: 'tabular-nums' }}>{level}%</span> + {zoom === level ? ( + <Icon name="check" size={13} /> + ) : null} + </button> + ))} + </div> + ) : null} + </div> <span className="viewer-divider" aria-hidden /> <a className="ghost-link" @@ -2008,6 +2013,30 @@ function formatCommentTime(ts: number, t: TranslateFn): string { return new Date(ts).toLocaleDateString(); } +function commentActivityAt(comment: PreviewComment): number { + return Math.max( + Number.isFinite(comment.updatedAt) ? comment.updatedAt : 0, + Number.isFinite(comment.createdAt) ? comment.createdAt : 0, + ); +} + +function commentTargetIntersectsPreview( + target: PreviewCommentSnapshot | null, + scale: number, + offset: { x: number; y: number }, + bounds?: PreviewCanvasSize, +): boolean { + if (!target || !bounds?.width || !bounds.height) return true; + const rect = overlayBoundsFromSnapshot(target, scale, offset); + const margin = 8; + return ( + rect.left + rect.width > margin && + rect.top + rect.height > margin && + rect.left < bounds.width - margin && + rect.top < bounds.height - margin + ); +} + function commentDisplayLabel(comment: PreviewComment, t: TranslateFn): string { if (comment.elementId.startsWith('pin-')) return t('chat.comments.pin'); const label = String(comment.label || '').trim().toLowerCase(); @@ -2032,6 +2061,7 @@ export function CommentSidePanel({ onCollapsedChange, onClose, onToggleSelect, + onSelectAll, onClearSelection, onReply, onSendSelected, @@ -2047,6 +2077,7 @@ export function CommentSidePanel({ onCollapsedChange: (collapsed: boolean) => void; onClose: () => void; onToggleSelect: (commentId: string) => void; + onSelectAll: () => void; onClearSelection: () => void; onReply: (comment: PreviewComment) => void; onSendSelected: () => void | Promise<void>; @@ -2056,9 +2087,10 @@ export function CommentSidePanel({ composer?: ReactNode; }) { const [newCommentDraft, setNewCommentDraft] = useState(''); - const sorted = [...comments].sort((a, b) => b.createdAt - a.createdAt); + const sorted = [...comments].sort((a, b) => commentActivityAt(b) - commentActivityAt(a)); const visibleSelectedIds = new Set(comments.filter((comment) => selectedIds.has(comment.id)).map((comment) => comment.id)); const selectedCount = visibleSelectedIds.size; + const allSelected = comments.length > 0 && selectedCount === comments.length; const commentsLabel = t('chat.tabComments'); const canCreateComment = Boolean(onCreateComment) && newCommentDraft.trim().length > 0 && !sending; const submitNewComment = async () => { @@ -2090,6 +2122,17 @@ export function CommentSidePanel({ <RemixIcon name="message-3-line" size={15} /> <span>{commentsLabel}</span> </div> + <div className="comment-side-header-actions"> + {comments.length > 0 ? ( + <button + type="button" + className="comment-side-select-all" + disabled={allSelected} + onClick={onSelectAll} + > + {t('chat.comments.selectAll')} + </button> + ) : null} <button type="button" className="comment-side-close" @@ -2099,6 +2142,7 @@ export function CommentSidePanel({ > <Icon name="close" size={12} /> </button> + </div> </div> <div className="comment-side-list"> {sorted.length === 0 ? ( @@ -2115,31 +2159,34 @@ export function CommentSidePanel({ data-testid="comment-side-item" data-comment-id={comment.id} aria-current={active ? 'true' : undefined} + role="button" + tabIndex={0} + onClick={() => onReply(comment)} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onReply(comment); + }} > <div className="comment-side-item-head"> <span className="comment-side-author"> <strong>{`${index + 1}. ${commentDisplayLabel(comment, t)}`}</strong> </span> - <span className="comment-side-time">{formatCommentTime(comment.createdAt, t)}</span> + <span className="comment-side-time">{formatCommentTime(commentActivityAt(comment), t)}</span> <button type="button" className={`comment-side-check${selected ? ' checked' : ''}`} aria-label={selected ? t('chat.comments.deselect') : t('chat.comments.select')} aria-pressed={selected} - onClick={() => onToggleSelect(comment.id)} + onClick={(event) => { + event.stopPropagation(); + onToggleSelect(comment.id); + }} > {selected ? <Icon name="check" size={11} /> : null} </button> </div> <div className="comment-side-body">{comment.note}</div> - <button - type="button" - className="comment-side-reply" - data-testid="comment-side-edit" - onClick={() => onReply(comment)} - > - {t('chat.comments.edit')} - </button> </div> ); })} @@ -2938,6 +2985,8 @@ function CommentPreviewOverlays({ boardTool, showActivePin = false, scale, + offsetX, + offsetY, strokePoints, onOpenComment, }: { @@ -2949,9 +2998,12 @@ function CommentPreviewOverlays({ boardTool: BoardTool; showActivePin?: boolean; scale: number; + offsetX: number; + offsetY: number; strokePoints: StrokePoint[]; onOpenComment: (comment: PreviewComment, snapshot: PreviewCommentSnapshot) => void; }) { + const overlayOffset = { x: offsetX, y: offsetY }; const visibleComments = comments .map((comment, index) => ({ comment, @@ -2971,7 +3023,7 @@ function CommentPreviewOverlays({ return ( <div className="comment-overlay-layer" aria-hidden={false}> {visibleComments.map(({ comment, index, snapshot }) => { - const bounds = overlayBoundsFromSnapshot(snapshot, scale); + const bounds = overlayBoundsFromSnapshot(snapshot, scale, overlayOffset); const label = commentTargetDisplayName(comment); return ( <div @@ -3006,6 +3058,7 @@ function CommentPreviewOverlays({ <CommentTargetOverlay snapshot={targetOverlay} scale={scale} + offset={overlayOffset} selected={Boolean(activeTarget)} hoveredMemberId={hoveredPodMemberId} /> @@ -3013,7 +3066,7 @@ function CommentPreviewOverlays({ {showActivePin && activeTarget ? ( <div className="comment-active-pin" - style={activeCommentPinStyle(activeTarget, scale)} + style={activeCommentPinStyle(activeTarget, scale, overlayOffset)} data-testid="comment-active-pin" aria-hidden="true" > @@ -3023,7 +3076,7 @@ function CommentPreviewOverlays({ {boardTool === 'pod' && strokePoints.length > 1 ? ( <svg className="board-pod-stroke"> <polyline - points={strokePoints.map((point) => `${point.x * scale},${point.y * scale}`).join(' ')} + points={strokePoints.map((point) => `${offsetX + point.x * scale},${offsetY + point.y * scale}`).join(' ')} /> </svg> ) : null} @@ -3031,36 +3084,43 @@ function CommentPreviewOverlays({ ); } -function activeCommentPinStyle(target: PreviewCommentSnapshot, scale: number): CSSProperties { +function activeCommentPinStyle( + target: PreviewCommentSnapshot, + scale: number, + offset: { x: number; y: number } = { x: 0, y: 0 }, +): CSSProperties { const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1; const anchor = target.hoverPoint ?? { x: target.position.x, y: target.position.y, }; return { - left: Math.round(anchor.x * safeScale), - top: Math.round(anchor.y * safeScale), + left: Math.round(offset.x + anchor.x * safeScale), + top: Math.round(offset.y + anchor.y * safeScale), }; } export function CommentTargetOverlay({ snapshot, scale, + offset, selected, hoveredMemberId, }: { snapshot: PreviewCommentSnapshot; scale: number; + offset?: { x: number; y: number }; selected: boolean; hoveredMemberId?: string | null; }) { + const overlayOffset = offset ?? { x: 0, y: 0 }; const displayMembers = podDisplayMembers(snapshot); if (displayMembers.length > 0) { const overlayWeights = podOverlayWeights(displayMembers); return ( <> {displayMembers.map((member, index) => { - const bounds = overlayBoundsFromSnapshot(member, scale); + const bounds = overlayBoundsFromSnapshot(member, scale, overlayOffset); const width = Math.round(member.position.width); const height = Math.round(member.position.height); const overlayWeight = overlayWeights[index] ?? { @@ -3095,7 +3155,7 @@ export function CommentTargetOverlay({ // Non-member fallback: single-element snapshots have no per-member chips, // so the hover-focus channel never reaches this branch — no is-hover-focused // class needed here. - const bounds = overlayBoundsFromSnapshot(snapshot, scale); + const bounds = overlayBoundsFromSnapshot(snapshot, scale, overlayOffset); return ( <div className={`comment-target-overlay${selected ? ' selected' : ''}`} @@ -3939,7 +3999,14 @@ function HtmlViewer({ const [source, setSource] = useState<string | null>(liveHtml ?? null); const [inlinedSource, setInlinedSource] = useState<string | null>(null); const [zoom, setZoom] = useState(100); - const [previewViewport, setPreviewViewport] = useState<PreviewViewportId>('desktop'); + const fileViewportKey = previewViewportStateKey(projectId, file); + const [previewViewport, setPreviewViewportState] = useState<PreviewViewportId>( + () => htmlPreviewViewportState.get(fileViewportKey) ?? 'desktop', + ); + const setPreviewViewport = useCallback((viewport: PreviewViewportId) => { + setPreviewViewportCached(fileViewportKey, viewport); + setPreviewViewportState(viewport); + }, [fileViewportKey]); const [zoomMenuOpen, setZoomMenuOpen] = useState(false); const zoomMenuRef = useRef<HTMLDivElement | null>(null); const [presentMenuOpen, setPresentMenuOpen] = useState(false); @@ -3952,6 +4019,10 @@ function HtmlViewer({ const [templateNote, setTemplateNote] = useState<string | null>(null); const [templateModalOpen, setTemplateModalOpen] = useState(false); const [templateName, setTemplateName] = useState(''); + + useEffect(() => { + setPreviewViewportState(htmlPreviewViewportState.get(fileViewportKey) ?? 'desktop'); + }, [fileViewportKey]); const [templateDescription, setTemplateDescription] = useState(''); const [templateSaveError, setTemplateSaveError] = useState<string | null>(null); const [deployment, setDeployment] = useState<WebDeploymentInfo | null>(null); @@ -3984,9 +4055,6 @@ function HtmlViewer({ const [inspectMode, setInspectMode] = useState(false); const [agentToolsOpen, setAgentToolsOpen] = useState(false); const [drawOverlayOpen, setDrawOverlayOpen] = useState(false); - const [drawOverlayIntent, setDrawOverlayIntent] = useState<'draw' | 'screenshot'>('draw'); - const [screenshotCaptureActive, setScreenshotCaptureActive] = useState(false); - const [screenshotToast, setScreenshotToast] = useState<string | null>(null); // for hint managing hint box state const [openHintBox, setOpenHintBox] = useState(true); const [manualEditMode, setManualEditModeRaw] = useState(false); @@ -4327,7 +4395,8 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ const [slideState, setSlideState] = useState<SlideState | null>( () => htmlPreviewSlideState.get(previewStateKey) ?? null, ); - const overlayPreviewScale = effectivePreviewScale(previewViewport, previewScale, previewBodySize); + const overlayPreviewTransform = previewOverlayTransform(previewViewport, previewScale, previewBodySize); + const overlayPreviewScale = overlayPreviewTransform.scale; const shareRef = useRef<HTMLDivElement | null>(null); const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null); useEffect(() => { @@ -4445,7 +4514,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ editMode: manualEditMode, urlModeBridge, inspectMode, - drawMode: drawOverlayOpen || screenshotCaptureActive, + drawMode: drawOverlayOpen, forceInline: forceInline || needsSandboxShim, needsFocusGuard, }); @@ -4536,7 +4605,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ // Tweaks, etc.), mount the real artifact HTML directly so we do not depend on // a postMessage activation that can race (#2253) and strand the iframe blank // (#2361, #2791). - const captureModeActive = drawOverlayOpen || screenshotCaptureActive; + const captureModeActive = drawOverlayOpen; const useLazySrcDocTransport = !manualEditMode && !captureModeActive && useUrlLoadPreview; const srcDocTransportContent = useLazySrcDocTransport ? lazySrcDocTransport : srcDoc; const urlTransportSrc = useUrlLoadPreview ? activePreviewSrcUrl : 'about:blank'; @@ -4960,7 +5029,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ current ? current.selectionKind === 'pod' ? current - : next.get(current.elementId) ?? null + : next.get(current.elementId) ?? current : null )); setHoveredCommentTarget((current) => ( @@ -4972,6 +5041,18 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ )); return; } + if (data.type === 'od:comment-active-target-update') { + const snapshot = snapshotFromData(data); + if (!snapshot.elementId) return; + setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot)); + setActiveCommentTarget((current) => ( + current && current.elementId === snapshot.elementId ? snapshot : current + )); + setHoveredCommentTarget((current) => ( + current && current.elementId === snapshot.elementId ? snapshot : current + )); + return; + } if (data.type === 'od:comment-leave') { setHoveredCommentTarget(null); return; @@ -4986,19 +5067,13 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ if (data.type === 'od:comment-target') { const snapshot = snapshotFromData(data); if (!snapshot.elementId) return; - const existing = previewComments.find((comment) => - comment.filePath === file.name && - comment.status === 'open' && - comment.elementId === snapshot.elementId, - ); const shouldOpenComposer = boardMode || commentCreateMode; setActiveCommentTarget((current) => (shouldOpenComposer ? snapshot : current)); setHoveredCommentTarget(snapshot); setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot)); - if (boardMode && shouldOpenComposer) { - setCommentCreateMode(true); - setActivePreviewCommentId(existing?.id ?? null); - setCommentDraft(existing?.note ?? ''); + if (shouldOpenComposer) { + setActivePreviewCommentId(null); + setCommentDraft(''); setQueuedBoardNotes([]); } return; @@ -5043,6 +5118,15 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ return () => window.removeEventListener('message', onMessage); }, [activeCommentTarget, boardMode, boardTool, commentPortalHost, file.name, isOurPreviewIframeSource, previewComments]); + useEffect(() => { + if (!boardMode || !activeCommentTarget || activeCommentTarget.selectionKind === 'pod') return; + iframeRef.current?.contentWindow?.postMessage({ + type: 'od:comment-active-target', + elementId: activeCommentTarget.elementId, + selector: activeCommentTarget.selector, + }, '*'); + }, [activeCommentTarget?.elementId, activeCommentTarget?.selector, activeCommentTarget?.selectionKind, boardMode]); + useEffect(() => { if (!manualEditMode) { setManualEditTargets([]); @@ -5203,11 +5287,18 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ setManualEditError(null); } + function refreshSrcDocPreviewAfterManualEditExit() { + activatedSrcDocTransportHtmlRef.current = null; + setSrcDocShellReady(false); + setSrcDocTransportResetKey((key) => key + 1); + } + async function exitManualEditModeAfterFlush(): Promise<boolean> { const ok = await flushManualEditStyleSave(); if (!ok) return false; setManualEditPanelPosition(null); setManualEditMode(false); + refreshSrcDocPreviewAfterManualEditExit(); return true; } @@ -5219,6 +5310,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ setManualEditDraft(emptyManualEditDraft(sourceRef.current ?? '')); setManualEditError(null); setManualEditMode(false); + refreshSrcDocPreviewAfterManualEditExit(); postSelectedManualEditTargetToIframe(null); } @@ -5918,7 +6010,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ function activateDrawTool() { fireArtifactToolbarClick('draw'); - const next = !(drawOverlayOpen && drawOverlayIntent === 'draw'); + const next = !drawOverlayOpen; if (!next) { setDrawOverlayOpen(false); setAgentToolsOpen(false); @@ -5930,7 +6022,6 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ setBoardMode(false); clearBoardComposer(); setInspectMode(false); - setDrawOverlayIntent('draw'); setMode('preview'); setDrawOverlayOpen(true); closeArtifactToolMenus(); @@ -5944,52 +6035,6 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ activateDraw(); } - function activateScreenshotTool() { - fireArtifactToolbarClick('draw'); - const activateScreenshot = async () => { - setCommentPanelOpen(false); - setCommentCreateMode(false); - setBoardMode(false); - clearBoardComposer(); - setInspectMode(false); - setDrawOverlayIntent('screenshot'); - setMode('preview'); - setScreenshotToast(t('fileViewer.screenshotCopying')); - setDrawOverlayOpen(false); - closeArtifactToolMenus(); - setScreenshotCaptureActive(true); - try { - await new Promise<void>((resolve) => window.setTimeout(resolve, 0)); - const srcDocIframe = srcDocPreviewIframeRef.current; - if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') { - setScreenshotToast(t('fileViewer.screenshotClipboardDenied')); - return; - } - const activeIframe = iframeRef.current; - const { blob } = await previewSnapshotBlobFromIframes([ - srcDocIframe, - activeIframe === srcDocIframe ? null : activeIframe, - ], t); - await navigator.clipboard.write([ - new ClipboardItem({ 'image/png': blob }), - ]); - setScreenshotToast(t('fileViewer.screenshotCopied')); - } catch (err) { - console.warn('[screenshot] failed to copy preview snapshot:', err); - setScreenshotToast(clipboardFailureMessage(err, t)); - } finally { - setScreenshotCaptureActive(false); - } - }; - if (manualEditMode) { - void exitManualEditModeAfterFlush().then((ok) => { - if (ok) void activateScreenshot(); - }); - return; - } - void activateScreenshot(); - } - function activateCommentTool() { fireArtifactToolbarClick('comment'); capturePreviewScrollPosition(); @@ -6005,7 +6050,6 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ setCommentCreateMode(false); clearBoardComposer(); setInspectMode(false); - setDrawOverlayIntent('draw'); setDrawOverlayOpen(false); setMode('preview'); activateBoard('inspect'); @@ -6035,9 +6079,8 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ setCommentPanelOpen(true); setCommentSidePanelCollapsed(false); setCommentCreateMode(true); - clearBoardComposer(); + if (!activeCommentTarget) clearBoardComposer(); setInspectMode(false); - setDrawOverlayIntent('draw'); setDrawOverlayOpen(false); setMode('preview'); activateBoard('inspect'); @@ -6061,7 +6104,6 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ setBoardMode(false); clearBoardComposer(); setInspectMode(false); - setDrawOverlayIntent('draw'); setDrawOverlayOpen(false); setMode('preview'); setManualEditViewportWidth(previewBodyRef.current?.clientWidth ?? null); @@ -6153,12 +6195,6 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ } } - useEffect(() => { - if (!screenshotToast) return; - const id = window.setTimeout(() => setScreenshotToast(null), 2200); - return () => window.clearTimeout(id); - }, [screenshotToast]); - const showPresent = source !== null; const canShare = source !== null; const exportTitle = file.name.replace(/\.html?$/i, '') || file.name; @@ -6183,13 +6219,15 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ const visibleSideComments = useMemo( () => previewComments .filter((comment) => comment.filePath === file.name && comment.status === 'open') - .sort((a, b) => b.createdAt - a.createdAt), + .sort((a, b) => commentActivityAt(b) - commentActivityAt(a)), [file.name, previewComments], ); - const activeSideCommentId = activePreviewCommentId ?? ( - activeCommentTarget - ? visibleSideComments.find((comment) => comment.elementId === activeCommentTarget.elementId)?.id ?? null - : null + const activeSideCommentId = activePreviewCommentId; + const activeCommentTargetVisible = commentTargetIntersectsPreview( + activeCommentTarget, + overlayPreviewScale, + { x: overlayPreviewTransform.offsetX, y: overlayPreviewTransform.offsetY }, + previewBodySize, ); useEffect(() => { if (!boardMode || !activePreviewCommentId) return; @@ -6354,10 +6392,13 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ }} /> ) : null; - const commentComposer = boardMode && activeCommentTarget ? ( + const activeComposerComment = activePreviewCommentId + ? visibleSideComments.find((comment) => comment.id === activePreviewCommentId) ?? null + : null; + const commentComposer = boardMode && activeCommentTarget && activeCommentTargetVisible ? ( <BoardComposerPopover target={activeCommentTarget} - existing={visibleSideComments.find((comment) => comment.elementId === activeCommentTarget.elementId) ?? null} + existing={activeComposerComment} draft={commentDraft} notes={queuedBoardNotes} onDraft={setCommentDraft} @@ -6377,10 +6418,24 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ setHoveredPodMemberId((current) => (current === elementId ? null : current)); }} onHoverMember={setHoveredPodMemberId} + onDeleteComment={onRemovePreviewComment ? async (commentId) => { + await onRemovePreviewComment(commentId); + clearBoardComposer(); + setSelectedSideCommentIds((current) => { + if (!current.has(commentId)) return current; + const next = new Set(current); + next.delete(commentId); + return next; + }); + setActivePreviewCommentId((current) => (current === commentId ? null : current)); + } : undefined} sending={sendingBoardBatch || streaming} t={t} scale={overlayPreviewScale} + offset={{ x: overlayPreviewTransform.offsetX, y: overlayPreviewTransform.offsetY }} + bounds={previewBodySize} docked={false} + commenting /> ) : null; const commentSidePanel = commentPanelOpen ? ( @@ -6393,6 +6448,8 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ onClose={() => { setCommentPanelOpen(false); setCommentSidePanelCollapsed(false); + setCommentCreateMode(false); + setBoardMode(false); clearBoardComposer(); }} onToggleSelect={(commentId) => { @@ -6403,25 +6460,8 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ return next; }); }} - onClearSelection={() => { - if (selectedSideCommentIds.size === 0) return; - if (!onRemovePreviewComment) { - setSelectedSideCommentIds(new Set()); - return; - } - const selectedIds = new Set(selectedSideCommentIds); - const targets = visibleSideComments - .filter((comment) => selectedIds.has(comment.id)) - .map((comment) => comment.id); - if (targets.length === 0) { - setSelectedSideCommentIds(new Set()); - return; - } - void (async () => { - await Promise.allSettled(targets.map((id) => onRemovePreviewComment(id))); - setSelectedSideCommentIds(new Set()); - })(); - }} + onSelectAll={() => setSelectedSideCommentIds(new Set(visibleSideComments.map((comment) => comment.id)))} + onClearSelection={() => setSelectedSideCommentIds(new Set())} onReply={(comment) => { // Reply == edit on a flat-thread model: prefill the // popover with the existing note so the user sees and @@ -6559,44 +6599,32 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ onClick={activateCommentTool} > <RemixIcon name="chat-new-line" size={15} /> - {boardMode && !commentCreateMode && boardTool === 'inspect' ? <span className="viewer-action-active-dot" aria-hidden /> : null} </button> </div> <button - className={`viewer-action viewer-action-icon${manualEditMode ? ' active' : ''}`} - type="button" - data-testid="manual-edit-mode-toggle" - data-tooltip="Edit" - title="Edit" - aria-label="Edit" - aria-pressed={manualEditMode} - onClick={activateManualEditTool} - > - <RemixIcon name="edit-line" size={15} /> - </button> - <button - className={`viewer-action viewer-action-icon${drawOverlayOpen && drawOverlayIntent === 'draw' ? ' active' : ''}`} + className={`viewer-action viewer-action-icon${drawOverlayOpen ? ' active' : ''}`} type="button" data-testid="draw-overlay-toggle" - data-tooltip="Draw" - title="Draw" - aria-label="Draw" - aria-pressed={drawOverlayOpen && drawOverlayIntent === 'draw'} + data-tooltip={t('fileViewer.mark')} + title={t('fileViewer.mark')} + aria-label={t('fileViewer.mark')} + aria-pressed={drawOverlayOpen} onClick={activateDrawTool} > <RemixIcon name="mark-pen-line" size={15} /> </button> + <span className="viewer-toolbar-tool-divider" aria-hidden /> <button - className={`viewer-action viewer-action-icon${drawOverlayOpen && drawOverlayIntent === 'screenshot' ? ' active' : ''}`} + className={`viewer-action viewer-action-icon${manualEditMode ? ' active' : ''}`} type="button" - data-testid="screenshot-capture-toggle" - data-tooltip="Screenshot" - title="Screenshot" - aria-label="Screenshot" - aria-pressed={drawOverlayOpen && drawOverlayIntent === 'screenshot'} - onClick={activateScreenshotTool} + data-testid="manual-edit-mode-toggle" + data-tooltip={t('fileViewer.edit')} + title={t('fileViewer.edit')} + aria-label={t('fileViewer.edit')} + aria-pressed={manualEditMode} + onClick={activateManualEditTool} > - <RemixIcon name="screenshot-2-line" size={15} /> + <RemixIcon name="edit-line" size={15} /> </button> <span className="viewer-toolbar-tool-divider" aria-hidden /> <button @@ -6611,7 +6639,6 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ > <RemixIcon name="message-3-line" size={15} /> <span className="viewer-comment-count" aria-hidden>{visibleSideComments.length}</span> - {boardMode && commentCreateMode ? <span className="viewer-action-active-dot" aria-hidden /> : null} </button> {source !== null && mode === 'preview' ? ( <div className="zoom-menu viewer-toolbar-zoom" ref={zoomMenuRef}> @@ -6963,7 +6990,6 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ > <PreviewDrawOverlay active={drawOverlayOpen} - captureViewport={drawOverlayIntent === 'screenshot'} onActiveChange={setDrawOverlayOpen} captureTarget={null} filePath={file.name} @@ -7093,6 +7119,8 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ boardTool={boardTool} showActivePin={commentCreateMode} scale={overlayPreviewScale} + offsetX={overlayPreviewTransform.offsetX} + offsetY={overlayPreviewTransform.offsetY} strokePoints={strokePoints} onOpenComment={(comment, snapshot) => { setCommentPanelOpen(true); @@ -7134,21 +7162,6 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([ /> </div> ) : null} - {screenshotToast ? ( - <div className="screenshot-toast-anchor"> - <div className="screenshot-toast" role="status" aria-live="polite"> - <RemixIcon name="checkbox-circle-line" size={16} /> - <span>{screenshotToast}</span> - <button - type="button" - aria-label={t('common.close')} - onClick={() => setScreenshotToast(null)} - > - <RemixIcon name="close-line" size={16} /> - </button> - </div> - </div> - ) : null} {commentComposer} {boardMode && !commentCreateMode && hoveredCommentTarget && (!activeCommentTarget || commentPortalHost) ? ( <AnnotationHoverPopover target={hoveredCommentTarget} scale={overlayPreviewScale} /> diff --git a/apps/web/src/components/ManualEditPanel.tsx b/apps/web/src/components/ManualEditPanel.tsx index bb7aad4c4..0cae8ca9c 100644 --- a/apps/web/src/components/ManualEditPanel.tsx +++ b/apps/web/src/components/ManualEditPanel.tsx @@ -32,7 +32,6 @@ export function ManualEditPanel({ onStyleChange, onInvalidStyle, onError, - onClearSelection, onCancelDraft, onSaveDraft, onExit, @@ -164,7 +163,6 @@ export function ManualEditPanel({ targetKind={targetForInspector.kind} styles={draft.styles} layoutEnabled={targetForInspector.isLayoutContainer} - onClearSelection={onClearSelection} onChange={changeTargetStyle} /> ) : !targetForInspector ? ( @@ -568,12 +566,11 @@ function styleLabel(key: keyof ManualEditStyles): string { } function StyleInspector({ - targetKind, styles, layoutEnabled, onClearSelection, onChange, + targetKind, styles, layoutEnabled, onChange, }: { targetKind: ManualEditTarget['kind']; styles: ManualEditStyles; layoutEnabled: boolean; - onClearSelection: () => void; onChange: (key: keyof ManualEditStyles, value: string) => void; }) { const u = (key: keyof ManualEditStyles, value: string) => onChange(key, value); @@ -584,11 +581,6 @@ function StyleInspector({ return ( <div className="cc-inspector"> - <div className="cc-inspector-nav"> - <button type="button" className="cc-inspector-page" onClick={onClearSelection} aria-label="Show page inspector"> - Page - </button> - </div> {showTypography ? ( <Section title="TYPOGRAPHY"> <FontRow value={styles.fontFamily} onChange={(v) => u('fontFamily', v)} /> diff --git a/apps/web/src/components/PreviewDrawOverlay.tsx b/apps/web/src/components/PreviewDrawOverlay.tsx index 7d3750cb6..46498b820 100644 --- a/apps/web/src/components/PreviewDrawOverlay.tsx +++ b/apps/web/src/components/PreviewDrawOverlay.tsx @@ -9,6 +9,8 @@ import { isImeComposing } from '../utils/imeComposing'; interface Point { x: number; y: number } interface Stroke { points: Point[] } +interface NormalizedRect { x: number; y: number; width: number; height: number } +type MarkTool = 'box' | 'pen'; interface CaptureTarget { filePath?: string; elementId?: string; @@ -61,11 +63,15 @@ export function PreviewDrawOverlay({ const wrapRef = useRef<HTMLDivElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null); const [note, setNote] = useState(''); + const [markTool, setMarkTool] = useState<MarkTool>('box'); const strokesRef = useRef<Stroke[]>([]); const undoneStrokesRef = useRef<Stroke[]>([]); const drawingRef = useRef<Stroke | null>(null); + const selectionBoxRef = useRef<NormalizedRect | null>(null); + const boxDraftRef = useRef<{ start: Point; current: Point } | null>(null); const composingRef = useRef(false); const [hasInk, setHasInk] = useState(false); + const [hasBox, setHasBox] = useState(false); const [undoCount, setUndoCount] = useState(0); const [redoCount, setRedoCount] = useState(0); const [pendingAction, setPendingAction] = useState<'queue' | 'send' | null>(null); @@ -88,6 +94,10 @@ export function PreviewDrawOverlay({ ctx.lineCap = 'round'; ctx.lineJoin = 'round'; const all = drawingRef.current ? [...strokesRef.current, drawingRef.current] : strokesRef.current; + const box = boxDraftRef.current + ? normalizedRectFromPoints(boxDraftRef.current.start, boxDraftRef.current.current) + : selectionBoxRef.current; + if (box) drawNormalizedBox(ctx, box, cvs.width, cvs.height); for (const s of all) { const first = s.points[0]; if (!first) continue; @@ -119,7 +129,7 @@ export function PreviewDrawOverlay({ const ro = new ResizeObserver(resize); ro.observe(wrap); return () => ro.disconnect(); - }, [redraw, active, hasInk]); + }, [redraw, active, hasInk, hasBox]); useEffect(() => { function onKey(e: KeyboardEvent) { @@ -139,6 +149,7 @@ export function PreviewDrawOverlay({ function syncHistoryState() { setHasInk(strokesRef.current.length > 0); + setHasBox(Boolean(selectionBoxRef.current)); setUndoCount(strokesRef.current.length); setRedoCount(undoneStrokesRef.current.length); } @@ -164,16 +175,40 @@ export function PreviewDrawOverlay({ function onPointerDown(e: PointerEvent) { if (!active || sending) return; (e.target as Element).setPointerCapture?.(e.pointerId); - drawingRef.current = { points: [pointFromEvent(e)] }; + const point = pointFromEvent(e); + if (markTool === 'box') { + boxDraftRef.current = { start: point, current: point }; + selectionBoxRef.current = null; + syncHistoryState(); + redraw(); + return; + } + drawingRef.current = { points: [point] }; redraw(); } function onPointerMove(e: PointerEvent) { - if (!active || sending || !drawingRef.current) return; + if (!active || sending) return; + if (boxDraftRef.current) { + boxDraftRef.current.current = pointFromEvent(e); + redraw(); + return; + } + if (!drawingRef.current) return; drawingRef.current.points.push(pointFromEvent(e)); redraw(); } - function onPointerUp() { - if (!active || sending || !drawingRef.current) return; + function onPointerUp(e: PointerEvent) { + if (!active || sending) return; + if (boxDraftRef.current) { + boxDraftRef.current.current = pointFromEvent(e); + const next = normalizedRectFromPoints(boxDraftRef.current.start, boxDraftRef.current.current); + boxDraftRef.current = null; + selectionBoxRef.current = next.width >= 0.006 && next.height >= 0.006 ? next : null; + syncHistoryState(); + redraw(); + return; + } + if (!drawingRef.current) return; if (drawingRef.current.points.length > 1) { strokesRef.current.push(drawingRef.current); undoneStrokesRef.current = []; @@ -196,12 +231,21 @@ export function PreviewDrawOverlay({ strokesRef.current = []; undoneStrokesRef.current = []; drawingRef.current = null; + selectionBoxRef.current = null; + boxDraftRef.current = null; syncHistoryState(); redraw(); } function undoStroke() { if (sending) return; + if (selectionBoxRef.current || boxDraftRef.current) { + selectionBoxRef.current = null; + boxDraftRef.current = null; + syncHistoryState(); + redraw(); + return; + } const stroke = strokesRef.current.pop(); if (!stroke) return; undoneStrokesRef.current.push(stroke); @@ -229,10 +273,24 @@ export function PreviewDrawOverlay({ strokesRef.current = []; undoneStrokesRef.current = []; drawingRef.current = null; + selectionBoxRef.current = null; + boxDraftRef.current = null; syncHistoryState(); redraw(); }, [active, redraw]); + function boxBounds(): { x: number; y: number; width: number; height: number } | null { + const rect = canvasRef.current?.getBoundingClientRect(); + const box = selectionBoxRef.current; + if (!rect || rect.width <= 0 || rect.height <= 0 || !box) return null; + return { + x: box.x * rect.width, + y: box.y * rect.height, + width: Math.max(1, box.width * rect.width), + height: Math.max(1, box.height * rect.height), + }; + } + function strokeBounds(): { x: number; y: number; width: number; height: number } | null { const rect = canvasRef.current?.getBoundingClientRect(); if (!rect || rect.width <= 0 || rect.height <= 0) return null; @@ -254,23 +312,25 @@ export function PreviewDrawOverlay({ } function annotationBounds(): { x: number; y: number; width: number; height: number } | undefined { + const box = boxBounds(); const stroke = strokeBounds(); const target = captureTarget?.position ?? null; - if (!stroke && !target) return undefined; - if (!stroke) return target ?? undefined; - if (!target) return stroke; - const left = Math.min(stroke.x, target.x); - const top = Math.min(stroke.y, target.y); - const right = Math.max(stroke.x + stroke.width, target.x + target.width); - const bottom = Math.max(stroke.y + stroke.height, target.y + target.height); + const bounds = [box, stroke, target].filter((item): item is { x: number; y: number; width: number; height: number } => Boolean(item)); + if (bounds.length === 0) return undefined; + if (bounds.length === 1) return bounds[0]; + const left = Math.min(...bounds.map((item) => item.x)); + const top = Math.min(...bounds.map((item) => item.y)); + const right = Math.max(...bounds.map((item) => item.x + item.width)); + const bottom = Math.max(...bounds.map((item) => item.y + item.height)); return { x: left, y: top, width: Math.max(1, right - left), height: Math.max(1, bottom - top) }; } function markKind(): PreviewVisualMarkKind | undefined { const hasTarget = Boolean(captureTarget); - if (hasTarget && hasInk) return 'click+stroke'; + const hasVisualMark = hasInk || hasBox; + if (hasTarget && hasVisualMark) return 'click+stroke'; if (hasTarget) return 'click'; - if (hasInk) return 'stroke'; + if (hasVisualMark) return 'stroke'; return undefined; } @@ -341,6 +401,7 @@ export function PreviewDrawOverlay({ const sx = snap.w / Math.max(1, rect.width); const sy = snap.h / Math.max(1, rect.height); drawCaptureTarget(ctx, sx, sy, captureTarget); + if (selectionBoxRef.current) drawNormalizedBox(ctx, selectionBoxRef.current, snap.w, snap.h); ctx.strokeStyle = STROKE_COLOR; ctx.lineWidth = STROKE_WIDTH * Math.max(sx, sy); ctx.lineCap = 'round'; @@ -361,7 +422,7 @@ export function PreviewDrawOverlay({ async function send(action: 'queue' | 'send') { const hasTarget = Boolean(captureTarget); - const shouldCapture = hasInk || hasTarget || captureViewport; + const shouldCapture = hasInk || hasBox || hasTarget || captureViewport; const canSubmit = shouldCapture || Boolean(note.trim()); if (sending || !canSubmit) return; // While a task is running the primary Send is disabled (use Queue instead). @@ -378,7 +439,7 @@ export function PreviewDrawOverlay({ if (!blob) { setCaptureWarning({ action, - message: captureViewport && !hasInk && !hasTarget + message: captureViewport && !hasInk && !hasBox && !hasTarget ? t('chat.annotationPreviewMissing') : t('chat.annotationPreviewMissingInk'), }); @@ -426,10 +487,10 @@ export function PreviewDrawOverlay({ } const overlayPointer = active ? 'auto' : 'none'; - const showCanvas = active || hasInk; - const canSubmit = hasInk || Boolean(captureTarget) || captureViewport || Boolean(note.trim()); + const showCanvas = active || hasInk || hasBox; + const canSubmit = hasInk || hasBox || Boolean(captureTarget) || captureViewport || Boolean(note.trim()); const canSend = canSubmit && !sendDisabled; - const canUndo = undoCount > 0 && !sending; + const canUndo = (undoCount > 0 || hasBox) && !sending; const canRedo = redoCount > 0 && !sending; return ( @@ -461,6 +522,7 @@ export function PreviewDrawOverlay({ ) : null} {active ? ( <> + <style>{tooltipStyle}</style> {captureWarning ? ( <div role="status" @@ -508,6 +570,42 @@ export function PreviewDrawOverlay({ fontSize: 13, }} > + <button + type="button" + onClick={closeOverlay} + disabled={sending} + aria-label={t('common.close')} + title={t('common.close')} + style={closeButtonStyle} + > + <Icon name="close" size={13} /> + </button> + <div style={subToolGroupStyle} aria-label={t('fileViewer.markTool')}> + <button + type="button" + onClick={() => setMarkTool('box')} + disabled={sending} + aria-label={t('fileViewer.boxSelect')} + title={t('fileViewer.boxSelect')} + data-tooltip={t('fileViewer.boxSelect')} + className="preview-draw-subtool-action" + style={subToolButtonStyle(markTool === 'box')} + > + <RemixIcon name="checkbox-blank-line" size={14} /> + </button> + <button + type="button" + onClick={() => setMarkTool('pen')} + disabled={sending} + aria-label={t('sketch.toolPen')} + title={t('sketch.toolPen')} + data-tooltip={t('sketch.toolPen')} + className="preview-draw-subtool-action" + style={subToolButtonStyle(markTool === 'pen')} + > + <RemixIcon name="pencil-line" size={14} /> + </button> + </div> <button type="button" onClick={undoStroke} @@ -561,51 +659,42 @@ export function PreviewDrawOverlay({ type="button" onClick={() => void send('queue')} disabled={sending || !canSubmit} + aria-label={pendingAction === 'queue' ? t('chat.annotationQueueing') : t('chat.annotationQueue')} + title={pendingAction === 'queue' ? t('chat.annotationQueueing') : t('chat.annotationQueue')} + data-tooltip={pendingAction === 'queue' ? t('chat.annotationQueueing') : t('chat.annotationQueue')} + className="preview-draw-icon-action" style={{ - ...ghostStyle, + ...drawActionButtonStyle(false), opacity: canSubmit ? 1 : 0.4, cursor: sending ? 'wait' : (canSubmit ? 'pointer' : 'not-allowed'), }} > {pendingAction === 'queue' ? ( - <> - <Icon name="spinner" size={12} /> - <span>{t('chat.annotationQueueing')}</span> - </> + <Icon name="spinner" size={14} /> ) : ( - t('chat.annotationQueue') + <RemixIcon name="list-check-2" size={15} /> )} </button> <button type="button" onClick={() => void send('send')} disabled={sending || !canSend} - title={sendDisabled ? sendDisabledReason : undefined} + aria-label={pendingAction === 'send' ? t('chat.annotationSending') : t('chat.send')} + title={sendDisabled ? sendDisabledReason : pendingAction === 'send' ? t('chat.annotationSending') : t('chat.send')} + data-tooltip={sendDisabled ? sendDisabledReason : pendingAction === 'send' ? t('chat.annotationSending') : t('chat.send')} + className="preview-draw-icon-action" style={{ - ...pillStyle(true), + ...drawActionButtonStyle(true), opacity: canSend ? 1 : 0.4, cursor: sending ? 'wait' : (canSend ? 'pointer' : 'not-allowed'), }} > {pendingAction === 'send' ? ( - <> - <Icon name="spinner" size={12} /> - <span>{t('chat.annotationSending')}</span> - </> + <Icon name="spinner" size={14} /> ) : ( - t('chat.send') + <Icon name="send" size={14} /> )} </button> - <button - type="button" - onClick={closeOverlay} - disabled={sending} - aria-label={t('common.close')} - title={t('common.close')} - style={iconButtonStyle} - > - <Icon name="close" size={13} /> - </button> </div> </> ) : null} @@ -613,34 +702,114 @@ export function PreviewDrawOverlay({ ); } -function pillStyle(active: boolean): CSSProperties { +const tooltipStyle = ` + .preview-draw-icon-action, + .preview-draw-subtool-action { + position: relative; + } + .preview-draw-icon-action::after, + .preview-draw-subtool-action::after { + content: attr(data-tooltip); + position: absolute; + z-index: 12; + left: 50%; + bottom: calc(100% + 8px); + transform: translateX(-50%) translateY(2px); + padding: 4px 7px; + border-radius: 6px; + background: rgba(20,20,20,0.94); + color: #fff; + font-size: 11px; + line-height: 1.2; + opacity: 0; + pointer-events: none; + white-space: nowrap; + transition: opacity 140ms cubic-bezier(0.23, 1, 0.32, 1), transform 140ms cubic-bezier(0.23, 1, 0.32, 1); + } + .preview-draw-icon-action:hover::after, + .preview-draw-icon-action:focus-visible::after, + .preview-draw-subtool-action:hover::after, + .preview-draw-subtool-action:focus-visible::after { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +`; + +function normalizedRectFromPoints(a: Point, b: Point): NormalizedRect { + const left = Math.min(a.x, b.x); + const top = Math.min(a.y, b.y); + const right = Math.max(a.x, b.x); + const bottom = Math.max(a.y, b.y); + return { + x: left, + y: top, + width: Math.max(0, right - left), + height: Math.max(0, bottom - top), + }; +} + +function drawNormalizedBox(ctx: CanvasRenderingContext2D, box: NormalizedRect, width: number, height: number) { + const left = box.x * width; + const top = box.y * height; + const boxWidth = Math.max(1, box.width * width); + const boxHeight = Math.max(1, box.height * height); + ctx.save(); + ctx.fillStyle = 'rgba(255, 59, 48, 0.10)'; + ctx.strokeStyle = STROKE_COLOR; + ctx.lineWidth = Math.max(2, Math.round(Math.min(width, height) * 0.002)); + ctx.setLineDash([10, 6]); + ctx.fillRect(left, top, boxWidth, boxHeight); + ctx.strokeRect(left, top, boxWidth, boxHeight); + ctx.restore(); +} + +const subToolGroupStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 4, + padding: 3, + borderRadius: 999, + background: 'rgba(255,255,255,0.08)', +}; + +function subToolButtonStyle(active: boolean): CSSProperties { return { border: 'none', borderRadius: 999, - padding: '4px 12px', + width: 34, + height: 30, + padding: 0, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + background: active ? 'rgba(255,255,255,0.18)' : 'transparent', + color: '#fff', + fontSize: 12, + fontWeight: active ? 650 : 500, + cursor: 'pointer', + whiteSpace: 'nowrap', + }; +} + +function drawActionButtonStyle(primary: boolean): CSSProperties { + return { + border: primary ? 'none' : '1px solid rgba(255,255,255,0.2)', + borderRadius: 999, + width: 36, + height: 36, + padding: 0, fontSize: 13, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', - gap: 6, - background: active ? 'var(--accent)' : 'transparent', - color: active ? '#fff' : 'inherit', + justifyContent: 'center', + flex: '0 0 auto', + whiteSpace: 'nowrap', + background: primary ? 'var(--accent)' : 'transparent', + color: primary ? '#fff' : 'inherit', }; } -const ghostStyle: CSSProperties = { - border: '1px solid rgba(255,255,255,0.2)', - borderRadius: 999, - padding: '3px 10px', - fontSize: 12, - cursor: 'pointer', - display: 'inline-flex', - alignItems: 'center', - gap: 6, - background: 'transparent', - color: 'inherit', -}; - function historyButtonStyle(enabled: boolean): CSSProperties { return { ...iconButtonStyle, @@ -662,3 +831,9 @@ const iconButtonStyle: CSSProperties = { background: 'rgba(255,255,255,0.06)', color: 'inherit', }; + +const closeButtonStyle: CSSProperties = { + ...iconButtonStyle, + border: 'none', + background: 'transparent', +}; diff --git a/apps/web/src/i18n/locales/ar.ts b/apps/web/src/i18n/locales/ar.ts index 6bfc4cf1f..40ee2e808 100644 --- a/apps/web/src/i18n/locales/ar.ts +++ b/apps/web/src/i18n/locales/ar.ts @@ -800,6 +800,7 @@ export const ar: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.selectAll': 'تحديد الكل', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', 'chat.comments.pin': 'Pin', @@ -816,7 +817,7 @@ export const ar: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -1072,6 +1073,10 @@ export const ar: Dict = { 'fileViewer.comment': 'تعليق', 'fileViewer.edit': 'تعديل', 'fileViewer.draw': 'رسم', + 'fileViewer.mark': 'Mark', + 'fileViewer.markTool': 'Mark tool', + 'fileViewer.boxSelect': 'Box select', + 'fileViewer.screenshot': 'Screenshot', 'manualEdit.layers': "Layers", 'manualEdit.editableCount': "{count} editable", 'manualEdit.hiddenBadge': "Hidden", diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index 848774084..dca4e8fc1 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -688,6 +688,7 @@ export const de: Dict = { 'chat.comments.sending': 'Wird gesendet…', 'chat.comments.edit': 'Bearbeiten', 'chat.comments.select': 'Auswählen', + 'chat.comments.selectAll': 'Alle auswählen', 'chat.comments.deselect': 'Abwählen', 'chat.comments.nSelected': '{n} ausgewählt', 'chat.comments.pin': 'Pin', @@ -704,7 +705,7 @@ export const de: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -960,6 +961,10 @@ export const de: Dict = { 'fileViewer.comment': 'Kommentieren', 'fileViewer.edit': 'Bearbeiten', 'fileViewer.draw': 'Zeichnen', + 'fileViewer.mark': 'Markieren', + 'fileViewer.markTool': 'Markierungswerkzeug', + 'fileViewer.boxSelect': 'Bereich auswählen', + 'fileViewer.screenshot': 'Screenshot', 'manualEdit.layers': "Layers", 'manualEdit.editableCount': "{count} editable", 'manualEdit.hiddenBadge': "Hidden", diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 786abe821..1e63a49ed 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -1404,6 +1404,7 @@ export const en: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.selectAll': 'Select all', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', 'chat.comments.pin': 'Pin', @@ -1420,7 +1421,7 @@ export const en: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -1700,6 +1701,10 @@ export const en: Dict = { 'fileViewer.comment': 'Comment', 'fileViewer.edit': 'Edit', 'fileViewer.draw': 'Draw', + 'fileViewer.mark': 'Mark', + 'fileViewer.markTool': 'Mark tool', + 'fileViewer.boxSelect': 'Box select', + 'fileViewer.screenshot': 'Screenshot', 'manualEdit.layers': "Layers", 'manualEdit.editableCount': "{count} editable", 'manualEdit.hiddenBadge': "Hidden", diff --git a/apps/web/src/i18n/locales/es-ES.ts b/apps/web/src/i18n/locales/es-ES.ts index 9e9ea71cf..753a16f55 100644 --- a/apps/web/src/i18n/locales/es-ES.ts +++ b/apps/web/src/i18n/locales/es-ES.ts @@ -689,6 +689,7 @@ export const esES: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.selectAll': 'Seleccionar todo', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', 'chat.comments.pin': 'Pin', @@ -705,7 +706,7 @@ export const esES: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -961,6 +962,10 @@ export const esES: Dict = { 'fileViewer.comment': 'Comentar', 'fileViewer.edit': 'Editar', 'fileViewer.draw': 'Dibujar', + 'fileViewer.mark': 'Marcar', + 'fileViewer.markTool': 'Herramienta de marcado', + 'fileViewer.boxSelect': 'Seleccionar área', + 'fileViewer.screenshot': 'Captura', 'manualEdit.layers': "Layers", 'manualEdit.editableCount': "{count} editable", 'manualEdit.hiddenBadge': "Hidden", diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index 9da1c04f8..0489cfbbd 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -822,6 +822,7 @@ export const fa: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.selectAll': 'انتخاب همه', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', 'chat.comments.pin': 'Pin', @@ -838,7 +839,7 @@ export const fa: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -1096,6 +1097,10 @@ export const fa: Dict = { 'fileViewer.comment': 'نظر', 'fileViewer.edit': 'ویرایش', 'fileViewer.draw': 'رسم', + 'fileViewer.mark': 'Mark', + 'fileViewer.markTool': 'Mark tool', + 'fileViewer.boxSelect': 'Box select', + 'fileViewer.screenshot': 'Screenshot', 'manualEdit.layers': "Layers", 'manualEdit.editableCount': "{count} editable", 'manualEdit.hiddenBadge': "Hidden", diff --git a/apps/web/src/i18n/locales/fr.ts b/apps/web/src/i18n/locales/fr.ts index 57d3c521d..57285c06a 100644 --- a/apps/web/src/i18n/locales/fr.ts +++ b/apps/web/src/i18n/locales/fr.ts @@ -1337,6 +1337,7 @@ export const fr: Dict = { 'chat.comments.sending': 'Envoi…', 'chat.comments.edit': 'Modifier', 'chat.comments.select': 'Sélectionner', + 'chat.comments.selectAll': 'Tout sélectionner', 'chat.comments.deselect': 'Désélectionner', 'chat.comments.nSelected': '{n} sélectionné(s)', 'chat.comments.pin': 'Épingler', @@ -1353,7 +1354,7 @@ export const fr: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -1605,6 +1606,10 @@ export const fr: Dict = { 'fileViewer.comment': 'Commenter', 'fileViewer.edit': 'Modifier', 'fileViewer.draw': 'Dessiner', + 'fileViewer.mark': 'Annoter', + 'fileViewer.markTool': 'Outil d’annotation', + 'fileViewer.boxSelect': 'Sélection par cadre', + 'fileViewer.screenshot': 'Capture d’écran', 'manualEdit.layers': 'Calques', 'manualEdit.editableCount': '{count} modifiable(s)', 'manualEdit.hiddenBadge': 'Masqué', diff --git a/apps/web/src/i18n/locales/hu.ts b/apps/web/src/i18n/locales/hu.ts index c4dd98800..699c53cfb 100644 --- a/apps/web/src/i18n/locales/hu.ts +++ b/apps/web/src/i18n/locales/hu.ts @@ -800,6 +800,7 @@ export const hu: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.selectAll': 'Összes kijelölése', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', 'chat.comments.pin': 'Pin', @@ -816,7 +817,7 @@ export const hu: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -1072,6 +1073,10 @@ export const hu: Dict = { 'fileViewer.comment': 'Megjegyzés', 'fileViewer.edit': 'Szerkesztés', 'fileViewer.draw': 'Rajz', + 'fileViewer.mark': 'Mark', + 'fileViewer.markTool': 'Mark tool', + 'fileViewer.boxSelect': 'Box select', + 'fileViewer.screenshot': 'Screenshot', 'manualEdit.layers': "Layers", 'manualEdit.editableCount': "{count} editable", 'manualEdit.hiddenBadge': "Hidden", diff --git a/apps/web/src/i18n/locales/id.ts b/apps/web/src/i18n/locales/id.ts index d6d7a94e2..bb074c950 100644 --- a/apps/web/src/i18n/locales/id.ts +++ b/apps/web/src/i18n/locales/id.ts @@ -914,6 +914,7 @@ export const id: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.selectAll': 'Pilih semua', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', 'chat.comments.pin': 'Pin', @@ -930,7 +931,7 @@ export const id: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -1188,6 +1189,10 @@ export const id: Dict = { 'fileViewer.comment': 'Komentar', 'fileViewer.edit': 'Edit', 'fileViewer.draw': 'Gambar', + 'fileViewer.mark': 'Mark', + 'fileViewer.markTool': 'Mark tool', + 'fileViewer.boxSelect': 'Box select', + 'fileViewer.screenshot': 'Screenshot', 'manualEdit.layers': 'Lapisan', 'manualEdit.editableCount': '{count} dapat diedit', diff --git a/apps/web/src/i18n/locales/it.ts b/apps/web/src/i18n/locales/it.ts index 1fcef310e..6aab76e0c 100644 --- a/apps/web/src/i18n/locales/it.ts +++ b/apps/web/src/i18n/locales/it.ts @@ -734,7 +734,8 @@ export const it: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.comments.selectAll': 'Seleziona tutto', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -987,6 +988,10 @@ export const it: Dict = { 'fileViewer.comment': 'Commenta', 'fileViewer.edit': 'Modifica', 'fileViewer.draw': 'Disegna', + 'fileViewer.mark': 'Mark', + 'fileViewer.markTool': 'Mark tool', + 'fileViewer.boxSelect': 'Box select', + 'fileViewer.screenshot': 'Screenshot', 'manualEdit.layers': 'Livelli', 'manualEdit.editableCount': '{count} modificabile', 'manualEdit.hiddenBadge': 'Nascosto', diff --git a/apps/web/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts index 1d67e38ab..e1e111b6e 100644 --- a/apps/web/src/i18n/locales/ja.ts +++ b/apps/web/src/i18n/locales/ja.ts @@ -687,6 +687,7 @@ export const ja: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.selectAll': 'すべて選択', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', 'chat.comments.pin': 'Pin', @@ -703,7 +704,7 @@ export const ja: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -959,6 +960,10 @@ export const ja: Dict = { 'fileViewer.comment': 'コメント', 'fileViewer.edit': '編集', 'fileViewer.draw': '描画', + 'fileViewer.mark': 'マーク', + 'fileViewer.markTool': 'マークツール', + 'fileViewer.boxSelect': '範囲選択', + 'fileViewer.screenshot': 'スクリーンショット', 'manualEdit.layers': "Layers", 'manualEdit.editableCount': "{count} editable", 'manualEdit.hiddenBadge': "Hidden", diff --git a/apps/web/src/i18n/locales/ko.ts b/apps/web/src/i18n/locales/ko.ts index 015e5f850..e38a63940 100644 --- a/apps/web/src/i18n/locales/ko.ts +++ b/apps/web/src/i18n/locales/ko.ts @@ -800,6 +800,7 @@ export const ko: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.selectAll': '모두 선택', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', 'chat.comments.pin': 'Pin', @@ -816,7 +817,7 @@ export const ko: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -1072,6 +1073,10 @@ export const ko: Dict = { 'fileViewer.comment': '댓글', 'fileViewer.edit': '편집', 'fileViewer.draw': '그리기', + 'fileViewer.mark': '표시', + 'fileViewer.markTool': '표시 도구', + 'fileViewer.boxSelect': '영역 선택', + 'fileViewer.screenshot': '스크린샷', 'manualEdit.layers': "Layers", 'manualEdit.editableCount': "{count} editable", 'manualEdit.hiddenBadge': "Hidden", diff --git a/apps/web/src/i18n/locales/pl.ts b/apps/web/src/i18n/locales/pl.ts index 66287f036..561e6e215 100644 --- a/apps/web/src/i18n/locales/pl.ts +++ b/apps/web/src/i18n/locales/pl.ts @@ -800,6 +800,7 @@ export const pl: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.selectAll': 'Zaznacz wszystko', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', 'chat.comments.pin': 'Pin', @@ -816,7 +817,7 @@ export const pl: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -1072,6 +1073,10 @@ export const pl: Dict = { 'fileViewer.comment': 'Komentarz', 'fileViewer.edit': 'Edytuj', 'fileViewer.draw': 'Rysuj', + 'fileViewer.mark': 'Mark', + 'fileViewer.markTool': 'Mark tool', + 'fileViewer.boxSelect': 'Box select', + 'fileViewer.screenshot': 'Screenshot', 'manualEdit.layers': "Layers", 'manualEdit.editableCount': "{count} editable", 'manualEdit.hiddenBadge': "Hidden", diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index 2120b5953..3f47d0bde 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -821,6 +821,7 @@ export const ptBR: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.selectAll': 'Selecionar tudo', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', 'chat.comments.pin': 'Pin', @@ -837,7 +838,7 @@ export const ptBR: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -1095,6 +1096,10 @@ export const ptBR: Dict = { 'fileViewer.comment': 'Comentar', 'fileViewer.edit': 'Editar', 'fileViewer.draw': 'Desenhar', + 'fileViewer.mark': 'Marcar', + 'fileViewer.markTool': 'Ferramenta de marcação', + 'fileViewer.boxSelect': 'Selecionar área', + 'fileViewer.screenshot': 'Captura de tela', 'manualEdit.layers': "Layers", 'manualEdit.editableCount': "{count} editable", 'manualEdit.hiddenBadge': "Hidden", diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index 506e78dc2..1a457adfe 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -821,6 +821,7 @@ export const ru: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.selectAll': 'Выбрать всё', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', 'chat.comments.pin': 'Pin', @@ -837,7 +838,7 @@ export const ru: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -1095,6 +1096,10 @@ export const ru: Dict = { 'fileViewer.comment': 'Комментарий', 'fileViewer.edit': 'Редактировать', 'fileViewer.draw': 'Рисовать', + 'fileViewer.mark': 'Пометить', + 'fileViewer.markTool': 'Инструмент пометки', + 'fileViewer.boxSelect': 'Выбрать область', + 'fileViewer.screenshot': 'Скриншот', 'manualEdit.layers': "Layers", 'manualEdit.editableCount': "{count} editable", 'manualEdit.hiddenBadge': "Hidden", diff --git a/apps/web/src/i18n/locales/th.ts b/apps/web/src/i18n/locales/th.ts index 2e1f3dbb7..28e87d49f 100644 --- a/apps/web/src/i18n/locales/th.ts +++ b/apps/web/src/i18n/locales/th.ts @@ -756,6 +756,7 @@ export const th: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.selectAll': 'เลือกทั้งหมด', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', 'chat.comments.pin': 'Pin', @@ -772,7 +773,7 @@ export const th: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -1009,6 +1010,10 @@ export const th: Dict = { 'fileViewer.comment': 'ช่วยคอมเมนต์', 'fileViewer.edit': 'จัดการแก้ไข', 'fileViewer.draw': 'วาดรูป', + 'fileViewer.mark': 'Mark', + 'fileViewer.markTool': 'Mark tool', + 'fileViewer.boxSelect': 'Box select', + 'fileViewer.screenshot': 'Screenshot', 'manualEdit.layers': "เลเยอร์", 'manualEdit.editableCount': "ใช้แก้ได้ {count} รูปแบบ", 'manualEdit.hiddenBadge': "ซ่อน", diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index 044f21f1d..8458a37e9 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -789,6 +789,7 @@ export const tr: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.selectAll': 'Tümünü seç', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', 'chat.comments.pin': 'Pin', @@ -805,7 +806,7 @@ export const tr: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -1059,6 +1060,10 @@ export const tr: Dict = { 'fileViewer.comment': 'Yorum', 'fileViewer.edit': 'Düzenle', 'fileViewer.draw': 'Çiz', + 'fileViewer.mark': 'Mark', + 'fileViewer.markTool': 'Mark tool', + 'fileViewer.boxSelect': 'Box select', + 'fileViewer.screenshot': 'Screenshot', 'manualEdit.layers': "Layers", 'manualEdit.editableCount': "{count} editable", 'manualEdit.hiddenBadge': "Hidden", diff --git a/apps/web/src/i18n/locales/uk.ts b/apps/web/src/i18n/locales/uk.ts index ca6b575fa..c10cebbdd 100644 --- a/apps/web/src/i18n/locales/uk.ts +++ b/apps/web/src/i18n/locales/uk.ts @@ -822,6 +822,7 @@ export const uk: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.selectAll': 'Вибрати все', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', 'chat.comments.pin': 'Pin', @@ -838,7 +839,7 @@ export const uk: Dict = { 'chat.comments.targetSection': 'Section', 'chat.comments.targetPage': 'Page', 'chat.comments.targetArea': 'Area', - 'chat.annotationNotePlaceholder': 'Add a note for this annotation', + 'chat.annotationNotePlaceholder': 'Add a note for this mark', 'chat.annotationQueue': 'Queue', 'chat.annotationQueueing': 'Queueing...', 'chat.annotationSending': 'Sending...', @@ -1114,6 +1115,10 @@ export const uk: Dict = { 'fileViewer.comment': 'Коментар', 'fileViewer.edit': 'Редагувати', 'fileViewer.draw': 'Малювати', + 'fileViewer.mark': 'Mark', + 'fileViewer.markTool': 'Mark tool', + 'fileViewer.boxSelect': 'Box select', + 'fileViewer.screenshot': 'Screenshot', 'manualEdit.layers': "Layers", 'manualEdit.editableCount': "{count} editable", 'manualEdit.hiddenBadge': "Hidden", diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 4e0e16a1d..32f4e7a91 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -1396,6 +1396,7 @@ export const zhCN: Dict = { 'chat.comments.sending': '发送中…', 'chat.comments.edit': '编辑', 'chat.comments.select': '选择', + 'chat.comments.selectAll': '全选', 'chat.comments.deselect': '取消选择', 'chat.comments.nSelected': '已选择 {n} 个', 'chat.comments.pin': '图钉', @@ -1412,7 +1413,7 @@ export const zhCN: Dict = { 'chat.comments.targetSection': '分区', 'chat.comments.targetPage': '页面', 'chat.comments.targetArea': '区域', - 'chat.annotationNotePlaceholder': '为这条标注添加说明', + 'chat.annotationNotePlaceholder': '为这个标记添加说明', 'chat.annotationQueue': '加入队列', 'chat.annotationQueueing': '加入队列中...', 'chat.annotationSending': '发送中...', @@ -1689,6 +1690,10 @@ export const zhCN: Dict = { 'fileViewer.comment': '注释', 'fileViewer.edit': '编辑', 'fileViewer.draw': '绘制', + 'fileViewer.mark': '标记', + 'fileViewer.markTool': '标记工具', + 'fileViewer.boxSelect': '框选', + 'fileViewer.screenshot': '截图', 'manualEdit.layers': '图层', 'manualEdit.editableCount': '{count} 个可编辑元素', 'manualEdit.hiddenBadge': '隐藏', diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 04288a98b..0b5872836 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -996,6 +996,7 @@ export const zhTW: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.selectAll': '全選', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', 'chat.comments.pin': 'Pin', @@ -1012,7 +1013,7 @@ export const zhTW: Dict = { 'chat.comments.targetSection': '區段', 'chat.comments.targetPage': '頁面', 'chat.comments.targetArea': '區域', - 'chat.annotationNotePlaceholder': '為這條標註加入說明', + 'chat.annotationNotePlaceholder': '為這個標記加入說明', 'chat.annotationQueue': '加入佇列', 'chat.annotationQueueing': '加入佇列中...', 'chat.annotationSending': '傳送中...', @@ -1286,6 +1287,10 @@ export const zhTW: Dict = { 'fileViewer.comment': '註釋', 'fileViewer.edit': '編輯', 'fileViewer.draw': '繪製', + 'fileViewer.mark': '標記', + 'fileViewer.markTool': '標記工具', + 'fileViewer.boxSelect': '框選', + 'fileViewer.screenshot': '截圖', 'manualEdit.layers': "Layers", 'manualEdit.editableCount': "{count} editable", 'manualEdit.hiddenBadge': "隱藏", diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index fd43db207..4c8347230 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -1716,6 +1716,7 @@ export interface Dict { 'chat.comments.sending': string; 'chat.comments.edit': string; 'chat.comments.select': string; + 'chat.comments.selectAll': string; 'chat.comments.deselect': string; 'chat.comments.nSelected': string; 'chat.comments.pin': string; @@ -2021,6 +2022,10 @@ export interface Dict { 'fileViewer.comment': string; 'fileViewer.edit': string; 'fileViewer.draw': string; + 'fileViewer.mark': string; + 'fileViewer.markTool': string; + 'fileViewer.boxSelect': string; + 'fileViewer.screenshot': string; 'manualEdit.layers': string; 'manualEdit.editableCount': string; 'manualEdit.hiddenBadge': string; diff --git a/apps/web/src/runtime/srcdoc.ts b/apps/web/src/runtime/srcdoc.ts index 6f6b7e533..90617878e 100644 --- a/apps/web/src/runtime/srcdoc.ts +++ b/apps/web/src/runtime/srcdoc.ts @@ -1114,6 +1114,9 @@ function meaningfulDomFallbackTarget(el) { } var postTargetsPending = false; var postPreviewScrollPending = false; + var postActiveTargetPending = false; + var activeCommentElementId = null; + var activeCommentSelector = null; function previewScrollElement(){ return document.querySelector('.design-canvas') || document.scrollingElement || document.documentElement; } @@ -1140,6 +1143,34 @@ function meaningfulDomFallbackTarget(el) { function requestPreviewScrollRestore(){ window.parent.postMessage({ type: 'od:preview-scroll-request' }, '*'); } + function findCommentTargetByIdentity(elementId, selector){ + var el = null; + if (selector) { + try { el = document.querySelector(String(selector)); } catch (_) { el = null; } + } + if (!el && elementId) { + try { + var id = String(elementId).replace(/"/g, '\\"'); + el = document.querySelector('[data-od-id="' + id + '"], [data-screen-label="' + id + '"]'); + } catch (_) { el = null; } + } + return el; + } + function postActiveCommentTarget(){ + if (!active() || !activeCommentElementId) return; + var el = findCommentTargetByIdentity(activeCommentElementId, activeCommentSelector); + if (!el) return; + var payload = targetFrom(el, commentEnabled && mode === 'picker' && !inspectEnabled); + if (payload) window.parent.postMessage(Object.assign({}, payload, { type: 'od:comment-active-target-update' }), '*'); + } + function schedulePostActiveCommentTarget(){ + if (!active() || !activeCommentElementId || postActiveTargetPending) return; + postActiveTargetPending = true; + window.requestAnimationFrame(function(){ + postActiveTargetPending = false; + postActiveCommentTarget(); + }); + } function postTargets(){ if (!active()) return; window.parent.postMessage({ type: 'od:comment-targets', targets: allTargets() }, '*'); @@ -1214,7 +1245,11 @@ function meaningfulDomFallbackTarget(el) { document.documentElement.toggleAttribute('data-od-comment-mode', commentEnabled); document.documentElement.setAttribute('data-od-comment-mode-kind', mode); if (active()) setTimeout(postTargets, 0); - else hoveredId = null; + else { + hoveredId = null; + activeCommentElementId = null; + activeCommentSelector = null; + } if (!commentEnabled || mode !== 'pod') { drawing = false; stroke = []; @@ -1230,6 +1265,12 @@ function meaningfulDomFallbackTarget(el) { setTimeout(postPreviewScroll, 0); return; } + if (data.type === 'od:comment-active-target') { + activeCommentElementId = data.elementId ? String(data.elementId) : null; + activeCommentSelector = data.selector ? String(data.selector) : null; + schedulePostActiveCommentTarget(); + return; + } if (data.type === 'od:inspect-mode') { inspectEnabled = !!data.enabled; document.documentElement.toggleAttribute('data-od-inspect-mode', inspectEnabled); @@ -1316,7 +1357,11 @@ function meaningfulDomFallbackTarget(el) { var commentPickerClick = commentEnabled && mode === 'picker' && !inspectEnabled; var clickPoint = commentPickerClick ? { x: ev.clientX, y: ev.clientY } : null; var payload = targetFrom(result.target, commentPickerClick, result.clicked, clickPoint); - if (payload) window.parent.postMessage(payload, '*'); + if (payload) { + activeCommentElementId = payload.elementId || activeCommentElementId; + activeCommentSelector = payload.selector || activeCommentSelector; + window.parent.postMessage(payload, '*'); + } return; } // Free-pin fallback (comment mode only). Lets users drop a comment @@ -1388,6 +1433,7 @@ function meaningfulDomFallbackTarget(el) { document.addEventListener('pointercancel', finishStroke, true); window.addEventListener('resize', schedulePostTargets); document.addEventListener('scroll', function(){ + schedulePostActiveCommentTarget(); schedulePostTargets(); schedulePostPreviewScroll(); }, true); diff --git a/apps/web/src/styles/viewer/core.css b/apps/web/src/styles/viewer/core.css index 82f1f06cc..67ae1f873 100644 --- a/apps/web/src/styles/viewer/core.css +++ b/apps/web/src/styles/viewer/core.css @@ -124,6 +124,10 @@ background: var(--accent-tint); color: var(--accent-strong); } +.viewer-action.active:hover:not(:disabled) { + background: var(--accent-tint); + color: var(--accent-strong); +} .viewer-action.primary { background: var(--accent); border: 1px solid var(--accent); @@ -164,7 +168,8 @@ line-height: 1; text-align: left; } -.viewer-action-icon[data-tooltip]::after { +.viewer-action-icon[data-tooltip]::after, +.viewer-comment-count-trigger[data-tooltip]::after { content: attr(data-tooltip); position: absolute; z-index: 80; @@ -183,24 +188,12 @@ transition: opacity 140ms cubic-bezier(0.23, 1, 0.32, 1), transform 140ms cubic-bezier(0.23, 1, 0.32, 1); } .viewer-action-icon[data-tooltip]:hover::after, -.viewer-action-icon[data-tooltip]:focus-visible::after { +.viewer-action-icon[data-tooltip]:focus-visible::after, +.viewer-comment-count-trigger[data-tooltip]:hover::after, +.viewer-comment-count-trigger[data-tooltip]:focus-visible::after { opacity: 1; transform: translateX(-50%) translateY(0); } -.viewer-comment-toggle.active { - border: 1px solid color-mix(in srgb, var(--accent) 26%, transparent); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 8%, transparent); -} -.viewer-action-active-dot { - position: absolute; - right: 5px; - top: 5px; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 0 2px var(--bg-panel); -} .artifact-tool-menu-anchor { position: relative; display: inline-flex; @@ -1130,14 +1123,60 @@ top: 14px; z-index: 40; width: min(320px, calc(100% - 28px)); + max-height: calc(100% - 28px); + overflow: auto; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-panel); box-shadow: var(--shadow-lg); } -.annotation-hover-popover { - pointer-events: none; +.comment-popover-section { + min-width: 0; +} +.comment-popover-section + .comment-popover-section { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border-soft); +} +.comment-popover-section-params { + display: grid; + gap: 8px; +} +.annotation-style-summary { + display: grid; + gap: 8px; + min-width: 220px; +} +.annotation-style-row { + display: grid; + grid-template-columns: minmax(52px, max-content) minmax(0, 1fr); + align-items: center; + column-gap: 14px; + font-size: 13px; + line-height: 1.35; +} +.annotation-style-row span { + color: var(--text-muted); + font-weight: 500; +} +.annotation-style-row strong { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + color: var(--text); + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.annotation-style-row strong i { + flex: 0 0 auto; + width: 14px; + height: 14px; + border: 1px solid var(--border); + border-radius: 4px; } .comment-popover-head { display: flex; @@ -1250,9 +1289,10 @@ overflow-wrap: anywhere; } .comment-popover textarea { - min-height: 78px; - max-height: 120px; - resize: vertical; + height: 112px; + max-height: 112px; + overflow-y: auto; + resize: none; } .comment-popover-actions { display: flex; @@ -1278,10 +1318,20 @@ gap: 10px; min-width: 0; } +.comment-popover-delete:hover { + background: var(--red-bg); + border-color: var(--red-border); + color: var(--red); +} /* Right-side comment thread panel. Shown while board (comment) mode is on; takes the place of the chat sidebar's removed Comments tab. Floats over the artifact preview at the right edge. */ .comment-side-panel { + --comment-accent: #ff5a3c; + --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-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)); position: absolute; top: 8px; right: 8px; @@ -1320,6 +1370,30 @@ text-overflow: ellipsis; white-space: nowrap; } +.comment-side-header-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} +.comment-side-select-all { + border: 0; + border-radius: 6px; + padding: 4px 6px; + background: transparent; + color: var(--text-muted); + font-size: 12px; + font-weight: 650; + cursor: pointer; +} +.comment-side-select-all:hover:not(:disabled) { + background: var(--bg-subtle); + color: var(--text); +} +.comment-side-select-all:disabled { + cursor: default; + opacity: 0.45; +} .comment-side-close { width: 26px; height: 26px; @@ -1403,18 +1477,19 @@ border-radius: 8px; padding: 10px 12px; background: transparent; + color: var(--text); display: flex; flex-direction: column; gap: 4px; } .comment-side-item.selected { - background: #fff1ec; - border-color: #ff8c75; + background: var(--comment-accent-surface); + border-color: var(--comment-accent-border); } .comment-side-item.active { - background: color-mix(in srgb, var(--accent) 10%, var(--bg-panel)); - border-color: color-mix(in srgb, var(--accent) 46%, var(--border)); - box-shadow: inset 3px 0 0 var(--accent); + background: var(--comment-accent-surface-strong); + border-color: var(--comment-accent-border); + box-shadow: inset 3px 0 0 var(--comment-accent); } .comment-side-item-head { display: flex; @@ -1458,7 +1533,7 @@ height: 16px; border-radius: 4px; border: 1.5px solid var(--border); - background: #fff; + background: var(--bg-panel); cursor: pointer; display: inline-flex; align-items: center; @@ -1468,8 +1543,8 @@ flex-shrink: 0; } .comment-side-check.checked { - background: #ff5a3c; - border-color: #ff5a3c; + background: var(--comment-accent); + border-color: var(--comment-accent); } .comment-side-body { font-size: 13px; @@ -1478,31 +1553,18 @@ word-break: break-word; white-space: pre-wrap; } -.comment-side-reply { - align-self: flex-start; - background: transparent; - border: none; - padding: 0; - font-size: 12px; - color: var(--text-muted); - cursor: pointer; -} -.comment-side-reply:hover { - color: var(--text); - text-decoration: underline; -} .comment-side-selectbar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--border); - background: #fff1ec; + background: var(--comment-accent-surface); } .comment-side-selectcount { flex: 1; font-size: 12px; - color: #cc3a20; + color: var(--comment-accent-strong); } .comment-side-selectbar .ghost { background: transparent; @@ -1520,22 +1582,23 @@ display: inline-flex; align-items: center; gap: 4px; - background: #ff5a3c; + background: var(--comment-accent); color: #fff; - border: 1px solid #ff5a3c; + border: 1px solid var(--comment-accent); border-radius: 6px; padding: 5px 10px; font-size: 12px; cursor: pointer; } .comment-side-selectbar .primary:disabled { - background: #ffb9aa; - border-color: #ffb9aa; + background: color-mix(in srgb, var(--comment-accent) 45%, var(--bg-panel)); + border-color: color-mix(in srgb, var(--comment-accent) 45%, var(--border)); + color: color-mix(in srgb, #fff 68%, var(--text-muted)); cursor: not-allowed; } .comment-side-selectbar .primary:hover:not(:disabled) { - background: #e94a2d; - border-color: #e94a2d; + background: color-mix(in srgb, var(--comment-accent) 88%, #000); + border-color: color-mix(in srgb, var(--comment-accent) 88%, #000); } .comment-side-new-comment { flex: 0 0 auto; @@ -1968,6 +2031,11 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) { .comment-history-attachments { gap: 6px; } +.comment-history-attachments .user-attachment.staged-comment { + max-height: none; + overflow: visible; + align-items: flex-start; +} .viewer-source { margin: 0; padding: 16px; diff --git a/apps/web/src/styles/viewer/memory.css b/apps/web/src/styles/viewer/memory.css index 836243296..3d0e85299 100644 --- a/apps/web/src/styles/viewer/memory.css +++ b/apps/web/src/styles/viewer/memory.css @@ -666,26 +666,6 @@ /* Claude Code-style edit inspector panel. */ .cc-panel { display: flex; flex-direction: column; gap: 0; padding: 8px 0; overflow: hidden; background: var(--bg-panel); } .cc-inspector { display: flex; flex-direction: column; gap: 12px; padding: 6px 12px; } -.cc-inspector-nav { - display: flex; - justify-content: flex-start; - margin-bottom: -4px; -} -.cc-inspector-page { - height: 24px; - padding: 0 8px; - border: 1px solid var(--border); - border-radius: 4px; - background: var(--bg-panel); - color: var(--text-muted); - font-size: 11px; - font-weight: 600; -} -.cc-inspector-page:hover:not(:disabled) { - border-color: var(--border-strong); - color: var(--text); - background: var(--bg-subtle); -} .cc-section { display: flex; flex-direction: column; gap: 6px; } .cc-section-inactive { opacity: 0.58; } .cc-section-head { @@ -702,15 +682,16 @@ .cc-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; } .cc-row { display: flex; align-items: center; - background: var(--surface-2, #f6f6f7); - border: 1px solid var(--border, #e4e4e7); + background: var(--manual-edit-field-bg); + border: 1px solid var(--manual-edit-field-border); border-radius: 4px; height: 26px; padding: 0 4px; + color: var(--manual-edit-field-text); font-size: 10px; min-width: 0; } .cc-row:focus-within { border-color: var(--accent, #2563eb); } .cc-label { - color: var(--text-muted, #6b7280); + color: var(--manual-edit-field-muted); flex: 0 0 auto; padding-right: 8px; white-space: nowrap; } @@ -726,14 +707,14 @@ border-radius: 3px; padding: 0; background: transparent; - color: var(--text-muted); + color: var(--manual-edit-field-muted); font: inherit; font-size: 11px; line-height: 1; cursor: pointer; } .cc-step:hover:not(:disabled) { - background: var(--surface-hover, rgba(0,0,0,0.06)); + background: var(--manual-edit-field-bg-hover); color: var(--text); } .cc-step:disabled { @@ -743,28 +724,31 @@ .cc-value > input, .cc-value > select { flex: 1 1 auto; min-width: 0; border: none; outline: none; - background: transparent; font: inherit; color: inherit; + background: transparent; font: inherit; color: var(--manual-edit-field-text); padding: 0; text-align: right; } +.cc-value > input::placeholder { + color: var(--manual-edit-field-muted); +} .cc-value > select { cursor: pointer; appearance: none; padding-right: 12px; } -.cc-unit { flex: 0 0 auto; color: var(--text-muted); font-style: normal; font-size: 11px; } +.cc-unit { flex: 0 0 auto; color: var(--manual-edit-field-muted); font-style: normal; font-size: 11px; } .cc-select { position: relative; } .cc-chevron { position: absolute; right: 0; pointer-events: none; - color: var(--text-muted); font-style: normal; font-size: 9px; + color: var(--manual-edit-field-muted); font-style: normal; font-size: 9px; } .cc-color { padding-right: 4px; position: relative; } .cc-color-compact .cc-swatch { margin-left: auto; } .cc-swatch { width: 14px; height: 14px; border-radius: 3px; - border: 1px solid rgba(0,0,0,0.18); + border: 1px solid color-mix(in srgb, var(--text) 28%, transparent); padding: 0; cursor: pointer; background-clip: padding-box; flex: 0 0 auto; } .cc-color > input { text-align: right; } .cc-color-popover { position: absolute; top: calc(100% + 6px); left: 0; z-index: 100; - background: var(--bg-panel); border: 1px solid var(--border); + background: var(--bg-panel); border: 1px solid var(--manual-edit-field-border); border-radius: 6px; padding: 8px; box-shadow: 0 6px 18px rgba(0,0,0,0.12); display: flex; flex-direction: column; gap: 8px; width: min(168px, calc(100vw - 32px)); @@ -777,11 +761,11 @@ .cc-color-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 4px; } .cc-color-tile { width: 100%; aspect-ratio: 1; border-radius: 4px; padding: 0; - border: 1px solid rgba(0,0,0,0.08); cursor: pointer; + border: 1px solid color-mix(in srgb, var(--text) 18%, transparent); cursor: pointer; } .cc-color-native { - width: 100%; height: 22px; border: 1px solid var(--border); - border-radius: 4px; padding: 0; background: var(--bg-panel); + width: 100%; height: 22px; border: 1px solid var(--manual-edit-field-border); + border-radius: 4px; padding: 0; background: var(--manual-edit-field-bg); } .cc-quad { display: flex; flex-direction: column; gap: 4px; margin-top: 2px; } @@ -795,11 +779,12 @@ .cc-quad-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; } .cc-quad-cell { display: flex; align-items: center; - background: var(--surface-2, #f6f6f7); - border: 1px solid var(--border); border-radius: 4px; + background: var(--manual-edit-field-bg); + border: 1px solid var(--manual-edit-field-border); border-radius: 4px; + color: var(--manual-edit-field-text); padding: 0 6px; height: 26px; font-size: 12px; gap: 2px; } -.cc-quad-axis { color: var(--text-muted); font-style: normal; font-size: 10px; flex: 0 0 auto; } +.cc-quad-axis { color: var(--manual-edit-field-muted); font-style: normal; font-size: 10px; flex: 0 0 auto; } .cc-step-quad { flex-basis: 12px; width: 12px; @@ -807,9 +792,9 @@ .cc-quad-cell input { flex: 1 1 auto; min-width: 0; border: none; outline: none; background: transparent; font: inherit; padding: 0; - color: inherit; text-align: right; + color: var(--manual-edit-field-text); text-align: right; } -.cc-quad-unit { color: var(--text-muted); font-style: normal; font-size: 10px; } +.cc-quad-unit { color: var(--manual-edit-field-muted); font-style: normal; font-size: 10px; } .cc-disclosure { font-size: 11px; color: var(--text-muted); } .cc-disclosure > summary { cursor: pointer; padding: 4px 0; } @@ -915,6 +900,12 @@ } .manual-edit-modal { + --manual-edit-field-bg: color-mix(in srgb, var(--bg-panel) 88%, var(--text) 12%); + --manual-edit-field-bg-hover: color-mix(in srgb, var(--bg-panel) 80%, var(--text) 20%); + --manual-edit-field-border: color-mix(in srgb, var(--border) 74%, var(--text) 16%); + --manual-edit-field-text: var(--text); + --manual-edit-field-muted: var(--text-muted); + color: var(--text); display: flex; flex-direction: column; min-height: 0; @@ -1040,22 +1031,22 @@ white-space: nowrap; } .manual-edit-floating .manual-edit-titlebar { - min-height: 42px; - padding: 10px 12px 4px; + min-height: 32px; + padding: 8px 12px 2px; } .manual-edit-floating .manual-edit-titlebar-close { - width: 30px; - height: 30px; - border-radius: 8px; + width: 28px; + height: 28px; + border-radius: 7px; } .manual-edit-titlebar { flex: 0 0 auto; display: flex; align-items: center; - gap: 10px; - min-height: 52px; - padding: 14px 18px 10px; + gap: 8px; + min-height: 44px; + padding: 12px 18px 8px; } .manual-edit-titlebar > span { @@ -1063,7 +1054,7 @@ min-width: 0; overflow: hidden; color: var(--text); - font-size: 16px; + font-size: 13px; font-weight: 600; line-height: 1.2; text-overflow: ellipsis; @@ -1075,8 +1066,8 @@ display: inline-flex; align-items: center; justify-content: center; - width: 24px; - height: 28px; + width: 22px; + height: 24px; border: 0; border-radius: 6px; padding: 0; @@ -1096,11 +1087,11 @@ } .manual-edit-drag-handle span { - width: 14px; - height: 18px; - background-image: radial-gradient(currentColor 1.5px, transparent 1.6px); + width: 12px; + height: 16px; + background-image: radial-gradient(currentColor 1.3px, transparent 1.4px); background-position: 0 0; - background-size: 7px 6px; + background-size: 6px 5px; } .manual-edit-titlebar-close { diff --git a/apps/web/tests/components/BoardComposerPopover.pod-remove.test.tsx b/apps/web/tests/components/BoardComposerPopover.pod-remove.test.tsx index e230b8f75..ff15894a7 100644 --- a/apps/web/tests/components/BoardComposerPopover.pod-remove.test.tsx +++ b/apps/web/tests/components/BoardComposerPopover.pod-remove.test.tsx @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { BoardComposerPopover } from '../../src/components/BoardComposerPopover'; import type { PreviewCommentSnapshot } from '../../src/comments'; -import type { PreviewCommentMember } from '../../src/types'; +import type { PreviewComment, PreviewCommentMember } from '../../src/types'; afterEach(() => { cleanup(); @@ -37,6 +37,34 @@ function podTarget(members: PreviewCommentMember[]): PreviewCommentSnapshot { }; } +function elementTarget(): PreviewCommentSnapshot { + return { + ...podTarget([]), + selectionKind: 'element', + memberCount: undefined, + podMembers: undefined, + }; +} + +function existingComment(note: string): PreviewComment { + return { + id: 'comment-1', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'index.html', + elementId: 'pod-1', + selector: '', + label: 'Pod', + text: '', + position: { x: 0, y: 0, width: 100, height: 60 }, + htmlHint: '', + note, + status: 'open', + createdAt: 1, + updatedAt: 1, + }; +} + function renderPopover(overrides: { target: PreviewCommentSnapshot; onRemoveMember: (elementId: string) => void; @@ -83,4 +111,86 @@ describe('BoardComposerPopover captured-component removal', () => { expect(document.querySelectorAll('.board-pod-chip')).toHaveLength(10); expect(screen.queryByText('Member 9')).not.toBeNull(); }); + + it('keeps the floating composer inside the preview bounds near the right edge', () => { + render( + <BoardComposerPopover + target={{ + ...podTarget([]), + hoverPoint: { x: 612, y: 120 }, + position: { x: 600, y: 110, width: 24, height: 24 }, + }} + existing={null} + draft="" + notes={[]} + onDraft={() => {}} + onAddDraft={() => {}} + onRemoveQueuedNote={() => {}} + onClose={() => {}} + onSaveComment={() => {}} + onSendBatch={() => {}} + onRemoveMember={() => {}} + sending={false} + t={((key: string) => String(key)) as never} + bounds={{ width: 640, height: 420 }} + />, + ); + + const popover = screen.getByTestId('comment-popover'); + expect(Number.parseFloat(popover.style.left)).toBeLessThanOrEqual(306); + expect(Number.parseFloat(popover.style.left)).toBeGreaterThanOrEqual(14); + }); + + it('disables the comment action for unchanged existing comments', () => { + const onSaveComment = vi.fn(); + const { rerender } = render( + <BoardComposerPopover + target={elementTarget()} + existing={existingComment('区域放大')} + draft="区域放大" + notes={[]} + onDraft={() => {}} + onAddDraft={() => {}} + onRemoveQueuedNote={() => {}} + onClose={() => {}} + onSaveComment={onSaveComment} + onSendBatch={() => {}} + onRemoveMember={() => {}} + sending={false} + t={((key: string) => { + if (key === 'chat.comments.comment') return 'Comment'; + if (key === 'chat.comments.sendToChat') return 'Send to chat'; + if (key === 'common.delete') return 'Delete'; + return String(key); + }) as never} + />, + ); + + expect(screen.getByTestId('comment-popover-save').hasAttribute('disabled')).toBe(true); + + rerender( + <BoardComposerPopover + target={elementTarget()} + existing={existingComment('区域放大')} + draft="区域放大一些" + notes={[]} + onDraft={() => {}} + onAddDraft={() => {}} + onRemoveQueuedNote={() => {}} + onClose={() => {}} + onSaveComment={onSaveComment} + onSendBatch={() => {}} + onRemoveMember={() => {}} + sending={false} + t={((key: string) => { + if (key === 'chat.comments.comment') return 'Comment'; + if (key === 'chat.comments.sendToChat') return 'Send to chat'; + if (key === 'common.delete') return 'Delete'; + return String(key); + }) as never} + />, + ); + + expect(screen.getByTestId('comment-popover-save').hasAttribute('disabled')).toBe(false); + }); }); diff --git a/apps/web/tests/components/FileViewer.test.tsx b/apps/web/tests/components/FileViewer.test.tsx index d520c99cd..2a11b4b87 100644 --- a/apps/web/tests/components/FileViewer.test.tsx +++ b/apps/web/tests/components/FileViewer.test.tsx @@ -31,6 +31,7 @@ import { applyInspectOverridesToSource, effectivePreviewScale, parseInspectOverridesFromSource, + previewOverlayTransform, serializeInspectOverrides, updateInspectOverride, } from '../../src/components/FileViewer'; @@ -119,6 +120,25 @@ describe('FileViewer preview scale', () => { expect(effectivePreviewScale('mobile', 1, { width: 390, height: 844 })).toBeLessThan(1); expect(effectivePreviewScale('tablet', 1.25, { width: 820, height: 700 })).toBeLessThan(1); }); + + it('offsets tablet and mobile overlays to the centered viewport card', () => { + expect(previewOverlayTransform('desktop', 1.25, { width: 1200, height: 800 })).toEqual({ + scale: 1.25, + offsetX: 0, + offsetY: 0, + }); + + expect(previewOverlayTransform('mobile', 1, { width: 1200, height: 1000 })).toEqual({ + scale: 1, + offsetX: 405, + offsetY: 24, + }); + + const tablet = previewOverlayTransform('tablet', 1.25, { width: 1200, height: 800 }); + expect(tablet.scale).toBeCloseTo(752 / 1180, 5); + expect(tablet.offsetX).toBeCloseTo(24 + (1152 - 820 * (752 / 1180)) / 2, 5); + expect(tablet.offsetY).toBe(24); + }); }); describe('FileViewer JSON artifacts', () => { @@ -1616,14 +1636,16 @@ describe('FileViewer tweaks toolbar', () => { 'chat.comments.emptySaved': 'No saved comments.', 'chat.comments.targetText': 'Text', 'chat.comments.targetLink': 'Link', + 'chat.comments.selectAll': 'Select all', 'common.close': 'Close', + 'common.delete': 'Delete', 'preview.showSidebar': 'Show Comments', 'preview.hideSidebar': 'Hide Comments', }; return labels[key] ?? key; }; - function htmlPreviewFile(): ProjectFile { + function htmlPreviewFile(overrides: Partial<ProjectFile> = {}): ProjectFile { return baseFile({ name: 'preview.html', path: 'preview.html', @@ -1637,6 +1659,7 @@ describe('FileViewer tweaks toolbar', () => { renderer: 'html', exports: ['html'], }, + ...overrides, }); } @@ -1654,26 +1677,63 @@ describe('FileViewer tweaks toolbar', () => { expect(screen.queryByRole('menuitem', { name: 'Pick element' })).toBeNull(); expect(screen.queryByRole('menuitem', { name: 'Region' })).toBeNull(); expect(screen.getByTestId('draw-overlay-toggle')).toBeTruthy(); - expect(screen.getByRole('button', { name: 'Draw' })).toBeTruthy(); - expect(screen.getByTestId('screenshot-capture-toggle')).toBeTruthy(); - expect(screen.getByRole('button', { name: 'Screenshot' })).toBeTruthy(); - expect(screen.queryByPlaceholderText('Add a note for this annotation')).toBeNull(); + expect(screen.getByRole('button', { name: 'Mark' })).toBeTruthy(); + expect(screen.queryByTestId('screenshot-capture-toggle')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Screenshot' })).toBeNull(); + expect(screen.queryByPlaceholderText('Add a note for this mark')).toBeNull(); expect(screen.queryByRole('button', { name: 'Pods' })).toBeNull(); fireEvent.click(screen.getByTestId('draw-overlay-toggle')); - expect(screen.getByPlaceholderText('Add a note for this annotation')).toBeTruthy(); + expect(screen.getByPlaceholderText('Add a note for this mark')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Box select' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Pen' })).toBeTruthy(); expect(screen.queryByRole('button', { name: 'Click' })).toBeNull(); expect(screen.getByRole('button', { name: 'Undo' })).toBeTruthy(); expect(screen.getByRole('button', { name: 'Redo' })).toBeTruthy(); clickAgentTool('draw-overlay-toggle'); - expect(screen.queryByPlaceholderText('Add a note for this annotation')).toBeNull(); + expect(screen.queryByPlaceholderText('Add a note for this mark')).toBeNull(); + }); - fireEvent.click(screen.getByTestId('screenshot-capture-toggle')); - expect(screen.queryByPlaceholderText('Add a note for this annotation')).toBeNull(); - expect(screen.getByRole('status').textContent).toContain('Copying screenshot'); - expect(screen.getByTestId('screenshot-capture-toggle').getAttribute('aria-pressed')).toBe('false'); - expect(screen.getByTestId('draw-overlay-toggle').getAttribute('aria-pressed')).toBe('false'); + it('keeps preview viewport selection scoped to each HTML file', async () => { + const firstFile = htmlPreviewFile({ name: 'first.html', path: 'first.html' }); + const secondFile = htmlPreviewFile({ name: 'second.html', path: 'second.html' }); + const { rerender } = render( + <FileViewer + projectId="viewport-scope-project" + projectKind="prototype" + file={firstFile} + liveHtml='<html><body><main>First</main></body></html>' + />, + ); + + const viewportButton = screen.getByRole('button', { name: 'Preview viewport' }); + expect(viewportButton.textContent).toContain('Desktop'); + fireEvent.click(viewportButton); + fireEvent.click(screen.getByRole('option', { name: /tablet/i })); + expect(screen.getByRole('button', { name: 'Preview viewport' }).textContent).toContain('Tablet'); + + rerender( + <FileViewer + projectId="viewport-scope-project" + projectKind="prototype" + file={secondFile} + liveHtml='<html><body><main>Second</main></body></html>' + />, + ); + + expect((await screen.findByRole('button', { name: 'Preview viewport' })).textContent).toContain('Desktop'); + + rerender( + <FileViewer + projectId="viewport-scope-project" + projectKind="prototype" + file={firstFile} + liveHtml='<html><body><main>First</main></body></html>' + />, + ); + + expect((await screen.findByRole('button', { name: 'Preview viewport' })).textContent).toContain('Tablet'); }); it('keeps the Draw bar open after queueing an annotation', () => { @@ -1684,15 +1744,15 @@ describe('FileViewer tweaks toolbar', () => { ); clickAgentTool('draw-overlay-toggle'); - const note = screen.getByPlaceholderText('Add a note for this annotation'); + const note = screen.getByPlaceholderText('Add a note for this mark'); fireEvent.change(note, { target: { value: 'mark this' } }); fireEvent.click(screen.getByRole('button', { name: 'Queue' })); - expect(screen.getByPlaceholderText('Add a note for this annotation')).toBeTruthy(); + expect(screen.getByPlaceholderText('Add a note for this mark')).toBeTruthy(); expect(screen.queryByRole('button', { name: 'Click' })).toBeNull(); clickAgentTool('draw-overlay-toggle'); - expect(screen.queryByPlaceholderText('Add a note for this annotation')).toBeNull(); + expect(screen.queryByPlaceholderText('Add a note for this mark')).toBeNull(); }); it('uses a materialized srcDoc bridge while the Draw bar is open', async () => { @@ -1722,8 +1782,11 @@ describe('FileViewer tweaks toolbar', () => { expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).srcdoc).toBe(frame.srcdoc); }); - it('disables Draw direct send during a run but keeps Queue available so the annotation is not lost', async () => { - const annotationSpy = vi.fn(); + it('keeps Draw queue available while disabling direct send during a running task', async () => { + const annotationSpy = vi.fn((event: Event) => { + const detail = (event as CustomEvent<{ ack?: (result: { ok: boolean }) => void }>).detail; + detail.ack?.({ ok: true }); + }); window.addEventListener(ANNOTATION_EVENT, annotationSpy); render( @@ -1734,7 +1797,7 @@ describe('FileViewer tweaks toolbar', () => { ); clickAgentTool('draw-overlay-toggle'); - fireEvent.change(screen.getByPlaceholderText('Add a note for this annotation'), { + fireEvent.change(screen.getByPlaceholderText('Add a note for this mark'), { target: { value: 'mark this' }, }); @@ -1842,7 +1905,7 @@ describe('FileViewer tweaks toolbar', () => { Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); expect( - screen.getByTestId('screenshot-capture-toggle').compareDocumentPosition(commentsButton) & + screen.getByTestId('manual-edit-mode-toggle').compareDocumentPosition(commentsButton) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); }); @@ -1879,6 +1942,48 @@ describe('FileViewer tweaks toolbar', () => { expect(screen.queryByTestId('inspect-empty-hint-container')).toBeNull(); }); + it('exits comment mode when closing the comments side panel', () => { + const openComment: PreviewComment = { + id: 'comment-open', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'preview.html', + elementId: 'pin-open', + selector: '[data-od-pin="pin-open"]', + label: 'pin-open', + text: '', + htmlHint: '', + position: { x: 24, y: 32, width: 18, height: 18 }, + note: 'Open comment', + status: 'open', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + render( + <FileViewer + projectId="project-1" + projectKind="prototype" + file={htmlPreviewFile()} + liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>' + previewComments={[openComment]} + />, + ); + + fireEvent.click(screen.getByTestId('comment-panel-toggle')); + + expect(screen.getByTestId('comment-side-panel')).toBeTruthy(); + expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('true'); + expect(screen.getByTestId('comment-saved-marker-pin-open')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: /close/i })); + + expect(screen.queryByTestId('comment-side-panel')).toBeNull(); + expect(screen.queryByTestId('comment-saved-marker-pin-open')).toBeNull(); + expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('false'); + expect(screen.getByTestId('board-mode-toggle').getAttribute('aria-pressed')).toBe('false'); + }); + it('keeps saved comment pins visible while adding another comment', async () => { const olderComment: PreviewComment = { id: 'comment-older', @@ -1961,6 +2066,55 @@ describe('FileViewer tweaks toolbar', () => { expect(document.querySelector('[data-comment-id="comment-older"]')?.className).not.toContain('active'); }); + it('orders and timestamps side comments by latest update time', () => { + const createdFirstUpdatedLast: PreviewComment = { + id: 'comment-updated-last', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'preview.html', + elementId: 'hero-title', + selector: '[data-od-id="hero-title"]', + label: 'Hero title', + text: 'Hero', + htmlHint: '<h1 data-od-id="hero-title">Hero</h1>', + position: { x: 24, y: 32, width: 180, height: 36 }, + note: 'Latest edit', + status: 'open', + createdAt: Date.now() - 20 * 60_000, + updatedAt: Date.now(), + }; + const createdLastUpdatedFirst: PreviewComment = { + ...createdFirstUpdatedLast, + id: 'comment-created-last', + elementId: 'hero-subtitle', + selector: '[data-od-id="hero-subtitle"]', + label: 'Hero subtitle', + note: 'Older edit', + createdAt: Date.now() - 5 * 60_000, + updatedAt: Date.now() - 10 * 60_000, + }; + + render( + <FileViewer + projectId="project-1" + projectKind="prototype" + file={htmlPreviewFile()} + liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>' + previewComments={[createdLastUpdatedFirst, createdFirstUpdatedLast]} + />, + ); + + fireEvent.click(screen.getByTestId('comment-panel-toggle')); + + const items = screen.getAllByTestId('comment-side-item'); + const [firstItem, secondItem] = items; + expect(firstItem).toBeDefined(); + expect(secondItem).toBeDefined(); + expect(firstItem!.textContent).toContain('Latest edit'); + expect(firstItem!.textContent).toContain('just now'); + expect(secondItem!.textContent).toContain('Older edit'); + }); + it('does not preload non-open element comments into the picker composer', async () => { const applyingElementComment: PreviewComment = { id: 'comment-element-applying', @@ -2011,6 +2165,57 @@ describe('FileViewer tweaks toolbar', () => { expect(screen.queryByText('Do not resurrect this note')).toBeNull(); }); + it('does not preload open element comments when starting a new annotation', async () => { + const openComment: PreviewComment = { + id: 'comment-element-open', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'preview.html', + elementId: 'hero', + selector: '[data-od-id="hero"]', + label: 'Hero', + text: 'Hero', + htmlHint: '<main data-od-id="hero">Hero</main>', + position: { x: 8, y: 12, width: 120, height: 48 }, + note: 'Existing note should stay in the thread', + status: 'open', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + render( + <FileViewer + projectId="project-1" + projectKind="prototype" + file={htmlPreviewFile()} + liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>' + previewComments={[openComment]} + onRemovePreviewComment={vi.fn()} + />, + ); + + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + clickAgentTool('board-mode-toggle'); + + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: { + type: 'od:comment-target', + elementId: 'hero', + selector: '[data-od-id="hero"]', + label: 'Hero', + text: 'Hero', + position: { x: 8, y: 12, width: 120, height: 48 }, + htmlHint: '<main data-od-id="hero">Hero</main>', + }, + })); + + const input = await screen.findByTestId('comment-popover-input') as HTMLTextAreaElement; + expect(input.value).toBe(''); + expect(screen.queryByText('Existing note should stay in the thread')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Delete' })).toBeNull(); + }); + it('keeps the comment composer focused on the note after picking an element', async () => { render( <FileViewer @@ -2104,6 +2309,7 @@ describe('FileViewer tweaks toolbar', () => { })); const input = await screen.findByTestId('comment-popover-input'); + expect(screen.getByTestId('comment-side-panel')).toBeTruthy(); fireEvent.change(input, { target: { value: '加大字号' } }); fireEvent.click(screen.getByTestId('comment-popover-save')); @@ -2136,7 +2342,7 @@ describe('FileViewer tweaks toolbar', () => { expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('true'); }); - it('shows element parameters on annotation hover and opens comments on click', async () => { + it('opens annotation parameters and comments on click only', async () => { render( <FileViewer projectId="project-1" @@ -2169,11 +2375,10 @@ describe('FileViewer tweaks toolbar', () => { data: { ...target, type: 'od:comment-hover' }, })); - const summary = await screen.findByTestId('annotation-hover-style-summary'); - expect(summary.textContent).toContain('Color'); - expect(summary.textContent).toContain('#1A1916'); - expect(summary.textContent).toContain('13.5px'); + expect(screen.queryByTestId('annotation-hover-style-summary')).toBeNull(); + expect(screen.queryByTestId('annotation-hover-popover')).toBeNull(); expect(screen.queryByTestId('inspect-panel')).toBeNull(); + expect(await screen.findByTestId('comment-target-overlay')).toBeTruthy(); expect(screen.queryByTestId('comment-popover-input')).toBeNull(); window.dispatchEvent(new MessageEvent('message', { @@ -2181,9 +2386,14 @@ describe('FileViewer tweaks toolbar', () => { data: { ...target, type: 'od:comment-target' }, })); + const summary = await screen.findByTestId('comment-popover-style-summary'); + expect(summary.textContent).toContain('Color'); + expect(summary.textContent).toContain('#1A1916'); + expect(summary.textContent).toContain('13.5px'); expect(await screen.findByTestId('comment-popover-input')).toBeTruthy(); - expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('true'); - expect(screen.getByTestId('board-mode-toggle').getAttribute('aria-pressed')).toBe('false'); + expect(screen.getByTestId('comment-target-overlay')).toBeTruthy(); + expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('false'); + expect(screen.getByTestId('board-mode-toggle').getAttribute('aria-pressed')).toBe('true'); expect(screen.queryByTestId('inspect-panel')).toBeNull(); await waitFor(() => { expect(screen.queryByTestId('annotation-hover-popover')).toBeNull(); @@ -2244,6 +2454,8 @@ describe('FileViewer tweaks toolbar', () => { it('closes the comment side panel from the header close button', () => { const onCollapseChange = vi.fn(); const onClose = vi.fn(); + const onSelectAll = vi.fn(); + const onReply = vi.fn(); function Harness() { const [collapsed, setCollapsed] = useState(false); @@ -2280,8 +2492,9 @@ describe('FileViewer tweaks toolbar', () => { setOpen(false); }} onToggleSelect={() => {}} + onSelectAll={onSelectAll} onClearSelection={() => {}} - onReply={() => {}} + onReply={onReply} onSendSelected={() => {}} sending={false} t={t} @@ -2293,6 +2506,10 @@ describe('FileViewer tweaks toolbar', () => { expect(screen.getByTestId('comment-side-panel')).toBeTruthy(); expect(screen.getByText('不要github,换成微信')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Select all' }).hasAttribute('disabled')).toBe(true); + expect(screen.queryByRole('button', { name: 'Delete' })).toBeNull(); + fireEvent.click(screen.getByText('不要github,换成微信').closest('[data-testid="comment-side-item"]')!); + expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ id: 'comment-1' })); fireEvent.click(screen.getByRole('button', { name: /close/i })); @@ -2330,6 +2547,7 @@ describe('FileViewer tweaks toolbar', () => { onCollapsedChange={() => {}} onClose={() => {}} onToggleSelect={() => {}} + onSelectAll={() => {}} onClearSelection={() => {}} onReply={() => {}} onSendSelected={() => {}} @@ -2342,7 +2560,7 @@ describe('FileViewer tweaks toolbar', () => { expect(screen.queryByText('Link')).toBeNull(); }); - it('deletes selected comments when clear is clicked', async () => { + it('clears the comment selection without deleting when clear is clicked', async () => { const removed: string[] = []; function Harness() { @@ -2405,10 +2623,10 @@ describe('FileViewer tweaks toolbar', () => { fireEvent.click(firstSelectButton); fireEvent.click(screen.getByRole('button', { name: 'Clear' })); - await waitFor(() => { - expect(screen.queryByText('Second')).toBeNull(); - }); - expect(removed).toEqual(['comment-2']); + // Per #3081, Clear deselects rather than batch-deleting: the comments stay + // and removal stays wired to per-comment delete / send-selected instead. + expect(screen.queryByText('Second')).not.toBeNull(); + expect(removed).toEqual([]); }); }); @@ -2978,6 +3196,35 @@ describe('LiveArtifactViewer', () => { expect(rule).toContain('align-items: center;'); }); + it('uses the shared zoom dropdown for live artifact previews', async () => { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input); + if (url === '/api/live-artifacts/la_1?projectId=proj_1') { + return new Response(JSON.stringify({ artifact: baseLiveArtifact() }), { status: 200 }); + } + if (url === '/api/live-artifacts/la_1/refreshes?projectId=proj_1') { + return new Response(JSON.stringify({ refreshes: [] }), { status: 200 }); + } + return new Response(JSON.stringify({}), { status: 404 }); + }); + vi.stubGlobal('fetch', fetchMock); + + render( + <LiveArtifactViewer + projectId="proj_1" + liveArtifact={baseLiveArtifactWorkspaceEntry()} + />, + ); + + const zoomTrigger = await screen.findByRole('button', { name: '100%' }); + expect(screen.queryByRole('button', { name: /zoom out/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /zoom in/i })).toBeNull(); + + fireEvent.click(zoomTrigger); + expect(screen.getByRole('menuitem', { name: '50%' })).toBeTruthy(); + expect(screen.getByRole('menuitem', { name: '200%' })).toBeTruthy(); + }); + it('enters and exits in-tab presentation from the present menu', async () => { const fetchMock = vi.fn(async (input: string | URL | Request) => { const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input); diff --git a/apps/web/tests/components/ManualEditPanel.test.tsx b/apps/web/tests/components/ManualEditPanel.test.tsx index 5ae0a9bc8..eff54df1f 100644 --- a/apps/web/tests/components/ManualEditPanel.test.tsx +++ b/apps/web/tests/components/ManualEditPanel.test.tsx @@ -86,18 +86,13 @@ describe('ManualEditPanel', () => { expect(host.querySelector('.manual-edit-drag-handle')?.getAttribute('aria-label')).toBe('Move edit panel'); }); - it('allows returning from an element inspector to the page inspector', () => { + it('does not show page-level controls inside an element inspector', () => { const onClearSelection = vi.fn(); renderPanel({ onClearSelection }); - const pageButton = host.querySelector('button[aria-label="Show page inspector"]') as HTMLButtonElement | null; - if (!pageButton) throw new Error('Page inspector button not found'); - - act(() => { - pageButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); - }); - - expect(onClearSelection).toHaveBeenCalledTimes(1); + expect(host.querySelector('button[aria-label="Show page inspector"]')).toBeNull(); + expect(host.textContent).not.toContain('PAGE'); + expect(onClearSelection).not.toHaveBeenCalled(); }); it('keeps inspector controls scrollable separately from footer actions', () => { diff --git a/apps/web/tests/components/PreviewDrawOverlay.test.tsx b/apps/web/tests/components/PreviewDrawOverlay.test.tsx index 61c1f1452..d1b974503 100644 --- a/apps/web/tests/components/PreviewDrawOverlay.test.tsx +++ b/apps/web/tests/components/PreviewDrawOverlay.test.tsx @@ -61,60 +61,39 @@ describe('PreviewDrawOverlay', () => { } }); - it('does not direct-send via Enter while a task is running', () => { - const annotation = vi.fn(); + it('disables only the primary send action when sending is blocked', async () => { + const annotation = vi.fn((event: Event) => { + const detail = (event as CustomEvent<{ ack?: (result: { ok: boolean }) => void }>).detail; + detail.ack?.({ ok: true }); + }); window.addEventListener('opendesign:annotation', annotation); try { - const { container } = render( - <PreviewDrawOverlay active sendDisabled sendDisabledReason="A task is currently running"> + const { container, getByRole } = render( + <PreviewDrawOverlay active sendDisabled sendDisabledReason="Task running"> <div style={{ width: 320, height: 200 }} /> </PreviewDrawOverlay>, ); const input = container.querySelector<HTMLInputElement>('.preview-draw-note-input'); expect(input).toBeTruthy(); + fireEvent.change(input!, { target: { value: 'Please queue this note.' } }); + + const sendButton = getByRole('button', { name: 'Send' }) as HTMLButtonElement; + const queueButton = getByRole('button', { name: 'Queue' }) as HTMLButtonElement; + expect(sendButton.disabled).toBe(true); + expect(sendButton.title).toBe('Task running'); + expect(queueButton.disabled).toBe(false); - fireEvent.change(input!, { target: { value: 'Please inspect this panel.' } }); fireEvent.keyDown(input!, { key: 'Enter' }); - + fireEvent.click(sendButton); expect(annotation).not.toHaveBeenCalled(); - } finally { - window.removeEventListener('opendesign:annotation', annotation); - } - }); - it('disables the primary Send action while a task is running', () => { - const { getByRole } = render( - <PreviewDrawOverlay active sendDisabled sendDisabledReason="A task is currently running"> - <div style={{ width: 320, height: 200 }} /> - </PreviewDrawOverlay>, - ); - - const sendButton = getByRole('button', { name: 'Send' }); - expect((sendButton as HTMLButtonElement).disabled).toBe(true); - }); - - it('keeps Queue available so an annotation is not lost while a task is running', async () => { - const annotation = vi.fn(); - window.addEventListener('opendesign:annotation', annotation); - - try { - const { container, getByRole } = render( - <PreviewDrawOverlay active sendDisabled sendDisabledReason="A task is currently running"> - <div style={{ width: 320, height: 200 }} /> - </PreviewDrawOverlay>, - ); - - const input = container.querySelector<HTMLInputElement>('.preview-draw-note-input'); - fireEvent.change(input!, { target: { value: 'Queue this up.' } }); - - const queueButton = getByRole('button', { name: 'Queue' }); - expect((queueButton as HTMLButtonElement).disabled).toBe(false); fireEvent.click(queueButton); - await waitFor(() => expect(annotation).toHaveBeenCalledTimes(1)); - expect(annotation.mock.calls[0]?.[0].detail).toMatchObject({ action: 'queue' }); + expect(annotation.mock.calls[0]?.[0]).toMatchObject({ + detail: expect.objectContaining({ action: 'queue' }), + }); } finally { window.removeEventListener('opendesign:annotation', annotation); } diff --git a/apps/web/tests/edit-mode/bridge.test.ts b/apps/web/tests/edit-mode/bridge.test.ts index 9cdcff29e..342bc576b 100644 --- a/apps/web/tests/edit-mode/bridge.test.ts +++ b/apps/web/tests/edit-mode/bridge.test.ts @@ -418,4 +418,23 @@ describe('manual edit bridge target normalization', () => { dom.window.close(); }); + + it('blocks clicks on unmapped elements while edit mode is enabled', () => { + const dom = new JSDOM( + `<main><button id="cta">Launch</button></main>${buildManualEditBridge(true)}`, + { runScripts: 'dangerously', url: 'http://localhost' }, + ); + const button = dom.window.document.getElementById('cta') as HTMLButtonElement; + const clicked = vi.fn(); + button.addEventListener('click', clicked); + + const event = new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }); + const result = button.dispatchEvent(event); + + expect(result).toBe(false); + expect(event.defaultPrevented).toBe(true); + expect(clicked).not.toHaveBeenCalled(); + + dom.window.close(); + }); }); From 937946c6fa087bcf7550cfc14ef877d280385bc9 Mon Sep 17 00:00:00 2001 From: Amy <58060647+AmyShang-alt@users.noreply.github.com> Date: Fri, 29 May 2026 15:07:40 +0800 Subject: [PATCH 17/56] Improve model picker search and shared BYOK catalogs (#3262) (#3278) --- apps/web/src/App.tsx | 8 + apps/web/src/components/EntryShell.tsx | 15 +- apps/web/src/components/EntryView.tsx | 9 +- .../src/components/InlineModelSwitcher.tsx | 92 +++++--- apps/web/src/components/SettingsDialog.tsx | 81 ++++--- apps/web/src/components/modelOptions.tsx | 211 ++++++++++++++++-- apps/web/src/styles/home/entry-layout.css | 78 +++++++ apps/web/src/styles/workspace/artifacts.css | 95 ++++++++ .../components/InlineModelSwitcher.test.tsx | 105 +++++++-- .../SettingsDialog.execution.test.tsx | 122 ++++++++-- 10 files changed, 708 insertions(+), 108 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ddad6689b..f3a7b6acb 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -96,6 +96,7 @@ import type { DesignSystemSummary, Project, ProjectTemplate, + ProviderModelOption, PromptTemplateSummary, SkillSummary, } from './types'; @@ -232,6 +233,9 @@ function AppInner() { const [appVersionInfo, setAppVersionInfo] = useState<AppVersionInfo | null>( null, ); + const [providerModelsCache, setProviderModelsCache] = useState< + Record<string, ProviderModelOption[]> + >({}); const [daemonMediaProviders, setDaemonMediaProviders] = useState< AppConfig['mediaProviders'] | null >(null); @@ -1495,6 +1499,8 @@ function AppInner() { defaultDesignSystemId={config.designSystemId} agents={agents} config={config} + providerModelsCache={providerModelsCache} + onProviderModelsCacheChange={setProviderModelsCache} integrationInitialTab={integrationInitialTab} composioConfigLoading={composioConfigLoading} daemonLive={daemonLive} @@ -1618,6 +1624,8 @@ function AppInner() { onReloadMediaProviders={reloadMediaProvidersFromDaemon} onSkillsChanged={handleSkillsChanged} onDesignSystemsChanged={handleDesignSystemsChanged} + providerModelsCache={providerModelsCache} + onProviderModelsCacheChange={setProviderModelsCache} /> ) : null} <MemoryToast onOpenMemory={() => openSettings('memory')} /> diff --git a/apps/web/src/components/EntryShell.tsx b/apps/web/src/components/EntryShell.tsx index 1874cb0dc..53c99f5eb 100644 --- a/apps/web/src/components/EntryShell.tsx +++ b/apps/web/src/components/EntryShell.tsx @@ -13,8 +13,10 @@ import { useMemo, useRef, useState, + type Dispatch, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, + type SetStateAction, } from 'react'; import { defaultScenarioPluginIdForProjectMetadata, @@ -227,6 +229,8 @@ interface Props { // top-bar `InlineModelSwitcher` can render the active mode/agent/model // and persist changes through the same callbacks the project view uses. config: AppConfig; + providerModelsCache?: Record<string, ProviderModelOption[]>; + onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>; agents: AgentInfo[]; daemonLive: boolean; onModeChange: (mode: ExecMode) => void; @@ -353,6 +357,8 @@ export function EntryShell({ designSystemsLoading = false, projectsLoading = false, config, + providerModelsCache: sharedProviderModelsCache, + onProviderModelsCacheChange, agents, daemonLive, onModeChange, @@ -579,6 +585,7 @@ export function EntryShell({ <span className="entry-discord-badge__label">Join Discord</span> </a> <InlineModelSwitcher + providerModelsCache={sharedProviderModelsCache} config={config} agents={agents} daemonLive={daemonLive} @@ -748,6 +755,8 @@ export function EntryShell({ function OnboardingView({ config, + providerModelsCache: sharedProviderModelsCache, + onProviderModelsCacheChange, agents, daemonLive, onModeChange, @@ -761,6 +770,8 @@ function OnboardingView({ onFinish, }: { config: AppConfig; + providerModelsCache?: Record<string, ProviderModelOption[]>; + onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>; agents: AgentInfo[]; daemonLive: boolean; onModeChange: (mode: ExecMode) => void; @@ -808,9 +819,11 @@ function OnboardingView({ | { status: 'running'; inputKey: string } | { status: 'done'; inputKey: string; result: ProviderModelsResponse } >({ status: 'idle' }); - const [providerModelsCache, setProviderModelsCache] = useState< + const [localProviderModelsCache, setLocalProviderModelsCache] = useState< Record<string, ProviderModelOption[]> >({}); + const providerModelsCache = sharedProviderModelsCache ?? localProviderModelsCache; + const setProviderModelsCache = onProviderModelsCacheChange ?? setLocalProviderModelsCache; const [profile, setProfile] = useState({ role: '', orgSize: '', diff --git a/apps/web/src/components/EntryView.tsx b/apps/web/src/components/EntryView.tsx index 182e032a2..c92c53c92 100644 --- a/apps/web/src/components/EntryView.tsx +++ b/apps/web/src/components/EntryView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import { useCallback, useEffect, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react'; import type { DesignSystemGenerateSnapshot } from './DesignSystemFlow'; import type { ConnectorDetail, @@ -22,6 +22,7 @@ import type { ProjectMetadata, ProjectTemplate, PromptTemplateSummary, + ProviderModelOption, SkillSummary, } from '../types'; // `EntryShell` owns the redesigned home layout (left rail + centered @@ -60,6 +61,8 @@ interface Props { // sticky top-bar can expose the active CLI/BYOK + model and persist // changes through the same channels as the project view. config: AppConfig; + providerModelsCache?: Record<string, ProviderModelOption[]>; + onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>; integrationInitialTab?: IntegrationTab; composioConfigLoading?: boolean; daemonLive: boolean; @@ -255,6 +258,8 @@ export function EntryView({ defaultDesignSystemId, agents, config, + providerModelsCache, + onProviderModelsCacheChange, integrationInitialTab, composioConfigLoading = false, daemonLive, @@ -355,6 +360,8 @@ export function EntryView({ designSystemsLoading={designSystemsLoading} projectsLoading={projectsLoading} config={config} + providerModelsCache={providerModelsCache} + onProviderModelsCacheChange={onProviderModelsCacheChange} agents={agents} daemonLive={daemonLive} onModeChange={onModeChange} diff --git a/apps/web/src/components/InlineModelSwitcher.tsx b/apps/web/src/components/InlineModelSwitcher.tsx index 2fb6afd9c..fb8dae29a 100644 --- a/apps/web/src/components/InlineModelSwitcher.tsx +++ b/apps/web/src/components/InlineModelSwitcher.tsx @@ -17,7 +17,7 @@ import { startVelaLogin, type VelaLoginStatus, } from '../providers/daemon'; -import type { AgentInfo, ApiProtocol, AppConfig, ExecMode } from '../types'; +import type { AgentInfo, ApiProtocol, AppConfig, ExecMode, ProviderModelOption } from '../types'; import { apiProtocolLabel } from '../utils/apiProtocol'; import { AgentIcon } from './AgentIcon'; import { Icon } from './Icon'; @@ -30,7 +30,7 @@ import { notifyAmrLoginStatusChanged, } from './amrLoginPolling'; import { normalizeAgentModelChoice } from './agentModelSelection'; -import { renderModelOptions } from './modelOptions'; +import { SearchableModelSelect } from './modelOptions'; interface Props { config: AppConfig; @@ -44,6 +44,7 @@ interface Props { ) => void; onApiProtocolChange: (protocol: ApiProtocol) => void; onApiModelChange: (model: string) => void; + providerModelsCache?: Record<string, ProviderModelOption[]>; onOpenSettings: ( section?: | 'execution' @@ -108,6 +109,7 @@ export function InlineModelSwitcher({ onAgentModelChange, onApiProtocolChange, onApiModelChange, + providerModelsCache, onOpenSettings, }: Props) { const t = useT(); @@ -348,6 +350,12 @@ export function InlineModelSwitcher({ : null; const apiProtocol = config.apiProtocol ?? 'anthropic'; + const providerModelsInputKey = [ + apiProtocol, + config.baseUrl.trim().replace(/\/+$/, ''), + config.apiKey.trim(), + config.apiVersion?.trim() ?? '', + ].join('\n'); const providerForProtocol = useMemo( () => KNOWN_PROVIDERS.find( @@ -359,7 +367,18 @@ export function InlineModelSwitcher({ ) ?? KNOWN_PROVIDERS.find((p) => p.protocol === apiProtocol), [apiProtocol, config.apiProviderBaseUrl], ); - const apiModelOptions = providerForProtocol?.models ?? []; + const fetchedProviderModels = providerModelsCache?.[providerModelsInputKey] ?? []; + const apiModelOptions = useMemo(() => { + const discovered = fetchedProviderModels.map((model) => model.id); + const staticOptions = providerForProtocol?.models ?? []; + const merged = new Set<string>([...discovered, ...staticOptions]); + if (config.model.trim()) merged.add(config.model.trim()); + return Array.from(merged); + }, [config.model, fetchedProviderModels, providerForProtocol?.models]); + const apiModelChoices = useMemo( + () => apiModelOptions.map((id) => ({ id, label: id })), + [apiModelOptions], + ); // Chip text — keep it tight so the pill doesn't wrap on small viewports. // CLI: "Claude · Sonnet 4.5"; BYOK: "Anthropic · sonnet-4.5". @@ -618,25 +637,33 @@ export function InlineModelSwitcher({ <span className="inline-switcher__label"> {t('inlineSwitcher.modelLabel')} </span> - <select + <SearchableModelSelect className="inline-switcher__select" data-testid="inline-model-switcher-agent-model" + searchInputTestId="inline-model-switcher-agent-model-search" + popoverTestId="inline-model-switcher-agent-model-popover" + searchPlaceholder={t('designs.searchPlaceholder')} + aria-label={t('inlineSwitcher.modelLabel')} + models={currentAgent.models} value={currentModelId ?? ''} - onChange={(e) => + onChange={(nextValue) => onAgentModelChange?.(currentAgent.id, { - model: e.target.value, + model: nextValue, }) } - > - {renderModelOptions(currentAgent.models)} - {currentAgent.id !== 'amr' && - currentModelId && - !currentAgent.models.some((m) => m.id === currentModelId) ? ( - <option value={currentModelId}> - {currentModelId} {t('inlineSwitcher.customSuffix')} - </option> - ) : null} - </select> + additionalOptions={ + currentAgent.id !== 'amr' && + currentModelId && + !currentAgent.models.some((m) => m.id === currentModelId) + ? [ + { + value: currentModelId, + label: `${currentModelId} ${t('inlineSwitcher.customSuffix')}`, + }, + ] + : undefined + } + /> </div> ) : null} </> @@ -674,24 +701,27 @@ export function InlineModelSwitcher({ {t('inlineSwitcher.modelLabel')} </span> {apiModelOptions.length > 0 ? ( - <select + <SearchableModelSelect className="inline-switcher__select" data-testid="inline-model-switcher-api-model" + searchInputTestId="inline-model-switcher-api-model-search" + popoverTestId="inline-model-switcher-api-model-popover" + searchPlaceholder={t('designs.searchPlaceholder')} + aria-label={t('inlineSwitcher.modelLabel')} + models={apiModelChoices} value={config.model} - onChange={(e) => onApiModelChange?.(e.target.value)} - > - {apiModelOptions.map((id) => ( - <option key={id} value={id}> - {id} - </option> - ))} - {config.model && - !apiModelOptions.includes(config.model) ? ( - <option value={config.model}> - {config.model} {t('inlineSwitcher.customSuffix')} - </option> - ) : null} - </select> + onChange={(nextValue) => onApiModelChange?.(nextValue)} + additionalOptions={ + config.model && !apiModelOptions.includes(config.model) + ? [ + { + value: config.model, + label: `${config.model} ${t('inlineSwitcher.customSuffix')}`, + }, + ] + : undefined + } + /> ) : ( <span className="inline-switcher__hint"> {t('inlineSwitcher.openSettingsForModel')} diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index 90f7cbb75..6fc58bde6 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -36,7 +36,7 @@ import { ExportDiagnosticsRow } from './ExportDiagnosticsButton'; import { Icon } from './Icon'; import { CUSTOM_MODEL_SENTINEL, - renderModelOptions, + SearchableModelSelect, } from './modelOptions'; import { DEFAULT_NOTIFICATIONS, @@ -204,6 +204,8 @@ interface Props { onSkillsChanged?: (affectedSkillId?: string) => void; /** Same channel for design-system registry mutations. */ onDesignSystemsChanged?: (affectedDesignSystemId?: string) => void; + providerModelsCache?: Record<string, ProviderModelOption[]>; + onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>; } export interface AgentRefreshOptions { @@ -835,6 +837,8 @@ export function SettingsDialog({ onReloadMediaProviders, onSkillsChanged, onDesignSystemsChanged, + providerModelsCache: sharedProviderModelsCache, + onProviderModelsCacheChange, }: Props) { const { t, locale, setLocale } = useI18n(); const analytics = useAnalytics(); @@ -945,9 +949,11 @@ export function SettingsDialog({ initial.apiVersion ?? '', ); }); - const [providerModelsCache, setProviderModelsCache] = useState< + const [localProviderModelsCache, setLocalProviderModelsCache] = useState< Record<string, ProviderModelOption[]> >({}); + const providerModelsCache = sharedProviderModelsCache ?? localProviderModelsCache; + const setProviderModelsCache = onProviderModelsCacheChange ?? setLocalProviderModelsCache; const agentTestAbortRef = useRef<AbortController | null>(null); const providerTestAbortRef = useRef<AbortController | null>(null); const providerModelsAbortRef = useRef<AbortController | null>(null); @@ -959,7 +965,7 @@ export function SettingsDialog({ const providerAutoTestKeyRef = useRef<string | null>(null); const apiKeyInputRef = useRef<HTMLInputElement | null>(null); const baseUrlInputRef = useRef<HTMLInputElement | null>(null); - const modelSelectRef = useRef<HTMLSelectElement | null>(null); + const modelSelectRef = useRef<HTMLButtonElement | null>(null); const customModelInputRef = useRef<HTMLInputElement | null>(null); const focusByokRequiredFieldAfterProtocolSwitchRef = useRef(false); const [apiModelCustomEditing, setApiModelCustomEditing] = useState(false); @@ -2139,10 +2145,16 @@ export function SettingsDialog({ </span> </span> <div className="agent-model-select-wrap"> - <select + <SearchableModelSelect + className="inline-switcher__select settings-model-select" value={selectValue} - onChange={(e) => { - if (e.target.value === CUSTOM_MODEL_SENTINEL) { + aria-label={t('settings.modelPicker')} + searchPlaceholder={t('designs.searchPlaceholder')} + searchInputTestId={`settings-agent-model-search-${selected.id}`} + popoverTestId={`settings-agent-model-popover-${selected.id}`} + models={selected.models!} + onChange={(nextValue) => { + if (nextValue === CUSTOM_MODEL_SENTINEL) { setAgentCustomModelIds((prev) => { const next = new Set(prev); next.add(selected.id); @@ -2156,21 +2168,19 @@ export function SettingsDialog({ next.delete(selected.id); return next; }); - setChoice({ model: e.target.value }); + setChoice({ model: nextValue }); } }} - > - {renderModelOptions(selected.models!)} - {allowCustomModel ? ( - <option value={CUSTOM_MODEL_SENTINEL}> - {t('settings.modelCustom')} - </option> - ) : null} - </select> - <Icon - name="chevron-down" - size={12} - className="agent-model-select-chevron" + additionalOptions={ + allowCustomModel + ? [ + { + value: CUSTOM_MODEL_SENTINEL, + label: t('settings.modelCustom'), + }, + ] + : undefined + } /> </div> </label> @@ -3370,13 +3380,21 @@ export function SettingsDialog({ * </span> </span> - <select + <SearchableModelSelect ref={modelSelectRef} + className="inline-switcher__select settings-model-select settings-model-select--byok" aria-label={ apiProtocol === 'azure' ? t('settings.azureDeploymentModel') : t('settings.model') } + searchPlaceholder={t('designs.searchPlaceholder')} + searchInputTestId="settings-byok-model-search" + popoverTestId="settings-byok-model-popover" + models={apiModelOptions.map((m) => ({ + id: m.id, + label: apiModelOptionLabel(m), + }))} value={apiModelSelectValue} onFocus={() => { const byokProviderId = byokProtocolToTracking(apiProtocol); @@ -3390,21 +3408,22 @@ export function SettingsDialog({ }); } }} - onChange={(e) => { - if (e.target.value === CUSTOM_MODEL_SENTINEL) { + onChange={(nextValue) => { + if (nextValue === CUSTOM_MODEL_SENTINEL) { setApiModelCustomEditing(true); updateApiConfig({ model: '' }); } else { setApiModelCustomEditing(false); - updateApiConfig({ model: e.target.value }); + updateApiConfig({ model: nextValue }); } }} - > - {apiModelOptions.map((m) => ( - <option value={m.id} key={m.id}>{apiModelOptionLabel(m)}</option> - ))} - <option value={CUSTOM_MODEL_SENTINEL}>{t('settings.modelCustom')}</option> - </select> + additionalOptions={[ + { + value: CUSTOM_MODEL_SENTINEL, + label: t('settings.modelCustom'), + }, + ]} + /> {loadedAccountModelCount > 0 ? ( <span className="field-inline-status success" role="status"> {t('settings.modelsLoadedFromAccount', { @@ -5100,6 +5119,8 @@ function MediaProvidersSection({ setCfg, mediaProvidersNotice, onReloadMediaProviders, + providerModelsCache: sharedProviderModelsCache, + onProviderModelsCacheChange, pendingLocalProviderIds, onChange, }: { @@ -5107,6 +5128,8 @@ function MediaProvidersSection({ setCfg: Dispatch<SetStateAction<AppConfig>>; mediaProvidersNotice?: string | null; onReloadMediaProviders?: () => Promise<AppConfig['mediaProviders'] | null>; + providerModelsCache?: Record<string, ProviderModelOption[]>; + onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>; pendingLocalProviderIds: ReadonlySet<string>; onChange: (providerId: string) => void; }) { diff --git a/apps/web/src/components/modelOptions.tsx b/apps/web/src/components/modelOptions.tsx index b05057396..297a32345 100644 --- a/apps/web/src/components/modelOptions.tsx +++ b/apps/web/src/components/modelOptions.tsx @@ -1,12 +1,7 @@ +import { createPortal } from 'react-dom'; +import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type ButtonHTMLAttributes } from 'react'; import type { AgentModelOption } from '../types'; -// Render the `<option>` children for a model `<select>`. When the list -// contains `provider/model` ids (opencode's listing has hundreds), we -// group them under `<optgroup>` so the dropdown is navigable. Flat lists -// (Claude, Codex, Gemini, Qwen) are emitted as plain options. -// -// `'default'` is always pinned first (no group), so the user can return -// to "let the CLI decide" with one click. export function renderModelOptions(models: AgentModelOption[]) { const groups = new Map<string, AgentModelOption[]>(); const flat: AgentModelOption[] = []; @@ -44,9 +39,6 @@ export function renderModelOptions(models: AgentModelOption[]) { <optgroup key={provider} label={provider}> {items.map((m) => ( <option key={m.id} value={m.id}> - {/* Strip the redundant `provider/` prefix from the label - inside its own optgroup; keep it in the value so the - CLI sees the fully-qualified id. */} {m.label.startsWith(`${provider}/`) ? m.label.slice(provider.length + 1) : m.label} @@ -58,9 +50,202 @@ export function renderModelOptions(models: AgentModelOption[]) { ); } -// True when the picked model id isn't one of the listed options — i.e. -// the user has typed a custom id and we should keep the custom input -// visible / the dropdown showing "Custom…". +function matchesModelSearch(model: AgentModelOption, query: string): boolean { + const haystack = `${model.id}\n${model.label}`.toLowerCase(); + return haystack.includes(query); +} + +interface SearchableModelSelectProps + extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onChange' | 'value'> { + models: AgentModelOption[]; + value: string; + onChange: (value: string) => void; + searchPlaceholder: string; + searchInputTestId?: string; + popoverTestId?: string; + additionalOptions?: Array<{ value: string; label: string }>; + minSearchableOptions?: number; +} + +export const SearchableModelSelect = forwardRef< + HTMLButtonElement, + SearchableModelSelectProps +>(function SearchableModelSelect( + { + models, + value, + onChange, + searchPlaceholder, + searchInputTestId, + popoverTestId, + additionalOptions, + minSearchableOptions = 8, + className, + ...buttonProps + }, + ref, +) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [popoverStyle, setPopoverStyle] = useState<{ top: number; left: number; width: number } | null>(null); + const wrapRef = useRef<HTMLDivElement | null>(null); + const popoverRef = useRef<HTMLDivElement | null>(null); + const searchRef = useRef<HTMLInputElement | null>(null); + const listboxId = useMemo( + () => `model-picker-${Math.random().toString(36).slice(2, 10)}`, + [], + ); + const allOptions = useMemo(() => { + const merged = new Map<string, AgentModelOption>(); + for (const option of models) merged.set(option.id, option); + for (const option of additionalOptions ?? []) { + if (!merged.has(option.value)) { + merged.set(option.value, { id: option.value, label: option.label }); + } + } + return Array.from(merged.values()); + }, [additionalOptions, models]); + const selectedOption = + allOptions.find((option) => option.id === value) ?? + (value ? { id: value, label: value } : allOptions[0] ?? null); + const normalizedQuery = query.trim().toLowerCase(); + const filteredOptions = useMemo(() => { + if (!normalizedQuery) return allOptions; + return allOptions.filter( + (option) => + option.id === value || + option.id === CUSTOM_MODEL_SENTINEL || + matchesModelSearch(option, normalizedQuery), + ); + }, [allOptions, normalizedQuery, value]); + const shouldShowSearch = allOptions.length >= minSearchableOptions; + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: MouseEvent) => { + const target = event.target as Node; + if (wrapRef.current?.contains(target) || popoverRef.current?.contains(target)) return; + setOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + + useLayoutEffect(() => { + if (!open) return; + const updatePosition = () => { + const rect = wrapRef.current?.getBoundingClientRect(); + if (!rect) return; + setPopoverStyle({ + top: rect.bottom + 6, + left: rect.left, + width: rect.width, + }); + }; + updatePosition(); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [open]); + + useEffect(() => { + if (!open || !shouldShowSearch) return; + searchRef.current?.focus(); + }, [open, shouldShowSearch]); + + useEffect(() => { + if (!open) setQuery(''); + }, [open]); + + return ( + <div className={`model-select-searchable${open ? ' is-open' : ''}`} ref={wrapRef}> + <button + {...buttonProps} + ref={ref} + type="button" + role="combobox" + aria-expanded={open} + aria-controls={listboxId} + aria-haspopup="listbox" + className={className} + onClick={(event) => { + buttonProps.onClick?.(event); + if (!event.defaultPrevented) setOpen((prev) => !prev); + }} + > + {selectedOption?.label ?? ''} + </button> + {open && popoverStyle + ? createPortal( + <div + ref={popoverRef} + className="model-select-searchable__popover" + role="presentation" + data-testid={popoverTestId} + style={{ + position: 'fixed', + top: `${popoverStyle.top}px`, + left: `${popoverStyle.left}px`, + width: `${popoverStyle.width}px`, + }} + > + {shouldShowSearch ? ( + <div className="model-select-searchable__search-row"> + <input + ref={searchRef} + type="search" + className="ds-picker-search model-select-searchable__input" + value={query} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + data-testid={searchInputTestId} + onChange={(e) => setQuery(e.target.value)} + /> + </div> + ) : null} + <div className="model-select-searchable__list" id={listboxId} role="listbox"> + {filteredOptions.map((option) => { + const active = option.id === value; + return ( + <button + key={option.id} + type="button" + role="option" + aria-selected={active} + className={`model-select-searchable__option${active ? ' is-active' : ''}`} + data-selected={active ? 'true' : undefined} + onClick={() => { + onChange(option.id); + setOpen(false); + }} + > + <span className="model-select-searchable__option-label">{option.label}</span> + </button> + ); + })} + {filteredOptions.length === 0 ? ( + <div className="model-select-searchable__empty">No matching models</div> + ) : null} + </div> + </div>, + document.body, + ) + : null} + </div> + ); +}); + export function isCustomModel( modelId: string | null | undefined, models: AgentModelOption[], diff --git a/apps/web/src/styles/home/entry-layout.css b/apps/web/src/styles/home/entry-layout.css index f9228a900..bfdd51896 100644 --- a/apps/web/src/styles/home/entry-layout.css +++ b/apps/web/src/styles/home/entry-layout.css @@ -1014,6 +1014,84 @@ } /* Shared select inside the popover */ +.model-select-searchable { + position: relative; +} +.model-select-searchable.is-open { + z-index: 1400; +} +.model-select-searchable__popover { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: 1401; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: var(--shadow-lg); + overflow: hidden; +} +.model-select-searchable__search-row { + padding: 8px; + border-bottom: 1px solid var(--border); +} +.model-select-searchable__input { + width: 100%; +} +.model-select-searchable__list { + max-height: 280px; + overflow-y: auto; + display: flex; + flex-direction: column; + padding: 6px; +} +.model-select-searchable__option { + appearance: none; + border: 0; + background: transparent; + text-align: left; + padding: 8px 30px 8px 10px; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 2px; + color: var(--text); + cursor: pointer; + position: relative; +} +.model-select-searchable__option:hover { + background: var(--bg-subtle); +} +.model-select-searchable__option.is-active { + background: var(--accent-tint); + color: var(--accent-strong); +} +.model-select-searchable__option[data-selected='true']::after { + content: '✓'; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + font-size: 12px; + font-weight: 700; + color: currentColor; +} +.model-select-searchable__option-label { + font-size: 12.5px; + line-height: 1.35; +} +.model-select-searchable__option-meta { + font-size: 11px; + color: var(--text-muted); +} +.model-select-searchable__empty { + padding: 12px 10px; + font-size: 12px; + color: var(--text-muted); +} + + .inline-switcher__select { appearance: none; width: 100%; diff --git a/apps/web/src/styles/workspace/artifacts.css b/apps/web/src/styles/workspace/artifacts.css index a4ca83de0..9b9b8274e 100644 --- a/apps/web/src/styles/workspace/artifacts.css +++ b/apps/web/src/styles/workspace/artifacts.css @@ -1488,12 +1488,101 @@ } .agent-card-config .hint, .agent-model-row .hint { margin: 0; font-size: 11.5px; } + +.model-select-searchable { + position: relative; +} +.model-select-searchable.is-open { + z-index: 1400; +} +.model-select-searchable__popover { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: 1401; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: var(--shadow-lg); + overflow: hidden; +} +.model-select-searchable__search-row { + padding: 8px; + border-bottom: 1px solid var(--border); +} +.model-select-searchable__input { + width: 100%; +} +.model-select-searchable__list { + max-height: 280px; + overflow-y: auto; + display: flex; + flex-direction: column; + padding: 6px; +} +.model-select-searchable__option { + appearance: none; + border: 0; + background: transparent; + text-align: left; + padding: 8px 30px 8px 10px; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 2px; + color: var(--text); + cursor: pointer; + position: relative; +} +.model-select-searchable__option:hover { + background: var(--bg-subtle); +} +.model-select-searchable__option.is-active { + background: var(--accent-tint); + color: var(--accent-strong); +} +.model-select-searchable__option[data-selected='true']::after { + content: '✓'; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + font-size: 12px; + font-weight: 700; + color: currentColor; +} +.model-select-searchable__option-label { + font-size: 12.5px; + line-height: 1.35; +} +.model-select-searchable__option-meta { + font-size: 11px; + color: var(--text-muted); +} +.model-select-searchable__empty { + padding: 12px 10px; + font-size: 12px; + color: var(--text-muted); +} + .agent-card-config .hint { color: var(--text-muted); line-height: 1.35; } .agent-model-select-wrap { position: relative; + overflow: visible; +} + +.settings-model-select { + width: 100%; + min-height: 36px; + text-align: left; + line-height: 1.35; +} +.settings-model-select--byok { + min-width: min(420px, 100%); } .agent-model-select-wrap select { appearance: none; @@ -1502,6 +1591,12 @@ padding-right: 28px; width: 100%; } +.agent-model-select-wrap .model-select-searchable select { + background: + color-mix(in srgb, var(--bg-subtle) 82%, var(--bg-panel)) + url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2374716b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>") + no-repeat right 10px center; +} .agent-card-config .agent-model-select-wrap select { min-height: 36px; border-color: color-mix(in srgb, var(--border) 78%, transparent); diff --git a/apps/web/tests/components/InlineModelSwitcher.test.tsx b/apps/web/tests/components/InlineModelSwitcher.test.tsx index 988365a76..871444a2e 100644 --- a/apps/web/tests/components/InlineModelSwitcher.test.tsx +++ b/apps/web/tests/components/InlineModelSwitcher.test.tsx @@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi import { afterEach, describe, expect, it, vi } from 'vitest'; import { InlineModelSwitcher } from '../../src/components/InlineModelSwitcher'; import { AMR_LOGIN_TIMEOUT_MS } from '../../src/components/amrLoginPolling'; -import type { AgentInfo, AppConfig } from '../../src/types'; +import type { AgentInfo, AppConfig, ProviderModelOption } from '../../src/types'; const baseConfig: AppConfig = { mode: 'daemon', @@ -48,6 +48,7 @@ const codexAgent: AgentInfo = { function renderSwitcher( config: Partial<AppConfig> = {}, agents: AgentInfo[] = [amrAgent], + providerModelsCache?: Record<string, ProviderModelOption[]>, ) { const onAgentModelChange = vi.fn(); const view = render( @@ -60,6 +61,7 @@ function renderSwitcher( onAgentModelChange={onAgentModelChange} onApiProtocolChange={vi.fn()} onApiModelChange={vi.fn()} + providerModelsCache={providerModelsCache} onOpenSettings={vi.fn()} />, ); @@ -167,13 +169,15 @@ describe('InlineModelSwitcher AMR row', () => { expect(within(popover).queryByText(/Not signed in/i)).toBeNull(); expect(within(popover).queryByRole('button', { name: 'Sign in' })).toBeNull(); - const modelSelect = within(popover).getByTestId( + const modelPicker = within(popover).getByTestId( 'inline-model-switcher-agent-model', - ) as HTMLSelectElement; - expect(Array.from(modelSelect.options).map((option) => option.value)).toEqual([ - 'default', - 'amr-cloud-latest', - ]); + ); + expect(modelPicker.textContent).toContain('Default'); + fireEvent.click(modelPicker); + const modelPopover = screen.getByTestId('inline-model-switcher-agent-model-popover'); + expect( + within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()), + ).toEqual(['Default', 'AMR Cloud Latest']); }); it('persists the live AMR fallback when the saved AMR model is stale', async () => { @@ -196,14 +200,15 @@ describe('InlineModelSwitcher AMR row', () => { fireEvent.click(screen.getByTestId('inline-model-switcher-chip')); const popover = screen.getByTestId('inline-model-switcher-popover'); - const modelSelect = within(popover).getByTestId( + const modelPicker = within(popover).getByTestId( 'inline-model-switcher-agent-model', - ) as HTMLSelectElement; - expect(modelSelect.value).toBe('default'); - expect(Array.from(modelSelect.options).map((option) => option.value)).toEqual([ - 'default', - 'amr-cloud-latest', - ]); + ); + expect(modelPicker.textContent).toContain('Default'); + fireEvent.click(modelPicker); + const modelPopover = screen.getByTestId('inline-model-switcher-agent-model-popover'); + expect( + within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()), + ).toEqual(['Default', 'AMR Cloud Latest']); await waitFor(() => { expect(onAgentModelChange).toHaveBeenCalledWith('amr', { model: 'default', @@ -247,6 +252,78 @@ describe('InlineModelSwitcher AMR row', () => { expect(within(popover).queryByRole('button', { name: 'Sign out' })).toBeNull(); }); + it('filters fetched BYOK provider models in the Home switcher search box', async () => { + renderSwitcher( + { + mode: 'api', + apiProtocol: 'openai', + baseUrl: 'https://api.openai.com/v1', + apiProviderBaseUrl: 'https://api.openai.com/v1', + apiKey: 'sk-test', + model: 'gpt-4.1-mini', + }, + [amrAgent, codexAgent], + { + ['openai\nhttps://api.openai.com/v1\nsk-test\n']: [ + { id: 'gpt-4.1-mini', label: 'gpt-4.1-mini' }, + { id: 'gpt-4.1', label: 'gpt-4.1' }, + { id: 'gpt-5.5', label: 'gpt-5.5' }, + { id: 'o4-mini', label: 'o4-mini' }, + { id: 'o3', label: 'o3' }, + { id: 'o1', label: 'o1' }, + { id: 'gpt-4o', label: 'gpt-4o' }, + { id: 'gpt-4o-mini', label: 'gpt-4o-mini' }, + ], + }, + ); + + fireEvent.click(screen.getByTestId('inline-model-switcher-chip')); + + const modelPicker = screen.getByTestId('inline-model-switcher-api-model'); + fireEvent.click(modelPicker); + + const searchInput = screen.getByTestId( + 'inline-model-switcher-api-model-search', + ) as HTMLInputElement; + fireEvent.change(searchInput, { target: { value: '5.5' } }); + + const modelPopover = screen.getByTestId('inline-model-switcher-api-model-popover'); + expect( + within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()), + ).toEqual(['gpt-4.1-mini', 'gpt-5.5']); + }); + + it('prefers fetched BYOK provider models over only showing the currently selected custom model', async () => { + renderSwitcher( + { + mode: 'api', + apiProtocol: 'openai', + baseUrl: 'https://api.openai.com/v1', + apiProviderBaseUrl: 'https://api.openai.com/v1', + apiKey: 'sk-test', + model: 'gpt-4.1-mini', + }, + [amrAgent, codexAgent], + { + ['openai\nhttps://api.openai.com/v1\nsk-test\n']: [ + { id: 'gpt-4.1-mini', label: 'gpt-4.1-mini' }, + { id: 'gpt-4.1', label: 'gpt-4.1' }, + { id: 'gpt-5.5', label: 'gpt-5.5' }, + ], + }, + ); + + fireEvent.click(screen.getByTestId('inline-model-switcher-chip')); + + const modelPicker = screen.getByTestId('inline-model-switcher-api-model'); + fireEvent.click(modelPicker); + const modelPopover = screen.getByTestId('inline-model-switcher-api-model-popover'); + expect( + within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()), + ).toEqual(expect.arrayContaining(['gpt-4.1-mini', 'gpt-4.1', 'gpt-5.5'])); + expect(within(modelPopover).getAllByRole('option').length).toBeGreaterThan(1); + }); + it('treats env-backed AMR login as signed in even when no user profile is available', async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = input.toString(); diff --git a/apps/web/tests/components/SettingsDialog.execution.test.tsx b/apps/web/tests/components/SettingsDialog.execution.test.tsx index 37d578f84..585654e68 100644 --- a/apps/web/tests/components/SettingsDialog.execution.test.tsx +++ b/apps/web/tests/components/SettingsDialog.execution.test.tsx @@ -450,7 +450,7 @@ describe('SettingsDialog execution settings BYOK interactions', () => { target: { value: '1' }, }); - expect((screen.getByLabelText('Model') as HTMLSelectElement).value).toBe('deepseek-chat'); + expect(screen.getByRole('combobox', { name: 'Model' }).textContent).toContain('deepseek-chat'); expect((screen.getByLabelText('Base URL') as HTMLInputElement).value).toBe('https://api.deepseek.com'); }); @@ -469,7 +469,7 @@ describe('SettingsDialog execution settings BYOK interactions', () => { fireEvent.change(providerSelect, { target: { value: '1' } }); - expect((screen.getByLabelText('Model') as HTMLSelectElement).value).toBe('deepseek-chat'); + expect(screen.getByRole('combobox', { name: 'Model' }).textContent).toContain('deepseek-chat'); expect((screen.getByLabelText('Base URL') as HTMLInputElement).value).toBe( 'https://api.deepseek.com/anthropic', ); @@ -771,10 +771,12 @@ describe('SettingsDialog execution settings BYOK interactions', () => { }), expect.any(AbortSignal), ); - const select = screen.getByLabelText('Model') as HTMLSelectElement; - expect(Array.from(select.options).map((option) => option.value)).toEqual( - expect.arrayContaining(['gpt-account', 'gpt-4o', '__custom__']), - ); + const modelPicker = screen.getByRole('combobox', { name: 'Model' }); + fireEvent.click(modelPicker); + const modelPopover = screen.getByTestId('settings-byok-model-popover'); + expect( + within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()), + ).toEqual(expect.arrayContaining(['Account Model (gpt-account)', 'gpt-4o', 'Custom (type below)…'])); fireEvent.click(screen.getByRole('tab', { name: 'Azure OpenAI' })); expect(screen.queryByRole('button', { name: 'Fetch models' })).toBeNull(); @@ -800,6 +802,45 @@ describe('SettingsDialog execution settings BYOK interactions', () => { expect(screen.queryByText('Fill API key to test the connection.')).toBeNull(); }); + it('filters long BYOK model lists in Settings after provider discovery succeeds', async () => { + fetchProviderModelsMock.mockResolvedValueOnce({ + ok: true, + kind: 'success', + latencyMs: 12, + models: [ + { id: 'gpt-4.1-mini', label: 'gpt-4.1-mini' }, + { id: 'gpt-4.1', label: 'gpt-4.1' }, + { id: 'gpt-5.5', label: 'gpt-5.5' }, + { id: 'o4-mini', label: 'o4-mini' }, + { id: 'o3', label: 'o3' }, + { id: 'o1', label: 'o1' }, + { id: 'gpt-4o', label: 'gpt-4o' }, + { id: 'gpt-4o-mini', label: 'gpt-4o-mini' }, + ], + }); + renderSettingsDialog({ + apiProtocol: 'openai', + apiKey: 'sk-openai', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4.1-mini', + apiProviderBaseUrl: 'https://api.openai.com/v1', + }); + + fireEvent.click(screen.getByRole('tab', { name: 'OpenAI' })); + expect(await screen.findByText('✓ Loaded 8 models from your account.')).toBeTruthy(); + + const modelPicker = screen.getByRole('combobox', { name: 'Model' }); + fireEvent.click(modelPicker); + + const searchInput = screen.getByTestId('settings-byok-model-search') as HTMLInputElement; + fireEvent.change(searchInput, { target: { value: '5.5' } }); + + const modelPopover = screen.getByTestId('settings-byok-model-popover'); + expect( + within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()), + ).toEqual(['gpt-4.1-mini', 'gpt-5.5', 'Custom (type below)…']); + }); + it('fetches provider models, merges them into the picker, and preserves a custom current model', async () => { fetchProviderModelsMock.mockResolvedValueOnce({ ok: true, @@ -830,13 +871,12 @@ describe('SettingsDialog execution settings BYOK interactions', () => { }), expect.any(AbortSignal), ); - const select = screen.getByLabelText('Model') as HTMLSelectElement; - expect(Array.from(select.options).map((option) => option.value)).toEqual( - expect.arrayContaining(['remote-alpha', 'gpt-4o', '__custom__']), - ); + const modelPicker = screen.getByRole('combobox', { name: 'Model' }); + fireEvent.click(modelPicker); + const modelPopover = screen.getByTestId('settings-byok-model-popover'); expect( - Array.from(select.options).some((option) => option.textContent === 'Remote Alpha (remote-alpha)'), - ).toBe(true); + within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()), + ).toEqual(expect.arrayContaining(['Remote Alpha (remote-alpha)', 'gpt-4o', 'Custom (type below)…'])); expect((screen.getByLabelText('Custom model id') as HTMLInputElement).value).toBe('custom-still-here'); }); @@ -916,9 +956,9 @@ describe('SettingsDialog execution settings BYOK interactions', () => { fireEvent.change(screen.getByLabelText('API key'), { target: { value: 'sk-openai' }, }); - fireEvent.change(screen.getByLabelText('Model'), { - target: { value: '__custom__' }, - }); + fireEvent.click(screen.getByRole('combobox', { name: 'Model' })); + const modelPopover = screen.getByTestId('settings-byok-model-popover'); + fireEvent.click(within(modelPopover).getByRole('option', { name: 'Custom (type below)…' })); const customModelInput = screen.getByLabelText('Custom model id') as HTMLInputElement; expect(customModelInput).toBeTruthy(); @@ -1105,6 +1145,48 @@ describe('SettingsDialog execution settings Local CLI interactions', () => { ); }); + it('filters long Local CLI model lists in Settings without hiding the current selection', () => { + renderSettingsDialog( + { mode: 'daemon', agentId: 'codex', agentModels: { codex: { model: 'gpt-4.1-mini' } } }, + { + agents: [ + { + ...availableAgents[0]!, + modelsSource: 'live', + models: [ + { id: 'default', label: 'Default' }, + { id: 'gpt-4.1-mini', label: 'gpt-4.1-mini' }, + { id: 'gpt-4.1', label: 'gpt-4.1' }, + { id: 'gpt-5.5', label: 'gpt-5.5' }, + { id: 'o4-mini', label: 'o4-mini' }, + { id: 'o3', label: 'o3' }, + { id: 'o1', label: 'o1' }, + { id: 'gpt-4o', label: 'gpt-4o' }, + { id: 'gpt-4o-mini', label: 'gpt-4o-mini' }, + ], + }, + ], + }, + ); + + fireEvent.click(screen.getByRole('tab', { name: /Local CLI/i })); + const codexCard = screen.getByRole('button', { name: /Codex CLI/i }); + fireEvent.click(codexCard); + + const modelPicker = screen.getByRole('combobox', { + name: en['settings.modelPicker'], + }); + fireEvent.click(modelPicker); + + const searchInput = screen.getByTestId('settings-agent-model-search-codex') as HTMLInputElement; + fireEvent.change(searchInput, { target: { value: '5.5' } }); + + const modelPopover = screen.getByTestId('settings-agent-model-popover-codex'); + expect( + within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()), + ).toEqual(['gpt-4.1-mini', 'gpt-5.5', 'Custom (type below)…']); + }); + it('labels live CLI model metadata in the model picker', () => { renderSettingsDialog( { mode: 'daemon', agentId: 'codex' }, @@ -1175,12 +1257,14 @@ describe('SettingsDialog execution settings Local CLI interactions', () => { const modelPickers = screen.getAllByRole('combobox', { name: en['settings.modelPicker'], - }) as HTMLSelectElement[]; + }); expect(modelPickers).toHaveLength(1); - expect(modelPickers[0]?.value).toBe('glm-5'); + expect(modelPickers[0]?.textContent).toContain('GLM 5'); + fireEvent.click(modelPickers[0]!); + const modelPopover = screen.getByTestId('settings-agent-model-popover-amr'); expect( - Array.from(modelPickers[0]?.options ?? []).map((option) => option.value), - ).toEqual(['glm-5', 'glm-5.1']); + within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()), + ).toEqual(['GLM 5', 'GLM 5.1']); expect(screen.queryByLabelText(en['settings.modelCustomLabel'])).toBeNull(); }); From 4b7c018a9bacd7c2a540f74e2ab50aa7d5636edb Mon Sep 17 00:00:00 2001 From: koki <leilei926524@gmail.com> Date: Fri, 29 May 2026 15:16:04 +0800 Subject: [PATCH 18/56] feat(contrib): add od-contribute skill for non-coder contributors (#3172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(contrib): add od-contribute skill for non-coder contributors Adds a Claude Code skill at .claude/skills/od-contribute/ that walks any OD user — including non-coders — through a first-PR contribution flow: - Ship a Skill / Design System made with OD - Translate README / QUICKSTART / CONTRIBUTING to a new language - Fix a typo / dead link / write a use-case blog post - Report a high-quality bug (issue path, no PR) The skill replaces the test-driven dev-loop of auto-github-contributor with type-specific no-code validators (frontmatter parse, markdown link check, code-fence balance, structural overlap with reference DESIGN.md files), so artifact-only contributions don't have to pretend to be code. This commit only adds files under .claude/ — no product code, no build config, no runtime dependencies. .gitignore is amended with three explicit exceptions so the skill is tracked while personal Claude state (sessions, settings, etc.) stays ignored as before. Next steps (separate PRs): - Wire the OD app to mount this skill for its embedded agent - Add a "Ship to GitHub" UI button in OD that invokes /od-contribute Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com> * feat(contrib): English-by-default skill + zip installer for non-coders Two follow-ups to the initial od-contribute skill: 1. Skill content is now English with an explicit instruction at the top telling the agent to mirror the user's chat language for every user- facing prompt. Generated artifacts (PR titles, commit messages, PR/ issue body) stay English regardless — GitHub convention. 2. tools/od-contribute-installer/ ships a cross-platform installer that drops the skill into every supported agent's home dir without the user opening a terminal: install.command macOS double-click install.bat Windows double-click install.sh Linux Targets covered: ~/.claude/skills/od-contribute/ Claude Code (native) ~/.claude/commands/od-contribute.md Claude Code slash command ~/.agents/skills/od-contribute/ Codex CLI (canonical) ~/.codex/skills/od-contribute/ Codex CLI (legacy, only written if ~/.codex/ exists) Verified Codex CLI reads the same SKILL.md frontmatter format as Claude Code (source: openai/codex codex-rs/core-skills/src/loader.rs). Added agents/openai.yaml sidecar inside the skill for Codex picker UX. 3. build-zip.sh produces od-contribute-installer.zip (~37KB) from the in-repo skill. The zip is meant to be hosted as a GitHub Release asset; the marketing site button points at: github.com/nexu-io/open-design/releases/latest/download/od-contribute-installer.zip (See tools/od-contribute-installer/HOSTING.md for the manual release recipe; CI workflow can come later.) The zip itself is gitignored — distribute via Releases, not source. Still no product code touched, no build config changed. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com> * refactor(contrib): drop zip installer; ship single curl one-liner Replace tools/od-contribute-installer/ (4 install scripts + zip build machinery) with a single self-bootstrapping tools/install-od-contribute.sh. User flow becomes: 1. Click button on opendesign.so 2. Modal shows: paste this into your AI agent's chat: curl -sSL https://raw.githubusercontent.com/nexu-io/open-design/main/tools/install-od-contribute.sh | bash 3. Agent runs it via its Bash tool. User never touches a terminal. 4. /od-contribute is live in their next chat. Why this is better than the zip approach: * Zero downloads visible to the user — no .zip in their Downloads folder * Zero unzip step * Zero terminal window flash (the agent's Bash tool runs in-process) * Zero per-OS installer files (.command/.bat/.sh) to maintain * Auto-updates: re-running the one-liner pulls the latest skill from main The script downloads only the skill subtree (.claude/skills/od-contribute/ and .claude/commands/od-contribute.md) from a GitHub tarball — no `git` dependency, just curl + tar (universally available). Targets remain the same: ~/.claude/skills/od-contribute/ ~/.claude/commands/od-contribute.md ~/.agents/skills/od-contribute/ ~/.codex/skills/od-contribute/ (only if ~/.codex/ exists) Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com> * chore(contrib): remove leftover zip artifact Build artifact accidentally committed in the previous commit. Cleaning up so the binary doesn't live in git history. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com> * fix(contrib): make skill work in sandboxed agents (Codex.app, Cursor) macOS App Sandbox apps like Codex.app cannot reach the system keychain where `gh auth login` stores the GitHub token by default. Result: the skill's check-prereqs.sh fails on `gh auth status` with a misleading "not authenticated" error, even when gh works fine in the user's regular shell. Two changes: 1. config.sh: if GH_TOKEN isn't set in the env, fall back to reading a .gh-token file at the skill root. Lets a user (or the OD app, or a future OAuth Device Flow bootstrapper) drop a token there once and have every skill script pick it up automatically. 2. check-prereqs.sh: accept GH_TOKEN-from-env as a valid auth path alongside `gh auth status`. When neither works, the error hint now shows BOTH options: A) gh auth login from a regular terminal (any agent) B) gh auth token > <skill>/.gh-token (sandboxed agents) Verified: in my local Claude Code (where gh has keychain access), the keychain path still wins and nothing changes. With GH_TOKEN exported, check-prereqs.sh succeeds without even consulting gh auth status. Future: implement OAuth Device Flow inside the skill so non-coder users hitting this in Codex.app can authenticate by clicking a link, no terminal involved. That's a separate PR. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com> * chore(contrib): move install script into skill folder (CI policy fix) The repo's tools/ directory has a strict allowlist policy enforced by scripts/guard.ts — only AGENTS.md, dev/, pack/, and serve/ are permitted top-level entries. Moving install-od-contribute.sh out of tools/ and into .claude/skills/od-contribute/install.sh: - Satisfies the guard policy (no scripts/guard.ts edit needed) - Co-locates the install script with the skill it installs (cleaner mental model: skill folder is self-contained) - The install URL stays inside the gitignore exception we already established for .claude/skills/od-contribute/ Public install URL changes from raw.githubusercontent.com/.../main/tools/install-od-contribute.sh to raw.githubusercontent.com/.../main/.claude/skills/od-contribute/install.sh Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com> * fix(contrib): address @nettee/looper review feedback (3 blocking issues) Three real bugs caught by the looper review bot, all fixed: 1) create-pr.sh:48 — git diff missed untracked files `git diff --quiet || git diff --cached --quiet` ignored untracked paths, so the most common contribution shape (a brand-new Skill folder, a new translation file, a new doc) hit the else branch and pushed an empty commit. Replaced with `git status --porcelain` which sees untracked, plus a post-stage sanity check via `git diff --cached --quiet` so we skip the commit cleanly if everything turned out to be in .gitignore. 2) validate-skill-submission.sh:34 — frontmatter parse too lenient The awk fence-counter accepted `---` anywhere in the file as the opening fence. A SKILL.md with prose before the YAML block parsed as "valid frontmatter" by this script while the actual loaders (Claude Code + codex-rs/core-skills) required the fence on line 1 and would reject it. Added an explicit head -n 1 check so leading prose is rejected with a clear error before awk runs. 3) check-prereqs.sh:87 — gh api user failure swallowed `GH_USER="$(gh api user --jq .login 2>/dev/null || echo '?')"` set GH_USER to literal "?" when the API call failed (revoked token, missing 'repo' scope, network), then the script exited READY=1. Downstream that propagated to TARGET_FORK="?/open-design" and blew up at push time. Dropped the `|| echo '?'` fallback. An empty GH_USER now triggers a structured error with three common causes and the recovery command, and exits 2. While here, also fixed a related bug: this script sources config.sh which has `set -euo pipefail`, so -e leaked in and aborted the script silently the moment any check failed (instead of accumulating diagnostics like the original auto-github-contributor design intended). Added explicit `set +e; set -uo pipefail` after sourcing to restore the "keep checking past failures" behavior the comment on line 7 promised. Smoke-tested all four fixes locally: - create-pr.sh: git status --porcelain correctly sees untracked files - validator: rejects SKILL.md starting with prose, passes well-formed - check-prereqs.sh: with stubbed gh that fails `gh api user`, now exits 2 with the structured error (was: silent exit 1) - check-prereqs.sh: happy path on real machine unchanged Thanks @nettee for the careful review. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com> * fix(contrib): macOS Bash 3.2 + over-strict link validator (review round 2) Two more blocking issues from the looper review, plus one related bug I caught while re-testing on real OD docs. 1) discover-i18n-gaps.sh: removed Bash 4 dep (declare -A) macOS still ships Bash 3.2.57 by default and most agent-spawned bash subprocesses inherit that. `declare -A SEEN_LANG=()` failed with `declare: -A: invalid option`, crashing Step 3b before any translation target could be shown. Replaced the associative array with a newline-delimited string set (\n<lang>\n bracket form to avoid prefix-overlap false matches like zh vs zh-CN). Verified end-to-end on /bin/bash 3.2.57 against the actual OD repo: returns the correct 28 stale-translation rows across the four English source docs. Also fixed a latent path-stripping bug in the same loop: `find` emits `./README.zh-CN.md` with leading `./`, so `${path#README.}` wasn't stripping the prefix at all. Switched to basename-first. 2) validate-markdown.sh: --reference flag for i18n / docs-edit flows The validator was treating every relative link target as a file path and failing on slugs like `skills/blog-post/` that are website router routes, not files in the checkout. A structure-preserving translation of README.md couldn't pass even when the user changed nothing except language. Added --reference <orig> flag. The validator now builds a "known already-broken" set of refs from the source file and excuses those in the new file. Newly-introduced broken refs still fail. Without --reference (e.g. brand-new blog file with no prior version), the relative-ref check is skipped entirely with a SKIP note — since we can't tell route slugs from file paths in isolation, failing would be wrong. Code-fence balance + external-link health still run. Updated SKILL.md so the i18n branch (3b.6) and the docs branch (3c.6) call validate-markdown.sh with --reference pointing at the English source / HEAD revision respectively. 3) (caught while testing) URL extraction regex too loose `grep -oE 'https?://[^) ]+'` was capturing trailing quotes from HTML <img src="..."> tags in OD's README, e.g. https://cms-assets.youmind.com/.../foo.jpg" The trailing `"` made the curl HEAD return 404. Tightened the character class to also stop at `"`, `'`, `<`, `>`, `[`, `]`. With this fix, README.md now passes all checks (20 external links verified 2xx/3xx). Smoke-tested on macOS /bin/bash 3.2.57 with the actual nexu-io/open-design working copy. All four scenarios behave correctly: - README.md without --reference → SKIP relative-ref check, PASS overall - README.md with --reference itself → 34 refs excused as pre-existing, PASS - Newly-introduced broken ref → FAIL (regression catch preserved) - Old test cases (skill validator, prereq check) → still pass Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com> * fix(contrib): preserve .gh-token across install.sh reruns `install_skill_to()` did `rm -rf $dest` before copying in the new skill, which wiped any user-local state files. The most consequential one is `.gh-token` — sandboxed agents (Codex.app, Cursor) write a GitHub token there because they can't reach the macOS keychain (see check-prereqs.sh's hint and config.sh's fallback path). Effect: the documented upgrade path ("re-run the curl one-liner to pull the latest skill") would silently lose the token on every refresh, and the very next /od-contribute run would fail at the prereq gate with "no GitHub credentials available", forcing the user back through manual token setup. This affects exactly the audience the PR is aimed at. Fix: stash any file in PRESERVE=(.gh-token) to a tempdir before rm -rf, restore after the copy, re-chmod 600 on the way back. Test: 1. Pre-seed .gh-token in all three target dirs 2. Run installer 3. Verify all three tokens still present, contents unchanged, perms 600 Centralized the preserved-state list as PRESERVE=() so future per-user state (e.g. an OAuth-flow-saved refresh token) only has to be added in one place. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com> * fix(contrib): i18n stale false-positive + tier markdown link check (round 4) Two more blocking issues from looper, both real. 1) discover-i18n-gaps.sh: false-stale on same-commit translations `git log --since=@<epoch>` is INCLUSIVE of the boundary epoch, so when the English source and a translation get touched in the SAME commit (a very common pattern: bulk i18n refresh, structural edits applied across all locales), the shared commit was counted toward english_commits_since_translation. Result: an already-current translation was reported with `status="stale", english_commits_since_ translation=1`, and Step 3b would suggest it for refresh — driving users into no-op PRs. Reproduced exactly per looper's case: README.md and README.uk.md both have last commit 338cb4d at epoch 1779948707; the OLD predicate returned 1, the NEW predicate returns 0. Switched commits_between() from `--since=@<epoch>` math to commit ancestry: `git rev-list <tr_sha>..HEAD -- <newer>`. tr_sha..HEAD reads "commits reachable from HEAD but not from tr_sha", which correctly excludes the shared tip when both files were last touched together. 2) validate-markdown.sh: brand-new files bypassed local link check The previous fix skipped relative-ref validation entirely when --reference was absent. That covered slug-style refs (good) but also covered explicit `./foo.md` and `../bar/baz.md` style refs (bad). Step 3c (new blog post) doesn't pass --reference, so a contribution could ship with `[broken](./missing.md)` and pass the validator. Tiered the relative-ref check: - Image refs (`![alt](path)`) — ALWAYS validated. Markdown image syntax is never a website route. - Refs starting with `./` or `../` — ALWAYS validated. Explicit relative paths are unambiguous file references. - Other link refs (`skills/blog-post/` style) — only validated when --reference is supplied; otherwise skipped (could be route). In all cases, refs already broken in --reference (when supplied) are excused as pre-existing rather than reported as regressions. Verified against looper's exact repro (`[new broken](./missing.md)` in a brand-new file with no --reference): now correctly fails. Also verified ambiguous-slug test (`skills/blog-post/`) still skips without --reference, image refs always check, and README.md regression tests both with and without --reference still pass. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com> * fix(contrib): catch bare-path refs in validators (review round 5) Two narrow follow-ups to the round-4 tiered link checks: - validate-skill-submission.sh: scan every non-URL, non-anchor markdown link target in SKILL.md (not just `./` / `../` prefixed paths). Plain intra-skill refs like `[ref](references/foo.md)` were previously ignored by the regex, letting broken bundles pass. Escape detection switches to lexical (segment count) instead of `cd … && pwd -P`, so a missing intermediate directory no longer masquerades as an escape. - validate-markdown.sh: treat file-like targets (`*.md`, `*.png`, `*.svg`, image/asset/script extensions) as on-disk refs even without `--reference`. `[doc](missing.md)` is unambiguously a sibling file, not a website route, and Step 3c (new docs/blog) had no `--reference` to fall back on. Slug-style refs without an extension still get skipped without `--reference`. Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code) * fix(contrib): scratch leak + dedupe gate + workdir reuse (review round 6) Three blocking issues from looper round 6, all fixed. 1) create-pr.sh + setup-workspace.sh: .od-contrib/ scratch leaked into PR `git add -A` in create-pr.sh staged everything in the worktree, including the skill's internal scratch dir (.od-contrib/type.txt, .od-contrib/ slug.txt, .od-contrib/PR-BODY.md created by setup-workspace.sh and the render step). OD's .gitignore doesn't exclude .od-contrib/, so every PR opened through this flow shipped those bookkeeping files in the user's contribution diff. Two layers of defense: - setup-workspace.sh now writes `.od-contrib/` to .git/info/exclude when preparing the workdir (repo-local exclude, not committed). - create-pr.sh now uses an explicit pathspec `:!:.od-contrib` on its git status / git add calls. So even if a workdir was prepared differently, this script alone refuses to stage the scratch dir. Verified with a temp repo containing both .od-contrib/PR-BODY.md and a user file: only the user file lands in the index after `git add -A -- . :!:.od-contrib`. 2) create-issue.sh: dedupe gate didn't actually gate The --dedupe-keywords flag printed search hits to stderr but then unconditionally fell through to `gh issue create`. The `|| true` after the gh search pipeline also swallowed network/jq failures, so a broken search looked identical to "no duplicates found" — and the issue got created either way. The user never got a real chance to choose "comment on existing / open anyway / cancel". Now: - Run gh search and jq as separate steps; either failure exits 2 with a structured REASON=search_failed/parse_failed. - If matches > 0 AND --allow-duplicates was NOT passed, exit 3 with REASON=duplicates_found and MATCH_COUNT=N. Caller must explicitly re-run with --allow-duplicates after surfacing matches to the user. - The script now requires `jq` (added od::require jq) since we actually parse JSON. - Updated the docstring at the top so the caller contract (ask the user, then re-invoke with --allow-duplicates) is explicit. Verified: searching keyword "preview" against nexu-io/open-design matches 5 open issues; the script exits 3 and never calls `gh issue create`. 3) setup-workspace.sh: same-day workdir reuse leaked stale state `SESSION_DIR=<TYPE>-<SLUG>-<YYYYMMDD>` reused the same directory for every same-day, same-(type,slug) invocation. The most acute case: SKILL.md 3b.1 calls `setup-workspace.sh i18n translate` BEFORE the user has picked a doc/language, so every i18n attempt on the same day landed in `i18n-translate-<date>/` — and untracked files from an abandoned earlier translation survived `git checkout`/`pull` and leaked into the next user's run. Two changes: - Bumped tag to second precision: `<YYYYMMDD>-<HHMMSS>`. Two human- paced sessions in the same second is vanishingly rare. Verified two rapid runs produce different tags (114208 vs 114209). - When a workdir IS reused (same SESSION_TAG passed in explicitly, or rare clock collision), now does `git reset --hard HEAD` and `git clean -fdx` first so the run starts from a known-good base instead of inheriting prior occupant state. The branch name now also tracks the timestamp tag, so two runs can't accidentally end up on the same feature branch either. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com> --------- Co-authored-by: leilei926524-tech <leilei926524-tech@users.noreply.github.com> Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com> --- .claude/commands/od-contribute.md | 23 ++ .claude/skills/od-contribute/SKILL.md | 320 ++++++++++++++++++ .../skills/od-contribute/agents/openai.yaml | 13 + .claude/skills/od-contribute/install.sh | 136 ++++++++ .../references/design-system-anatomy.md | 51 +++ .../od-contribute/references/newcomer-tone.md | 42 +++ .../od-contribute/references/od-repo-map.md | 38 +++ .../od-contribute/references/skill-anatomy.md | 53 +++ .../od-contribute/scripts/check-prereqs.sh | 121 +++++++ .../skills/od-contribute/scripts/config.sh | 66 ++++ .../od-contribute/scripts/create-issue.sh | 100 ++++++ .../skills/od-contribute/scripts/create-pr.sh | 116 +++++++ .../scripts/discover-doc-gaps.sh | 118 +++++++ .../scripts/discover-i18n-gaps.sh | 114 +++++++ .../od-contribute/scripts/setup-workspace.sh | 92 +++++ .../scripts/validate-design-system.sh | 97 ++++++ .../scripts/validate-markdown.sh | 205 +++++++++++ .../scripts/validate-skill-submission.sh | 138 ++++++++ .../od-contribute/templates/ISSUE-BODY-bug.md | 37 ++ .../templates/PR-BODY-design-system.md | 37 ++ .../od-contribute/templates/PR-BODY-docs.md | 32 ++ .../od-contribute/templates/PR-BODY-i18n.md | 41 +++ .../od-contribute/templates/PR-BODY-skill.md | 37 ++ .gitignore | 10 +- 24 files changed, 2036 insertions(+), 1 deletion(-) create mode 100644 .claude/commands/od-contribute.md create mode 100644 .claude/skills/od-contribute/SKILL.md create mode 100644 .claude/skills/od-contribute/agents/openai.yaml create mode 100755 .claude/skills/od-contribute/install.sh create mode 100644 .claude/skills/od-contribute/references/design-system-anatomy.md create mode 100644 .claude/skills/od-contribute/references/newcomer-tone.md create mode 100644 .claude/skills/od-contribute/references/od-repo-map.md create mode 100644 .claude/skills/od-contribute/references/skill-anatomy.md create mode 100755 .claude/skills/od-contribute/scripts/check-prereqs.sh create mode 100755 .claude/skills/od-contribute/scripts/config.sh create mode 100755 .claude/skills/od-contribute/scripts/create-issue.sh create mode 100755 .claude/skills/od-contribute/scripts/create-pr.sh create mode 100755 .claude/skills/od-contribute/scripts/discover-doc-gaps.sh create mode 100755 .claude/skills/od-contribute/scripts/discover-i18n-gaps.sh create mode 100755 .claude/skills/od-contribute/scripts/setup-workspace.sh create mode 100755 .claude/skills/od-contribute/scripts/validate-design-system.sh create mode 100755 .claude/skills/od-contribute/scripts/validate-markdown.sh create mode 100755 .claude/skills/od-contribute/scripts/validate-skill-submission.sh create mode 100644 .claude/skills/od-contribute/templates/ISSUE-BODY-bug.md create mode 100644 .claude/skills/od-contribute/templates/PR-BODY-design-system.md create mode 100644 .claude/skills/od-contribute/templates/PR-BODY-docs.md create mode 100644 .claude/skills/od-contribute/templates/PR-BODY-i18n.md create mode 100644 .claude/skills/od-contribute/templates/PR-BODY-skill.md diff --git a/.claude/commands/od-contribute.md b/.claude/commands/od-contribute.md new file mode 100644 index 000000000..a6d93a5f3 --- /dev/null +++ b/.claude/commands/od-contribute.md @@ -0,0 +1,23 @@ +--- +description: Open a first-contribution PR (or bug issue) on nexu-io/open-design — works for non-coders too. +argument-hint: "[skill | design-system | i18n | docs | bug — optional, otherwise the skill will ask]" +--- + +You are entering the **od-contribute** flow. + +User input (may be empty): `$ARGUMENTS` + +## What to do right now + +1. **Load the skill** by invoking the `od-contribute` skill via the Skill tool. The skill owns the full execution playbook — do not reimplement it inline. + +2. **Pass the user input forward**: + - If `$ARGUMENTS` is one of `skill`, `design-system`, `i18n`, `docs`, `bug` (or a recognizable Chinese / English equivalent), pre-select that branch and skip the type-picking `AskUserQuestion` in Step 2. + - Otherwise, the skill will ask the user via `AskUserQuestion`. + +3. **Honor the interactive contract**: + - Always run the prerequisite check first (`gh` installed + authed). If it fails, surface the install/auth hint and stop — do not try workarounds. + - Always show the preview + require explicit confirmation before pushing or opening any PR/issue. + - At the end, print the PR or issue URL on its own line so the user can click through. + +Begin by invoking the skill now. diff --git a/.claude/skills/od-contribute/SKILL.md b/.claude/skills/od-contribute/SKILL.md new file mode 100644 index 000000000..3b9abae11 --- /dev/null +++ b/.claude/skills/od-contribute/SKILL.md @@ -0,0 +1,320 @@ +--- +name: od-contribute +description: One-click contribution flow for Open Design (nexu-io/open-design) — even for non-coders. Pick one of four cards (ship a Skill or Design System you made with OD; translate docs; fix a typo / write a blog; report a bug), the agent validates and opens a PR (or issue) for you. Trigger words contribute to open design, ship my OD skill, ship my OD design system, translate OD docs, report an OD bug, od-contribute. +allowed-tools: + - Bash + - Read + - Write + - Edit + - AskUserQuestion + - TaskCreate + - TaskUpdate + - WebFetch +--- + +# od-contribute — first-contribution flow for Open Design + +Locked to `nexu-io/open-design`. Branches by **contribution type**, not by issue. Replaces the dev-loop with type-specific no-code validators. Designed so a product user with zero coding background can ship a real PR. + +## Language + +Mirror the user's language in every user-facing message — `AskUserQuestion` labels and descriptions, status updates, error explanations. Detect from their first message; when uncertain, default to English. + +**Generated artifacts (PR titles, commit messages, PR/issue body files, branch names) MUST be English** regardless of the user's chat language. GitHub conventions, maintainer review, and search all assume English. The templates under `templates/` are already English — keep them that way when rendering. + +Scripts live under `scripts/`. Source the shared helpers from any script: + +```bash +source "$(dirname "$0")/config.sh" +``` + +`SKILL_DIR` below = the directory that contains this `SKILL.md`. + +--- + +## Step 1 — Prereq check (always first) + +```bash +bash "$SKILL_DIR/scripts/check-prereqs.sh" +``` + +- Exit 0: capture `GH_USER=<login>` from stdout. Default `TARGET_FORK="${GH_USER}/open-design"`. +- Exit 2: surface the printed install / auth hint **verbatim** and stop. Do not attempt token workarounds. + +If `gh repo view "$TARGET_FORK"` fails, ask the user (one `AskUserQuestion`) whether to fork now via `gh repo fork nexu-io/open-design --clone=false`. Default to yes. + +## Step 2 — Pick contribution type + +Single `AskUserQuestion` (header: "Contribution", multiSelect: false), four options. Translate option labels/descriptions into the user's chat language; the branch routing is unchanged. + +1. **🎨 Ship something I made with OD** — _a Skill, Design System, HyperFrame, or template I want to contribute upstream_ → branch `3a` +2. **🌍 Translate OD docs** — _README / QUICKSTART / CONTRIBUTING into a new language_ → branch `3b` +3. **📝 Fix docs / write a blog / fix a typo** — _typo fix, dead link, use-case writeup_ → branch `3c` +4. **🐛 Report a bug** — _something broke; I'll help turn it into a high-quality issue_ → branch `3d` (issue path, no PR) + +Each branch below is self-contained. Steps 7–8 (preview + push) are shared across branches `3a`/`3b`/`3c`. Branch `3d` skips them entirely. + +--- + +### Step 3a — OD product submission (Skill / Design System) + +**3a.1** Ask user: "What's the local path to the artifact you want to ship?" (single free-text, translated into the user's chat language). Common: a folder path (Skill) or a single `DESIGN.md` file (Design System). + +**3a.2** Sniff type: + +```bash +# Skill: folder containing SKILL.md with frontmatter. +# Design System: file matching DESIGN.md anatomy. +``` + +If ambiguous, ask the user to confirm. + +**3a.3** Run setup: + +```bash +bash "$SKILL_DIR/scripts/setup-workspace.sh" skill <slug> +# or +bash "$SKILL_DIR/scripts/setup-workspace.sh" design-system <slug> +``` + +`<slug>` is `od::slugify` of the Skill `name` frontmatter field or of the brand name. Capture `WORKDIR` from stdout. + +**3a.4** Copy artifact into workspace at the right target dir: +- Skill → `$WORKDIR/skills/<slug>/` +- Design System → `$WORKDIR/design-systems/<brand-slug>/DESIGN.md` (+ any sibling assets in the same folder) + +**3a.5** Validate: + +```bash +bash "$SKILL_DIR/scripts/validate-skill-submission.sh" "$WORKDIR/skills/<slug>" +# or, with 1-2 reference DESIGN.md files passed in: +bash "$SKILL_DIR/scripts/validate-design-system.sh" \ + "$WORKDIR/design-systems/<slug>/DESIGN.md" \ + --reference "$WORKDIR/design-systems/airbnb/DESIGN.md" \ + --reference "$WORKDIR/design-systems/apple/DESIGN.md" +``` + +If validation fails, surface the FAIL lines verbatim, ask the user to fix, retry. **Never push a failing artifact.** + +**3a.6** Ask 3 short questions via `AskUserQuestion` (translate the labels into the user's chat language): +- "What name should we credit you under in the PR?" — free-text +- "One-line pitch for this Skill / Design System?" — free-text +- "Path to a screenshot (optional)?" — free-text + +**3a.7** Render `templates/PR-BODY-skill.md` (or `PR-BODY-design-system.md`) with substitutions: +- `{{SKILL_NAME}}`, `{{SKILL_SLUG}}` (or `{{BRAND_NAME}}`, `{{BRAND_SLUG}}`) +- `{{PITCH}}` (the one-line) +- `{{MOTIVATION}}` (free-text — agent can offer to draft this from the skill body, but user confirms) +- `{{TRY_PROMPT}}` (a prompt they recommend trying — agent suggests a default, user confirms) +- `{{SCREENSHOT_BLOCK}}` (Markdown image block if a screenshot path was given, else empty) +- `{{DISCORD_INVITE}}` from `$OD_DISCORD_INVITE` + +Write to `$WORKDIR/.od-contrib/PR-BODY.md`. + +→ Jump to **Step 7**. + +--- + +### Step 3b — i18n translation + +**3b.1** Setup workspace (slug = `translate-<doc>-<lang>` if known, else `translate`): + +```bash +bash "$SKILL_DIR/scripts/setup-workspace.sh" i18n translate +# capture WORKDIR +``` + +**3b.2** Discover gaps: + +```bash +bash "$SKILL_DIR/scripts/discover-i18n-gaps.sh" "$WORKDIR" > /tmp/od-i18n-gaps.json +``` + +Each line is JSON. Rank by: +- `status: "missing"` first (missing language is highest leverage) +- then `status: "stale"` ordered by `english_commits_since_translation` desc +- README family before QUICKSTART before CONTRIBUTING + +**3b.3** Take the top 3–4 gaps and present via `AskUserQuestion` (header: "Translation target"). Each option label like: `README → 한국어 (Korean)` / `QUICKSTART (zh-CN) refresh — 12 commits behind`. Translate the header text into the user's chat language but keep the option labels descriptive (the language names belong in their native script). + +**3b.4** Once user picks, **rename branch** to be specific: +```bash +git -C "$WORKDIR" branch -m "od-contrib/i18n/<doc>-<lang>-<date>" +``` +(or pre-set the slug in step 3b.1 if the user confirmed earlier.) + +**3b.5** Translate. Read the English source. Translate **structure-preserving**: +- Code blocks: leave untranslated +- Brand / product names: leave untranslated +- Filenames in inline code: leave untranslated +- Image / link targets: leave untranslated; if a localized version of a linked doc exists, swap the link to the localized file +- Headings: translate, keep the heading depth identical +- Tables: translate cell text only, keep alignment / pipes + +Write the result to `$WORKDIR/<TRANSLATED_PATH>` (e.g. `QUICKSTART.es.md`). Show user a unified diff vs. the English source for visual sanity-check (line-count delta within ±15% is a healthy signal). + +**3b.6** Validate the translated file against the English source. The `--reference` flag tells the validator to ignore relative refs that were already broken in the source — OD docs frequently link to website route slugs (e.g. `skills/blog-post/`) that aren't files on disk; we don't want a structure-preserving translation to fail because of pre-existing dead refs. + +```bash +bash "$SKILL_DIR/scripts/validate-markdown.sh" \ + "$WORKDIR/<TRANSLATED_PATH>" \ + --reference "$WORKDIR/<ENGLISH_PATH>" +``` + +If FAIL → surface verbatim, fix, retry. + +**3b.7** Render `templates/PR-BODY-i18n.md` with `{{DOC_NAME}}`, `{{LANG_DISPLAY_NAME}}`, `{{LANG_CODE}}`, `{{TRANSLATED_PATH}}`, `{{ENGLISH_PATH}}`, `{{STATUS}}`, `{{TRANSLATION_NOTES}}` (one paragraph from the agent: anything tricky, untranslated terms it kept, etc.), `{{DISCORD_INVITE}}`. + +→ **Step 7**. + +--- + +### Step 3c — Docs / blog / typo + +**3c.1** Setup workspace (slug `docs`): + +```bash +bash "$SKILL_DIR/scripts/setup-workspace.sh" docs <slug> +``` + +**3c.2** Ask user (one `AskUserQuestion`): +1. **Auto-discover small fixes** (run discover-doc-gaps, pick something) +2. **I have a specific fix in mind** (free-text) +3. **I want to write a blog / case study** (free-text — what's the use case?) + +**3c.3 (Auto-discover branch)** Run: + +```bash +bash "$SKILL_DIR/scripts/discover-doc-gaps.sh" "$WORKDIR" > /tmp/od-doc-gaps.json +``` + +Group by `kind` (typo / deadlink / todo). Show the user up to 6 candidates via `AskUserQuestion`. Once picked, apply the fix in code (typo: replace word; deadlink: ask user for the new URL; todo: that's a proper task, ask user to write the missing prose). + +**3c.4 (Specific-fix branch)** Read the file, apply user's described change. Confirm via diff. + +**3c.5 (Blog branch)** First check whether OD has a blog directory: + +```bash +ls "$WORKDIR/docs" 2>/dev/null +``` + +If a `docs/blog/` or similar exists, place the new post there. If not, ask the user where it should live, defaulting to `docs/<slug>.md`. Generate an outline → user fills in user-specific bits (their use case, screenshots, the prompt they used, the rendered output) → agent stitches into a final Markdown. + +**3c.6** Validate every changed/added file. For files that already exist in the repo (typo fix, dead-link fix, doc edit), pass `--reference` pointing at HEAD's version so we only fail on relative refs the user *introduced*, not on pre-existing route slugs: + +```bash +# For modifications to existing files: +git -C "$WORKDIR" show "HEAD:<path>" > "/tmp/od-contrib-orig-<basename>" 2>/dev/null +bash "$SKILL_DIR/scripts/validate-markdown.sh" \ + "$WORKDIR/<changed-path>" \ + --reference "/tmp/od-contrib-orig-<basename>" + +# For brand-new files (e.g. a blog post the user is creating from scratch), +# omit --reference. The validator will skip the relative-ref check entirely +# (since it can't tell route slugs from real paths in isolation). +``` + +**3c.7** Render `templates/PR-BODY-docs.md` with `{{ONE_LINE_SUMMARY}}`, `{{DETAILS}}`, `{{FILES_LIST}}`, `{{DISCORD_INVITE}}`. + +→ **Step 7**. + +--- + +### Step 3d — Bug report (issue path, no PR) + +**3d.1** Read OD's actual schema at runtime to make sure we mirror it: + +```bash +gh api "repos/${TARGET_REPO}/contents/.github/ISSUE_TEMPLATE/bug-report.yml" --jq .content | base64 -d > /tmp/od-bug-report.yml +``` + +If the schema has drifted from the template (`templates/ISSUE-BODY-bug.md`), regenerate the body to match. + +**3d.2** Ask the user via `AskUserQuestion`, one structured prompt per critical field. Use **plain language**, not the YAML field names: + +| Bug-report field | Prompt to user | +|---|---| +| `description` | "What went wrong? One sentence is fine." | +| `steps` | "How can I reproduce it? Walk me through step by step." | +| `expected` | "What did you expect to happen?" | +| `version` | "Which OD version are you running? (About menu, or `od --version`)" | +| `platform` | dropdown: macOS (Apple Silicon) / macOS (Intel) / Windows / Linux / Other | +| `logs` | "Any error logs you can paste? Skip if you don't have them." | +| `screenshots` | "Path to a screenshot? Skip if you don't have one." | + +Translate every prompt above into the user's chat language at runtime. + +**3d.3** Auto-collect what we can (these don't need to ask the user): +- OS family from `uname` +- Node version from `node -v` if relevant + +**3d.4** Dedupe: extract 3–5 keywords from the description, run: + +```bash +gh search issues "<keywords>" --repo "$TARGET_REPO" --state open --limit 5 --json number,title,url +``` + +If matches exist, present them to the user via `AskUserQuestion` (translate to user's language): "These existing issues look related. Do you want to: (a) comment on an existing one, (b) open a new issue anyway, (c) cancel?" + +**3d.5** If proceeding with new issue, render `templates/ISSUE-BODY-bug.md` and submit: + +```bash +bash "$SKILL_DIR/scripts/create-issue.sh" \ + --title "$TITLE" \ + --body-file "$WORKDIR_OR_TMP/.od-contrib/ISSUE-BODY.md" \ + --dedupe-keywords "<keywords>" +``` + +**3d.6** Print the issue URL on its own line. **Do not** push branches or open PRs from this branch. + +--- + +## Step 7 — Preview + confirm (shared, PR branches only) + +Show the user a clean summary: + +```text +About to commit: + Branch: od-contrib/<type>/<slug>-<date> + Files: + + skills/foo/SKILL.md (1.2 KB) + + skills/foo/preview.png (54 KB) + Push to: <fork or upstream> + Open PR: nexu-io/open-design:main ← <fork>:<branch> +``` + +Then `git -C "$WORKDIR" diff --stat` and a `head -40` of the rendered PR body for visual sanity. + +Required `AskUserQuestion` confirmation (translate to user's language): **"Push this PR?"** with three options: +- **Ship it** — proceed to Step 8 +- **Let me revise** — return to the relevant Step 3 sub-step +- **Cancel** — leave the workspace on disk, tell the user the path so they can return later, exit + +Never push without an explicit "Ship it". + +## Step 8 — Push & open PR + +```bash +bash "$SKILL_DIR/scripts/create-pr.sh" \ + --workdir "$WORKDIR" \ + --type "<skill|design-system|i18n|docs>" \ + --title "<PR title from references/newcomer-tone.md>" \ + --body-file "$WORKDIR/.od-contrib/PR-BODY.md" +``` + +Print the PR URL on its own line. Done. + +--- + +## Safety rails (mandatory) + +- Never push to `main` / `master` / `develop`. The push scripts refuse. +- Never `--force` push. Just don't. +- All workspace activity stays under `$OD_WORK_ROOT` (default `$HOME/od-contrib-work`). `od::assert_in_workroot` enforces this. +- Bug-report path **always** runs the dedupe search before `gh issue create`. +- Honor user memory: skip GitHub user `xxiaoxiong` from any contributor lookup ([[feedback_no_outreach_xxiaoxiong]]). + +## When NOT to use this skill + +- The user wants to fix a daemon / web bug or add a feature with code changes → use `auto-github-contributor` instead (it has the TDD loop). This skill deliberately doesn't run lint/typecheck/tests because content paths don't need them. +- The user wants to *generate* a Skill / Design System from scratch → that's Open Design itself. Run OD first, get an artifact, then come back here to ship it. diff --git a/.claude/skills/od-contribute/agents/openai.yaml b/.claude/skills/od-contribute/agents/openai.yaml new file mode 100644 index 000000000..bc798ef49 --- /dev/null +++ b/.claude/skills/od-contribute/agents/openai.yaml @@ -0,0 +1,13 @@ +# Codex CLI sidecar (optional). Adds a friendlier picker entry when this skill +# is loaded by Codex from ~/.agents/skills/od-contribute/ or .agents/skills/. +# Not required — Codex loads SKILL.md regardless. + +interface: + display_name: "Open Design — Contribute" + short_description: "Ship a Skill / Design System / translation / typo fix to nexu-io/open-design without writing code." + default_prompt: "I want to contribute to Open Design." + +policy: + # Allow Codex to surface this skill when the user mentions OD contribution + # without an explicit `$od-contribute` invocation. Keep on — it's the whole point. + allow_implicit_invocation: true diff --git a/.claude/skills/od-contribute/install.sh b/.claude/skills/od-contribute/install.sh new file mode 100755 index 000000000..2efb1f1de --- /dev/null +++ b/.claude/skills/od-contribute/install.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# OD Contribute installer — self-bootstrapping. +# Fetches the latest od-contribute skill from nexu-io/open-design and installs +# it into every supported AI agent's home directory. +# +# Two ways to run this: +# +# 1) Tell your AI agent (Claude Code / Codex / Cursor / etc.) in the chat: +# +# curl -sSL https://raw.githubusercontent.com/nexu-io/open-design/main/.claude/skills/od-contribute/install.sh | bash +# +# The agent's Bash tool runs this. You never open a terminal yourself. +# +# 2) Or paste that same one-liner into a terminal directly, if you prefer. +# +# Targets installed: +# ~/.claude/skills/od-contribute/ Claude Code (native skill format) +# ~/.claude/commands/od-contribute.md Claude Code slash command +# ~/.agents/skills/od-contribute/ Codex CLI (canonical path) +# ~/.codex/skills/od-contribute/ Codex CLI (legacy, only if ~/.codex exists) +# +# Override the source branch with OD_CONTRIBUTE_BRANCH=feat/foo (default: main). + +set -euo pipefail + +REPO="nexu-io/open-design" +BRANCH="${OD_CONTRIBUTE_BRANCH:-main}" + +cyan() { printf '\033[36m%s\033[0m\n' "$*"; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } +gray() { printf '\033[90m%s\033[0m\n' "$*"; } +die() { printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; } + +cyan "Installing OD Contribute skill from ${REPO}@${BRANCH}..." + +command -v curl >/dev/null 2>&1 || die "curl is required." +command -v tar >/dev/null 2>&1 || die "tar is required." + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +# Tarball download — no `git clone` needed (works in env without git). +TARBALL="$TMPDIR/repo.tar.gz" +curl -fsSL "https://github.com/${REPO}/archive/refs/heads/${BRANCH}.tar.gz" -o "$TARBALL" \ + || die "failed to fetch ${REPO}@${BRANCH} (branch may not exist)" + +# Extract just the two paths we need. GitHub tarballs name the root dir +# <repo>-<branch>/, with slashes in branch names converted to dashes. +TARBALL_ROOT="open-design-${BRANCH//\//-}" +tar -xzf "$TARBALL" -C "$TMPDIR" \ + "${TARBALL_ROOT}/.claude/skills/od-contribute" \ + "${TARBALL_ROOT}/.claude/commands/od-contribute.md" \ + 2>/dev/null || die "skill files not found in tarball — branch may have different layout" + +SKILL_SRC="$TMPDIR/${TARBALL_ROOT}/.claude/skills/od-contribute" +CMD_SRC="$TMPDIR/${TARBALL_ROOT}/.claude/commands/od-contribute.md" + +[[ -f "$SKILL_SRC/SKILL.md" ]] || die "SKILL.md missing at expected path" +[[ -f "$CMD_SRC" ]] || die "slash command missing at expected path" + +install_skill_to() { + local dest="$1" label="$2" + + # Preserve user-local state across reinstall/upgrade. Re-running this script + # is the documented upgrade path ("re-run to pull the latest skill from + # main"), so anything the user wrote here that ISN'T part of the skill + # itself must survive `rm -rf`. Today that's just `.gh-token` (sandboxed + # agents like Codex.app / Cursor write a GitHub token here when they can't + # reach the macOS keychain — see check-prereqs.sh's hint and config.sh's + # fallback). Add new state filenames to PRESERVE if we ever introduce more. + local PRESERVE=(.gh-token) + local stash="" + local f + for f in "${PRESERVE[@]}"; do + if [[ -f "$dest/$f" ]]; then + [[ -z "$stash" ]] && stash="$(mktemp -d)" + cp -p "$dest/$f" "$stash/$f" + fi + done + + rm -rf "$dest" + mkdir -p "$dest" + cp -R "$SKILL_SRC/." "$dest/" + + # Restore preserved state. The mode preservation (`cp -p` above + this + # explicit chmod) keeps tokens at 600. + if [[ -n "$stash" ]]; then + for f in "${PRESERVE[@]}"; do + if [[ -f "$stash/$f" ]]; then + cp -p "$stash/$f" "$dest/$f" + chmod 600 "$dest/$f" 2>/dev/null || true + fi + done + rm -rf "$stash" + fi + + # Ensure scripts retain executable bit (tar usually preserves; defense in depth). + find "$dest" -name '*.sh' -exec chmod +x {} + 2>/dev/null || true + green " ✓ $label" + gray " $dest" +} + +# --- Claude Code (native, always install) ----------------------------------- +install_skill_to "$HOME/.claude/skills/od-contribute" "Claude Code skill" +mkdir -p "$HOME/.claude/commands" +cp "$CMD_SRC" "$HOME/.claude/commands/od-contribute.md" +green " ✓ Claude Code slash command (/od-contribute)" +gray " $HOME/.claude/commands/od-contribute.md" + +# --- Codex CLI (canonical) -------------------------------------------------- +install_skill_to "$HOME/.agents/skills/od-contribute" "Codex CLI skill (~/.agents/skills/)" + +# --- Codex CLI (legacy) — only if user already has Codex -------------------- +if [[ -d "$HOME/.codex" ]]; then + install_skill_to "$HOME/.codex/skills/od-contribute" "Codex CLI skill (legacy ~/.codex/skills/)" +fi + +echo +green "Done." +echo +cyan "How to use it:" +cat <<'EOF' + + In Claude Code: type /od-contribute in any chat. + In Codex CLI: type @od-contribute or pick "Open Design — Contribute" from /skills. + In other agents: ask the agent to follow ~/.claude/skills/od-contribute/SKILL.md + +The skill walks you through one of: + + * shipping a Skill or Design System you made with Open Design + * translating a doc to a new language + * fixing a typo or writing a use-case blog + * reporting a clean bug + +Need help? Open Design Discord: https://discord.gg/qhbcCH8Am4 +EOF diff --git a/.claude/skills/od-contribute/references/design-system-anatomy.md b/.claude/skills/od-contribute/references/design-system-anatomy.md new file mode 100644 index 000000000..353d69b6a --- /dev/null +++ b/.claude/skills/od-contribute/references/design-system-anatomy.md @@ -0,0 +1,51 @@ +# What an OD design-system folder looks like + +Reference for the `od-contribute` skill's `validate-design-system.sh` step. + +> **Authoritative source**: read 1–2 existing folders under `design-systems/` in `nexu-io/open-design` at runtime — the conventions evolve as new systems land. + +## Minimum viable design system + +``` +design-systems/<brand-slug>/ +└── DESIGN.md # required — the brand brief OD loads +``` + +A few systems include extras: `components.html`, `tokens.css`. These are optional, referenced from `DESIGN.md` if present. + +## DESIGN.md structure (observed convention) + +H1 with the brand name, then a blockquote with category + one-sentence pitch, then numbered H2 sections. Looking at established systems (`airbnb`, `apple`, etc.), the typical section list is: + +```markdown +# Design System Inspired by <Brand> + +> Category: <e.g. E-Commerce & Retail> +> <one-sentence pitch> + +## 1. Visual Theme & Atmosphere +## 2. Color Palette & Roles +## 3. Typography +## 4. Layout & Spacing +## 5. Components +## 6. Motion & Interaction +## 7. Iconography & Imagery +## 8. Voice & Tone +## 9. Edge Cases & Variations +``` + +Section ordering and exact titles vary — the validator only checks **structural overlap with reference systems**, not exact heading text. ≥30% overlap with the union of headings from existing systems is enough to pass. + +## What the validator actually enforces + +1. File is non-empty and has at least one H1. +2. ≥30% heading overlap with reference DESIGN.md files (when references are passed in). +3. No `../` relative paths that would resolve outside `design-systems/<brand>/`. + +That's deliberately loose — DESIGN.md is a creative brief, not a schema. + +## Don'ts + +- Don't reference assets outside the brand folder. +- Don't paste binary fonts; use a CSS `@font-face` reference and let OD resolve at runtime. +- Don't use real customer logos / proprietary brand assets you don't have rights to (the validator won't catch this — it's a maintainer-review concern). diff --git a/.claude/skills/od-contribute/references/newcomer-tone.md b/.claude/skills/od-contribute/references/newcomer-tone.md new file mode 100644 index 000000000..af12e73c3 --- /dev/null +++ b/.claude/skills/od-contribute/references/newcomer-tone.md @@ -0,0 +1,42 @@ +# Newcomer tone — voice rules for PR / issue text + +Per user feedback ([[feedback_outreach_minimal]]), keep it minimal. The PR body is the **only** place we get to shape the maintainer's first impression of this contributor — make it warm, brief, and useful. + +## Hard rules + +1. **Always end the PR body with two things:** + - "👋 This is my first OD contribution." (or a similar one-line warmth) + - The OD Discord invite: <https://discord.gg/qhbcCH8Am4> (read from `OD_DISCORD_INVITE` env, never hardcode) +2. **Never claim more than the PR actually does.** A typo fix is a typo fix — don't dress it up as "improving documentation quality" or list 5 fake checkboxes. +3. **Plain language only.** No "ergonomic", "DX", "stakeholder", "stack rank". Talk like a friendly user, not a startup blog. +4. **No emojis except the opening 👋 and one optional 🎨 / 🌍 / 📝 / 🐛 in the title or first line.** OD is design-loving but the maintainers read a *lot* of PRs. + +## Soft rules + +- Lead with **what changed**, not why or how. Maintainers can read the diff for the how. +- "Why" gets at most 2–3 sentences. If it needs more, the work is too big for this skill — open an issue instead. +- One screenshot if the change is visible. Zero is fine. +- The "checklist" should reflect what the validator actually checked, not a generic ceremonial list. + +## Anti-patterns (do not do these) + +- **Don't** write an "ask" section. Don't say "please review when you have time" — the PR is the ask. +- **Don't** invite the maintainer to call / DM you. Discord is the channel. +- **Don't** apologize. ("Sorry if this isn't right" — the maintainer will tell you if it isn't.) +- **Don't** include a "TL;DR" — if the summary needs a TL;DR, the summary is too long. + +## Title conventions (for `git commit` and `gh pr create --title`) + +| Type | Format | Example | +|---|---|---| +| Skill | `Add Skill: <name>` | `Add Skill: invoice-template` | +| Design System | `Add Design System: <brand>` | `Add Design System: notion` | +| i18n | `Translate <doc> to <Lang>` | `Translate QUICKSTART to Spanish` | +| i18n (refresh) | `Update <Lang> translation of <doc>` | `Update zh-CN translation of README` | +| Docs typo | `Fix typo in <file>` | `Fix typo in README.md` | +| Docs other | `<verb> <noun> in <where>` | `Clarify daemon setup in QUICKSTART` | +| Bug (issue title) | `<observed> on <surface>` | `Preview iframe is blank on Safari 17` | + +## When to ask before writing + +If the user wants to ship something whose tone is unusual (a manifesto blog post, a contentious refactor, naming a brand after a real company without rights), pause and ask the user. Better to skip the PR than ship something the maintainer will close politely. diff --git a/.claude/skills/od-contribute/references/od-repo-map.md b/.claude/skills/od-contribute/references/od-repo-map.md new file mode 100644 index 000000000..7fb98ea0a --- /dev/null +++ b/.claude/skills/od-contribute/references/od-repo-map.md @@ -0,0 +1,38 @@ +# OD repo map — what goes where + +Mirrors `nexu-io/open-design` `CONTRIBUTING.md` so the skill doesn't need to re-fetch it on every run. **If this drifts from upstream CONTRIBUTING.md, upstream wins** — re-read the live file when in doubt. + +## Three high-leverage contribution surfaces (per OD's CONTRIBUTING.md) + +| If you want to… | You're really adding | Where it lives | Ship size | +|---|---|---|---| +| Make OD render a new kind of artifact | a **Skill** | `skills/<your-skill>/` | one folder, ~2 files | +| Make OD speak a new brand's visual language | a **Design System** | `design-systems/<brand>/DESIGN.md` | one Markdown file | +| Hook up a new coding-agent CLI | an **Agent adapter** | `apps/daemon/src/agents.ts` | ~10 lines (code — out of scope for this skill) | +| Improve docs, port a section to fr / de / zh-CN, fix typos | docs | `README.md`, `README.fr.md`, `README.de.md`, `README.zh-CN.md`, `docs/`, `QUICKSTART.md` | one PR | + +## Localized doc files we know about + +| Doc family | English source | Translations seen on disk (as of plan time) | +|---|---|---| +| README | `README.md` | ar, de, es, fr, ja-JP, ko, pt-BR, ru, tr, uk, zh-CN, zh-TW | +| QUICKSTART | `QUICKSTART.md` | de, fr, ja-JP, pt-BR, zh-CN, zh-TW | +| CONTRIBUTING | `CONTRIBUTING.md` | de, fr, ja-JP, pt-BR, zh-CN | +| MAINTAINERS | `MAINTAINERS.md` | de, fr, ja-JP, pt-BR, zh-CN | + +The skill `discover-i18n-gaps.sh` does NOT trust this table — it scans the workspace at runtime. Use this list only when you need to seed an `AskUserQuestion` card without a workspace. + +## Issue templates + +- `bug-report.yml` — required fields: description, steps to reproduce, expected, version, platform. +- `feature-request.yml` — out of scope for this skill (feature requests should come from product, not auto-routed.) +- `preview-v0.8.0-feedback.yml` — branch-specific. + +## Out-of-scope surfaces (don't touch from this skill) + +- `apps/daemon/src/` — daemon code. Requires real review. +- `apps/web/src/` — web app code. Requires real review. +- `packages/`, `plugins/`, `tools/` — internal libs. +- `e2e/` — Playwright-driven; non-trivial to author. + +If a user asks to contribute to those surfaces, suggest the original `auto-github-contributor` skill (TDD pipeline) instead. diff --git a/.claude/skills/od-contribute/references/skill-anatomy.md b/.claude/skills/od-contribute/references/skill-anatomy.md new file mode 100644 index 000000000..85184ab19 --- /dev/null +++ b/.claude/skills/od-contribute/references/skill-anatomy.md @@ -0,0 +1,53 @@ +# What an OD skill folder looks like + +Reference for the `od-contribute` skill's `validate-skill-submission.sh` step and for guiding a user through assembling a Skill submission. + +> **Authoritative source**: read 1–2 existing folders under `skills/` in `nexu-io/open-design` at runtime — conventions evolve faster than this doc. + +## Minimum viable skill + +``` +skills/<your-skill>/ +└── SKILL.md # required, must have YAML frontmatter +``` + +That's it. Many of the simplest skills in OD are exactly that: one Markdown file in one folder. + +## Frontmatter — what `validate-skill-submission.sh` requires + +```yaml +--- +name: <kebab-case-slug> # required; usually matches the folder name +description: | # required; one paragraph; what the skill does in user terms + Generate and iterate ad creative including headlines, descriptions, and primary text. +triggers: # optional but strongly recommended + - "ad creative" + - "ad headline" +od: # optional; OD-specific metadata + mode: design-system # or other modes; check existing skills + category: <category-slug> + upstream: "https://github.com/..." # if the skill was lifted from somewhere +--- +``` + +**Required by validator**: `name`, `description`. Everything else is convention. + +## Body conventions (after the frontmatter) + +Looking at existing skills, the typical body has: + +1. `# <skill-name>` H1. +2. A one-line "what it does" sentence. +3. Optional `## Source` block when adapted from upstream (with attribution). +4. `## How to use` with one or two example prompts the user might type. + +## When a skill folder needs more than `SKILL.md` + +- **Reference assets** — long prompt fragments, example outputs, image references — go alongside `SKILL.md` in the same folder, referenced via relative paths in `SKILL.md`. +- **Subfolders** are fine: the validator only requires that every relative reference inside `SKILL.md` resolves and that no path escapes the skill folder. + +## Don'ts + +- Don't put runtime code in here. Skills are *content* — Markdown + maybe assets. Code adapters live in `apps/daemon/src/`. +- Don't reference files outside `skills/<your-skill>/` — that breaks portability. +- Don't put binaries you don't need (the lighter the folder, the easier the review). diff --git a/.claude/skills/od-contribute/scripts/check-prereqs.sh b/.claude/skills/od-contribute/scripts/check-prereqs.sh new file mode 100755 index 000000000..83a108b8b --- /dev/null +++ b/.claude/skills/od-contribute/scripts/check-prereqs.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Verify required tools + gh auth before the skill starts. +# Exit 0 = ready (prints GH_USER=... and READY=1 to stdout) +# Exit 2 = missing prereq, hint printed to stderr; skill should surface it verbatim. + +set -uo pipefail + +# shellcheck disable=SC1091 +source "$(dirname "$0")/config.sh" + +# config.sh runs with `set -e` for its own callers, but this script wants the +# OPPOSITE behavior: continue checking all prereqs even when one fails so we +# can surface the full diagnostic in one shot rather than aborting at the +# first miss. Restore -uo pipefail without -e after sourcing. +set +e +set -uo pipefail + +# Skill root, used in the auth-failure hint below to tell the user where to +# drop a .gh-token file if they're stuck in a sandboxed agent. +_OD_SKILL_DIR_HINT="$(cd "$(dirname "$0")/.." && pwd)" + +STATUS=0 +MISSING=() +HINTS=() + +check_bin() { + local bin="$1" install_hint="$2" + if command -v "$bin" >/dev/null 2>&1; then + printf ' ✓ %s\n' "$bin" >&2 + else + printf ' ✗ %s (not installed)\n' "$bin" >&2 + MISSING+=("$bin") + HINTS+=("$install_hint") + STATUS=2 + fi +} + +printf '[od-contrib] checking prerequisites...\n' >&2 + +OS="$(uname -s)" +case "$OS" in + Darwin) GH_HINT="brew install gh" ;; + Linux) GH_HINT="see https://github.com/cli/cli#installation (e.g. 'sudo apt install gh' or 'brew install gh')" ;; + *) GH_HINT="see https://github.com/cli/cli#installation" ;; +esac + +check_bin gh "$GH_HINT" +check_bin git "install git for your OS" +check_bin jq "$( [[ $OS == Darwin ]] && echo 'brew install jq' || echo 'sudo apt install jq (or brew install jq)' )" + +if ((${#MISSING[@]} > 0)); then + printf '\n[od-contrib][error] missing required tools: %s\n' "${MISSING[*]}" >&2 + printf '\nInstall hints:\n' >&2 + for i in "${!MISSING[@]}"; do + printf ' - %s: %s\n' "${MISSING[$i]}" "${HINTS[$i]}" >&2 + done + exit 2 +fi + +# Two acceptable auth paths: +# 1. `gh auth status` succeeds (gh has a token in keychain or hosts.yml) +# 2. GH_TOKEN env var is set (config.sh loaded it from .gh-token, or caller exported it) +# Path 2 matters for sandboxed runtimes (Codex.app, Cursor, etc.) where gh +# CAN'T reach macOS keychain due to App Sandbox restrictions. +if [[ -n "${GH_TOKEN:-}" ]]; then + # Verify the token actually works against the API. + if ! gh api user --jq .login >/dev/null 2>&1; then + printf '[od-contrib][error] GH_TOKEN is set but gh api call failed (token expired?).\n' >&2 + printf '[od-contrib][error] Refresh the token: from a terminal run gh auth refresh or replace the .gh-token file.\n' >&2 + exit 2 + fi +elif ! gh auth status >/dev/null 2>&1; then + cat >&2 <<EOF + +[od-contrib][error] No GitHub credentials available. + +Two ways to fix this: + + Option A (one-time, works for any agent): + From a regular terminal, run: + gh auth login + Pick GitHub.com → HTTPS → browser login. Need 'repo' scope. + + Option B (for sandboxed agents like Codex.app / Cursor that can't reach + the macOS keychain): + From a regular terminal where gh IS authenticated, run: + gh auth token > "$_OD_SKILL_DIR_HINT/.gh-token" + chmod 600 "$_OD_SKILL_DIR_HINT/.gh-token" + The skill will pick up the token automatically next run. +EOF + exit 2 +fi + +# Resolve the authenticated login. Fail closed if this can't be done — even +# with `gh auth status` green, `gh api user` can fail when the token has +# insufficient scopes, has been revoked, or GitHub is unreachable. Returning +# a fabricated GH_USER like `?` would propagate to TARGET_FORK and cause +# downstream pushes to point at `?/open-design`, so we'd rather stop here. +GH_USER="$(gh api user --jq .login 2>/dev/null)" +if [[ -z "$GH_USER" ]]; then + cat >&2 <<'EOF' + +[od-contrib][error] gh auth check passed but `gh api user` could not resolve a login. + +Common causes: + - The token has insufficient scopes (need at least 'repo') + - The token has been revoked or expired since the session started + - GitHub API is unreachable + +Refresh the token with the right scopes and retry: + + gh auth refresh -s repo +EOF + exit 2 +fi + +printf ' ✓ gh authed as %s\n' "$GH_USER" >&2 +printf ' ✓ target locked to %s\n' "$OD_TARGET_REPO" >&2 + +printf 'GH_USER=%s\n' "$GH_USER" +printf 'READY=1\n' diff --git a/.claude/skills/od-contribute/scripts/config.sh b/.claude/skills/od-contribute/scripts/config.sh new file mode 100755 index 000000000..d884bc932 --- /dev/null +++ b/.claude/skills/od-contribute/scripts/config.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Shared config for the od-contribute skill. +# TARGET_REPO is hard-locked to nexu-io/open-design — this skill is OD-specific. +# +# Override via env vars before invoking a script: +# TARGET_FORK "<owner>/<name>" push branches here. Defaults to $GH_USER/open-design at runtime. +# OD_BASE_BRANCH default: main +# OD_WORK_ROOT default: $HOME/od-contrib-work +# OD_DISCORD_INVITE default: https://discord.gg/qhbcCH8Am4 + +set -euo pipefail + +readonly OD_TARGET_REPO="nexu-io/open-design" +TARGET_REPO="$OD_TARGET_REPO" + +: "${TARGET_FORK:=}" +: "${OD_BASE_BRANCH:=main}" +: "${OD_WORK_ROOT:="$HOME/od-contrib-work"}" +: "${OD_DISCORD_INVITE:=https://discord.gg/qhbcCH8Am4}" + +# Sandboxed-agent fallback for gh auth. +# Codex.app, Cursor, and other macOS App Sandbox runtimes can't reach the +# system keychain where `gh auth login` stores the token by default. If +# GH_TOKEN isn't already set in the env, look for a token file shipped +# alongside the skill. The skill never *creates* this file automatically — +# it must be written by either: +# - a one-time `gh auth token > <skill>/.gh-token` from a non-sandboxed shell, or +# - the OAuth Device Flow bootstrap (TODO: implement for non-coder users). +_OD_SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if [[ -z "${GH_TOKEN:-}" && -f "$_OD_SKILL_DIR/.gh-token" ]]; then + GH_TOKEN="$(tr -d '[:space:]' < "$_OD_SKILL_DIR/.gh-token")" + export GH_TOKEN +fi +unset _OD_SKILL_DIR + +export TARGET_REPO TARGET_FORK OD_BASE_BRANCH OD_WORK_ROOT OD_DISCORD_INVITE + +od::log() { printf '[od-contrib] %s\n' "$*" >&2; } +od::warn() { printf '[od-contrib][warn] %s\n' "$*" >&2; } +od::err() { printf '[od-contrib][error] %s\n' "$*" >&2; } +od::die() { od::err "$*"; exit 1; } + +od::require() { + command -v "$1" >/dev/null 2>&1 || od::die "missing dependency: $1" +} + +od::slugify() { + local s="${1:-}" + s="$(printf '%s' "$s" | tr '[:upper:]' '[:lower:]')" + s="$(printf '%s' "$s" | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" + printf '%s' "${s:0:48}" +} + +od::workdir_for() { + # $1 = a slug for this contribution session (e.g. "skill-foo-2026-05-28") + printf '%s/%s\n' "$OD_WORK_ROOT" "$1" +} + +# Refuse to operate outside $OD_WORK_ROOT (defense against runaway scripts). +od::assert_in_workroot() { + local path="$1" + case "$path" in + "$OD_WORK_ROOT"/*) return 0 ;; + *) od::die "refusing to operate on path outside OD_WORK_ROOT: $path" ;; + esac +} diff --git a/.claude/skills/od-contribute/scripts/create-issue.sh b/.claude/skills/od-contribute/scripts/create-issue.sh new file mode 100755 index 000000000..a0cc13d2b --- /dev/null +++ b/.claude/skills/od-contribute/scripts/create-issue.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Create a bug-report issue on nexu-io/open-design from a rendered body file. +# Usage: +# create-issue.sh --title "<issue title>" --body-file <rendered .md> +# [--allow-duplicates] [--dedupe-keywords "<kw>"] +# +# Dedupe gate (now actually a gate, not a print): +# - If --dedupe-keywords is supplied, the script runs `gh search issues` +# FIRST and writes the matches to stderr. +# - If any matches are found AND --allow-duplicates was NOT passed, the +# script EXITS NON-ZERO with a clear hint and refuses to call +# `gh issue create`. This lets the agent (per SKILL.md Step 3d.4) show +# the matches to the user and only re-invoke with --allow-duplicates +# after the user explicitly chose "open a new issue anyway". +# - If `gh search` ITSELF fails (network, rate limit, jq parse error), +# the script also exits non-zero. Failing closed is the right default +# for a bug-dedupe gate — we'd rather block creation than open +# potentially redundant issues silently. +# +# Caller contract (matches SKILL.md): +# 1. Run with --dedupe-keywords on first attempt; show output to user. +# 2. If exit is non-zero with REASON=duplicates_found, ask the user. +# 3. If user picks "open anyway", re-run WITHOUT --dedupe-keywords (or +# WITH --allow-duplicates). The script then creates the issue. +# +# Emits the issue URL on its own line (stdout) on success. + +set -euo pipefail +source "$(dirname "$0")/config.sh" + +TITLE="" +BODY_FILE="" +DEDUPE_KEYWORDS="" +ALLOW_DUPES=0 + +while (($#)); do + case "$1" in + --title) TITLE="$2"; shift 2 ;; + --body-file) BODY_FILE="$2"; shift 2 ;; + --dedupe-keywords) DEDUPE_KEYWORDS="$2"; shift 2 ;; + --allow-duplicates) ALLOW_DUPES=1; shift ;; + *) od::die "unknown flag: $1" ;; + esac +done + +[[ -n "$TITLE" ]] || od::die "--title required" +[[ -f "$BODY_FILE" ]] || od::die "--body-file does not exist: $BODY_FILE" + +od::require gh +od::require jq + +if [[ -n "$DEDUPE_KEYWORDS" && "$ALLOW_DUPES" -eq 0 ]]; then + od::log "checking for duplicates: $DEDUPE_KEYWORDS" + + # Run gh search and jq as separate steps so a failure in either is loud + # rather than swallowed by `|| true`. The previous implementation chained + # them with `|| true`, which let a network or jq error mask "no duplicates" + # vs "search broken" — both produced empty output and the script then + # created the issue regardless. + if ! SEARCH_JSON="$(gh search issues "$DEDUPE_KEYWORDS" \ + --repo "$TARGET_REPO" \ + --state open \ + --limit 5 \ + --json number,title,url 2>&1)"; then + od::err "gh search failed: $SEARCH_JSON" + printf 'REASON=search_failed\n' >&2 + exit 2 + fi + + MATCH_COUNT="$(printf '%s' "$SEARCH_JSON" | jq -r 'length' 2>/dev/null || echo 'parse-error')" + if [[ "$MATCH_COUNT" == "parse-error" ]]; then + od::err "could not parse gh search output as JSON" + printf 'REASON=parse_failed\n' >&2 + exit 2 + fi + + if (( MATCH_COUNT > 0 )); then + printf '%s' "$SEARCH_JSON" \ + | jq -r '.[] | " #\(.number) \(.title)\n \(.url)"' >&2 + od::err "${MATCH_COUNT} potentially duplicate open issue(s) found." + od::err "Refusing to create a new issue. Show these to the user and ask:" + od::err " (a) comment on an existing one — open the URL above" + od::err " (b) open a new issue anyway — re-run with --allow-duplicates" + od::err " (c) cancel — do nothing" + printf 'REASON=duplicates_found\n' >&2 + printf 'MATCH_COUNT=%s\n' "$MATCH_COUNT" >&2 + exit 3 + fi + + od::log "no duplicates found — proceeding with create" +fi + +URL="$(gh issue create \ + --repo "$TARGET_REPO" \ + --title "$TITLE" \ + --body-file "$BODY_FILE" \ + --label bug)" || od::die "gh issue create failed" + +printf '\n' +printf '%s\n' "$URL" diff --git a/.claude/skills/od-contribute/scripts/create-pr.sh b/.claude/skills/od-contribute/scripts/create-pr.sh new file mode 100755 index 000000000..d335fecdf --- /dev/null +++ b/.claude/skills/od-contribute/scripts/create-pr.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Commit, push, and open a PR against nexu-io/open-design. +# Usage: create-pr.sh --workdir <dir> --type <skill|design-system|i18n|docs> \ +# --title "<pr title>" --body-file <rendered PR body .md> +# +# Reads: +# <workdir>/.od-contrib/contributor.txt (display name; optional) +# <workdir>/.od-contrib/pitch.txt (one-line pitch; optional) +# Emits PR URL on its own line at the end (stdout). + +set -euo pipefail +source "$(dirname "$0")/config.sh" + +WORKDIR="" +TYPE="" +TITLE="" +BODY_FILE="" +DRAFT="" + +while (($#)); do + case "$1" in + --workdir) WORKDIR="$2"; shift 2 ;; + --type) TYPE="$2"; shift 2 ;; + --title) TITLE="$2"; shift 2 ;; + --body-file) BODY_FILE="$2"; shift 2 ;; + --draft) DRAFT="--draft"; shift ;; + *) od::die "unknown flag: $1" ;; + esac +done + +[[ -n "$WORKDIR" ]] || od::die "--workdir required" +[[ -n "$TYPE" ]] || od::die "--type required (skill|design-system|i18n|docs)" +[[ -n "$TITLE" ]] || od::die "--title required" +[[ -f "$BODY_FILE" ]] || od::die "--body-file does not exist: $BODY_FILE" +[[ -d "$WORKDIR/.git" ]] || od::die "not a git workdir: $WORKDIR" + +od::require gh +od::require git + +cd "$WORKDIR" +BRANCH="$(git rev-parse --abbrev-ref HEAD)" + +case "$BRANCH" in + main|master|develop) od::die "refusing to push base branch '$BRANCH'" ;; +esac + +# 1) Stage + commit if there are changes. Use a non-jargon commit message. +# +# Use `git status --porcelain` rather than `git diff --quiet` because the latter +# ignores untracked files. The most common contribution shape — a brand-new +# Skill folder, translation file, or doc — is 100% untracked at this point; +# any predicate that misses untracked paths would silently push an empty PR. +# +# Belt-and-suspenders against the skill's internal scratch dir leaking into +# the user's contribution PR: setup-workspace.sh adds `.od-contrib/` to +# .git/info/exclude, but in case this script is invoked against a workdir +# set up differently, also pass `:!.od-contrib` as a pathspec exclude so +# nothing under .od-contrib/ gets staged here. +SCRATCH_EXCLUDE=':!:.od-contrib' +if [[ -n "$(git status --porcelain -- . "$SCRATCH_EXCLUDE")" ]]; then + git add -A -- . "$SCRATCH_EXCLUDE" + # If even after `git add` the index is clean (e.g., changes were only in + # ignored paths or symlink mode bits), skip the commit instead of erroring. + if git diff --cached --quiet; then + od::log "no real changes after staging — skipping commit" + else + git commit -m "$TITLE" + od::log "created commit" + fi +else + od::log "nothing new to commit (assuming work was already committed)" +fi + +# 2) Decide push remote. Prefer fork. +PUSH_REMOTE="origin" +if [[ -n "${TARGET_FORK}" ]] && git remote | grep -q '^fork$'; then + PUSH_REMOTE="fork" +else + od::warn "no fork configured (TARGET_FORK empty) — pushing to upstream ${TARGET_REPO}. 3s to abort..." + sleep 3 || true +fi + +od::log "pushing to ${PUSH_REMOTE}/${BRANCH}" +git push -u "$PUSH_REMOTE" "$BRANCH" + +# 3) Pick label set per contribution type. (OD's labels: documentation, i18n, blog, enhancement, ...) +LABELS=() +case "$TYPE" in + skill) LABELS+=("good first issue" "enhancement") ;; + design-system) LABELS+=("good first issue" "enhancement") ;; + i18n) LABELS+=("i18n" "documentation") ;; + docs) LABELS+=("documentation") ;; +esac + +LABEL_FLAGS=() +for L in "${LABELS[@]}"; do + LABEL_FLAGS+=(--label "$L") +done + +# 4) Open the PR. `gh pr create` automatically picks `head` from the pushed branch. +HEAD_REF="$BRANCH" +if [[ "$PUSH_REMOTE" == "fork" && -n "$TARGET_FORK" ]]; then + HEAD_REF="${TARGET_FORK%%/*}:${BRANCH}" +fi + +PR_URL="$(gh pr create \ + --repo "$TARGET_REPO" \ + --base "$OD_BASE_BRANCH" \ + --head "$HEAD_REF" \ + --title "$TITLE" \ + --body-file "$BODY_FILE" \ + ${DRAFT} \ + "${LABEL_FLAGS[@]}")" || od::die "gh pr create failed" + +printf '\n' +printf '%s\n' "$PR_URL" diff --git a/.claude/skills/od-contribute/scripts/discover-doc-gaps.sh b/.claude/skills/od-contribute/scripts/discover-doc-gaps.sh new file mode 100755 index 000000000..c13590bfa --- /dev/null +++ b/.claude/skills/od-contribute/scripts/discover-doc-gaps.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Find low-effort doc improvements in nexu-io/open-design. +# Usage: discover-doc-gaps.sh <workdir> +# Stdout: NDJSON rows. Three classes: +# {"kind":"todo","file":"docs/foo.md","line":42,"text":"TODO: explain the daemon"} +# {"kind":"typo","file":"README.md","line":17,"word":"recieve","suggested":"receive"} +# {"kind":"deadlink","file":"docs/bar.md","line":3,"url":"https://example.com/x","status":"404"} +# +# Dead-link checks are best-effort: timeout 8s, only reports 4xx/5xx/timeout, not network errors. + +set -uo pipefail +source "$(dirname "$0")/config.sh" + +WORKDIR="${1:?workdir required}" +[[ -d "$WORKDIR/.git" ]] || od::die "not a git workdir: $WORKDIR" + +cd "$WORKDIR" +od::require jq + +# Use ripgrep when present for speed; fall back to grep -rE. +if command -v rg >/dev/null 2>&1; then + GREP() { rg --no-heading --line-number --color never "$@"; } +else + GREP() { + # Translate a couple of rg flags we use to grep-equivalents. + local args=() + while (($#)); do + case "$1" in + --no-heading|--color) shift ;; + --color=never) shift ;; + --line-number) args+=("-n"); shift ;; + *) args+=("$1"); shift ;; + esac + done + grep -rE "${args[@]}" + } +fi + +# 1) TODOs / FIXMEs in docs. +emit_todo() { + while IFS=: read -r file line rest; do + [[ -z "$file" ]] && continue + jq -nc --arg file "$file" --argjson line "$line" --arg text "$rest" \ + '{kind:"todo", file:$file, line:$line, text:($text|sub("^[[:space:]]+";""))}' + done +} + +GREP --no-heading --line-number --color never -e 'TODO|FIXME|XXX' \ + -g '*.md' docs/ README*.md QUICKSTART*.md CONTRIBUTING*.md 2>/dev/null \ + | emit_todo || true + +# Fallback path for environments where rg --glob isn't available — grep equivalent. +if ! command -v rg >/dev/null 2>&1; then + grep -rEn -- 'TODO|FIXME|XXX' docs README*.md QUICKSTART*.md CONTRIBUTING*.md 2>/dev/null \ + | emit_todo || true +fi + +# 2) Common typos. Whole-word match, case-sensitive (avoid false positives in code/links). +TYPOS=( + "teh|the" + "recieve|receive" + "seperate|separate" + "occured|occurred" + "succesful|successful" + "untill|until" + "wich|which" + "thier|their" + "alot|a lot" + "definately|definitely" + "neccessary|necessary" + "enviroment|environment" + "transparant|transparent" + "appearence|appearance" +) + +for entry in "${TYPOS[@]}"; do + bad="${entry%%|*}" + good="${entry##*|}" + while IFS=: read -r file line _rest; do + [[ -z "$file" ]] && continue + # Skip code blocks (rough heuristic: skip if line is inside ```). + jq -nc --arg file "$file" --argjson line "$line" --arg word "$bad" --arg good "$good" \ + '{kind:"typo", file:$file, line:$line, word:$word, suggested:$good}' + done < <(GREP --no-heading --line-number --color never -e "\\b${bad}\\b" -g '*.md' . 2>/dev/null \ + || grep -rEn "\\b${bad}\\b" --include='*.md' . 2>/dev/null \ + || true) +done + +# 3) External link health (best-effort, capped). +# Cap to 50 links per run so we don't hammer arbitrary hosts. +MAX_LINKS=50 +SEEN=0 +extract_links() { + GREP --no-heading --line-number --color never -e '\]\(https?://[^) ]+\)' -g '*.md' . 2>/dev/null \ + || grep -rEn '\]\(https?://[^) ]+\)' --include='*.md' . 2>/dev/null +} + +while IFS= read -r row; do + [[ "$SEEN" -ge "$MAX_LINKS" ]] && break + file="${row%%:*}" + rest="${row#*:}" + line="${rest%%:*}" + text="${rest#*:}" + # Extract first http(s) URL on the line. + url="$(printf '%s' "$text" | grep -oE 'https?://[^) ]+' | head -1)" + [[ -z "$url" ]] && continue + SEEN=$((SEEN+1)) + # HEAD with 8s timeout, follow redirects, take final status. + status="$(curl -sS -o /dev/null -m 8 -L -w '%{http_code}' --head "$url" 2>/dev/null || echo "000")" + case "$status" in + 2*|3*) ;; # OK + 000) ;; # network/timeout — skip rather than spam false positives + *) + jq -nc --arg file "$file" --argjson line "$line" --arg url "$url" --arg status "$status" \ + '{kind:"deadlink", file:$file, line:$line, url:$url, status:$status}' + ;; + esac +done < <(extract_links | head -n "$MAX_LINKS") diff --git a/.claude/skills/od-contribute/scripts/discover-i18n-gaps.sh b/.claude/skills/od-contribute/scripts/discover-i18n-gaps.sh new file mode 100755 index 000000000..d56b09fc2 --- /dev/null +++ b/.claude/skills/od-contribute/scripts/discover-i18n-gaps.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Find translation gaps in nexu-io/open-design. +# Usage: discover-i18n-gaps.sh <workdir> +# Stdout: NDJSON, one row per gap: +# {"doc":"README","english":"README.md","lang":"es","translated":null,"status":"missing"} +# {"doc":"QUICKSTART","english":"QUICKSTART.md","lang":"zh-CN","translated":"QUICKSTART.zh-CN.md","status":"stale","english_mtime":"...","translated_mtime":"...","english_commits_since":12} +# +# A "stale" translation is one whose last-touched commit is older than the most recent +# commit touching the English source. Ranking is left to the caller (the agent). + +set -euo pipefail +source "$(dirname "$0")/config.sh" + +WORKDIR="${1:?workdir required}" +[[ -d "$WORKDIR/.git" ]] || od::die "not a git workdir: $WORKDIR" + +cd "$WORKDIR" +od::require git +od::require jq + +# Translatable English source files we care about (top-level docs). +ENGLISH_DOCS=(README.md QUICKSTART.md CONTRIBUTING.md MAINTAINERS.md TRANSLATIONS.md PRIVACY.md) + +# Common language suffixes seen in OD's tree (extend as the project grows). +LANGS=(zh-CN zh-TW ja-JP de fr es ko ru pt-BR tr uk ar) + +# Languages already represented for a given doc are detected from disk; +# the LANGS array is what we *offer* to a contributor when no translation exists. + +last_commit_epoch() { + # Last commit touching $1 — empty string if file has never been committed. + git log -1 --format=%ct -- "$1" 2>/dev/null || true +} + +commits_between() { + # How many commits touched $newer that are NOT ancestors of $older_ref's tip + # commit. Uses commit ancestry rather than `--since=<epoch>` math because + # `--since` is inclusive of the boundary epoch — so when English source and + # translation are touched in the SAME commit (very common: bulk i18n + # refresh, structural change applied across all translations), `--since` + # would count that shared commit and mark the translation "stale" by 1. + # + # `tr_sha..HEAD -- $newer` reads as: "commits reachable from HEAD but not + # from tr_sha, that touched $newer". When tr_sha is HEAD's tip for $newer + # too (same-commit update), the answer is correctly 0. + local newer="$1" older_ref="$2" + local tr_sha + tr_sha="$(git log -1 --format=%H -- "$older_ref" 2>/dev/null)" + if [[ -z "$tr_sha" ]]; then + # Translation never committed; count all history of $newer. + git log --format=%H -- "$newer" 2>/dev/null | wc -l | tr -d ' ' + else + git rev-list "${tr_sha}..HEAD" -- "$newer" 2>/dev/null | wc -l | tr -d ' ' + fi +} + +emit() { + jq -nc \ + --arg doc "$1" --arg english "$2" --arg lang "$3" \ + --arg translated "$4" --arg status "$5" \ + --arg en_epoch "$6" --arg tr_epoch "$7" --arg en_commits_since "$8" \ + '{ + doc: $doc, english: $english, lang: $lang, + translated: ($translated | select(length>0)), + status: $status, + english_mtime_epoch: ($en_epoch | select(length>0) | tonumber? // null), + translated_mtime_epoch: ($tr_epoch | select(length>0) | tonumber? // null), + english_commits_since_translation: ($en_commits_since | tonumber? // null) + }' +} + +for english in "${ENGLISH_DOCS[@]}"; do + [[ -f "$english" ]] || continue + doc="${english%.md}" + en_epoch="$(last_commit_epoch "$english")" + + # Track observed languages for this doc as a newline-delimited string. + # Avoids `declare -A` (associative arrays), which requires Bash 4 — macOS + # ships with Bash 3.2 by default and most agent-spawned bash subprocesses + # inherit that. The leading + trailing newlines let us match `\n<lang>\n` + # without false positives on prefix overlap (e.g. zh vs zh-CN). + SEEN_LANGS=$'\n' + + while IFS= read -r -d '' translated; do + # Filename pattern: <DOC>.<lang>.md (e.g. README.zh-CN.md). + # `find . ... -print0` emits paths with a leading `./`; strip that first + # and operate on the basename so the prefix-strip below works regardless. + base="${translated#./}" + base="$(basename "$base")" + lang_part="${base#${doc}.}" + lang_part="${lang_part%.md}" + [[ -z "$lang_part" || "$lang_part" == "$base" ]] && continue + SEEN_LANGS+="${lang_part}"$'\n' + + tr_epoch="$(last_commit_epoch "$translated")" + if [[ -z "$tr_epoch" ]]; then + emit "$doc" "$english" "$lang_part" "$translated" "untracked" "$en_epoch" "" "" + continue + fi + en_commits_since="$(commits_between "$english" "$translated")" + if [[ "$en_commits_since" -gt 0 ]]; then + emit "$doc" "$english" "$lang_part" "$translated" "stale" "$en_epoch" "$tr_epoch" "$en_commits_since" + fi + # else: up-to-date, skip emission entirely. + done < <(find . -maxdepth 1 -type f -name "${doc}.*.md" -print0) + + # Then, for each language in LANGS that we didn't see, emit a "missing" row. + for lang in "${LANGS[@]}"; do + case "$SEEN_LANGS" in + *$'\n'"$lang"$'\n'*) continue ;; + esac + emit "$doc" "$english" "$lang" "" "missing" "$en_epoch" "" "" + done +done diff --git a/.claude/skills/od-contribute/scripts/setup-workspace.sh b/.claude/skills/od-contribute/scripts/setup-workspace.sh new file mode 100755 index 000000000..7ce9d1750 --- /dev/null +++ b/.claude/skills/od-contribute/scripts/setup-workspace.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Clone (or reuse) nexu-io/open-design in an isolated workdir + create a feature branch. +# Usage: setup-workspace.sh <type> <slug> +# <type> one of: skill | design-system | i18n | docs +# <slug> short kebab-case identifier (e.g. "translate-readme-es", "fix-typo-quickstart") +# +# Env: TARGET_FORK optional (else pushes go to upstream — create-pr.sh warns first). +# +# Stdout (machine-readable): +# WORKDIR=<abs path> +# BRANCH=<branch name> + +set -euo pipefail +source "$(dirname "$0")/config.sh" + +TYPE="${1:?type required (skill|design-system|i18n|docs)}" +SLUG="${2:?slug required}" + +case "$TYPE" in + skill|design-system|i18n|docs) ;; + *) od::die "unknown type: $TYPE (expected skill|design-system|i18n|docs)" ;; +esac + +od::require gh +od::require git + +# Use second-precision timestamp so two contribution sessions on the same day +# (or the SKILL.md i18n flow that calls setup-workspace.sh with a placeholder +# slug like "translate" before the user has picked a language) don't collide +# into the same workdir. Reusing a workdir would leak untracked / half-edited +# files from an earlier abandoned session into a later contribution. +SESSION_TAG="$(date +%Y%m%d-%H%M%S)" +SESSION_DIR="${TYPE}-${SLUG}-${SESSION_TAG}" +WORKDIR="$(od::workdir_for "$SESSION_DIR")" +BRANCH="od-contrib/${TYPE}/${SLUG}-${SESSION_TAG}" + +mkdir -p "$OD_WORK_ROOT" +od::assert_in_workroot "$WORKDIR" + +CLONE_URL="https://github.com/${TARGET_REPO}.git" + +if [[ -d "$WORKDIR/.git" ]]; then + # We reach here only if the user explicitly resumed by passing the same + # SESSION_TAG, or if the wall clock somehow produced a duplicate. Clean any + # untracked/dirty state so the run starts from a known good base instead of + # inheriting whatever the previous occupant left behind. + od::log "reusing existing workdir: $WORKDIR" + git -C "$WORKDIR" fetch origin --prune + git -C "$WORKDIR" reset --hard HEAD + git -C "$WORKDIR" clean -fdx +else + od::log "cloning $CLONE_URL → $WORKDIR (depth 50)" + git clone --depth 50 "$CLONE_URL" "$WORKDIR" +fi + +# Tell git to ignore our internal scratch dir so `git add -A` later (in +# create-pr.sh) doesn't accidentally stage type.txt, slug.txt, PR-BODY.md +# into the user's contribution PR. .git/info/exclude is repo-local and not +# committed, so we don't pollute the OD repo's .gitignore. +mkdir -p "$WORKDIR/.git/info" +if ! grep -qxF '.od-contrib/' "$WORKDIR/.git/info/exclude" 2>/dev/null; then + printf '\n# od-contribute scratch dir (added by setup-workspace.sh)\n.od-contrib/\n' \ + >> "$WORKDIR/.git/info/exclude" +fi + +git -C "$WORKDIR" checkout "$OD_BASE_BRANCH" +git -C "$WORKDIR" pull --ff-only origin "$OD_BASE_BRANCH" + +# Configure fork remote if provided. +if [[ -n "${TARGET_FORK}" ]]; then + if git -C "$WORKDIR" remote | grep -q '^fork$'; then + git -C "$WORKDIR" remote set-url fork "https://github.com/${TARGET_FORK}.git" + else + git -C "$WORKDIR" remote add fork "https://github.com/${TARGET_FORK}.git" + fi +fi + +# Create or reset branch off latest base. +if git -C "$WORKDIR" show-ref --verify --quiet "refs/heads/$BRANCH"; then + od::log "branch $BRANCH already exists — switching" + git -C "$WORKDIR" checkout "$BRANCH" +else + git -C "$WORKDIR" checkout -b "$BRANCH" "$OD_BASE_BRANCH" +fi + +mkdir -p "$WORKDIR/.od-contrib" +printf '%s\n' "$TYPE" > "$WORKDIR/.od-contrib/type.txt" +printf '%s\n' "$SLUG" > "$WORKDIR/.od-contrib/slug.txt" + +od::log "workspace ready" +printf 'WORKDIR=%s\n' "$WORKDIR" +printf 'BRANCH=%s\n' "$BRANCH" diff --git a/.claude/skills/od-contribute/scripts/validate-design-system.sh b/.claude/skills/od-contribute/scripts/validate-design-system.sh new file mode 100755 index 000000000..95b257ca8 --- /dev/null +++ b/.claude/skills/od-contribute/scripts/validate-design-system.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Validate a user-supplied DESIGN.md (Open Design "design system" submission). +# Usage: validate-design-system.sh <DESIGN.md path> [--reference <existing-DESIGN.md>] +# +# Strategy: instead of hardcoding a schema, we read 1-3 existing DESIGN.md files +# from the OD repo at runtime to learn which top-level sections are conventional, +# then check the new file has at least those sections (case-insensitive H1/H2 match). +# +# Heuristic-only: warns rather than fails on missing optional sections; only fails +# when the file is empty, unparseable, or has zero structural overlap with samples. + +set -uo pipefail +source "$(dirname "$0")/config.sh" + +NEW_FILE="${1:?DESIGN.md path required}" +shift || true + +REFERENCE_FILES=() +while (($#)); do + case "$1" in + --reference) REFERENCE_FILES+=("$2"); shift 2 ;; + *) od::die "unknown flag: $1" ;; + esac +done + +[[ -f "$NEW_FILE" ]] || od::die "not a file: $NEW_FILE" +[[ -s "$NEW_FILE" ]] || od::die "file is empty: $NEW_FILE" + +extract_headings() { + # Pull H1/H2 lines, lowercase, trim, dedupe. + awk '/^#{1,2}[[:space:]]+/ { sub(/^#{1,2}[[:space:]]+/, ""); print tolower($0) }' "$1" \ + | sed -E 's/[[:space:]]+$//' | sort -u +} + +new_headings="$(extract_headings "$NEW_FILE")" +[[ -n "$new_headings" ]] || { printf 'FAIL no H1/H2 headings found in %s — is this really a design system doc?\n' "$NEW_FILE"; printf 'RESULT=fail\n'; exit 1; } + +# If references were supplied, build the union of their headings as the "expected" set. +EXPECTED="" +for ref in "${REFERENCE_FILES[@]}"; do + [[ -f "$ref" ]] || continue + EXPECTED+=$'\n'"$(extract_headings "$ref")" +done +EXPECTED="$(printf '%s' "$EXPECTED" | grep -v '^$' | sort -u || true)" + +PASS=0 +WARN=0 +FAIL=0 + +if [[ -z "$EXPECTED" ]]; then + printf 'WARN no reference DESIGN.md provided — running structure-only checks\n' + WARN=$((WARN+1)) +else + # Count overlap. >= 30% structural overlap = looks like a design system. + overlap=0 + total=0 + while IFS= read -r h; do + [[ -z "$h" ]] && continue + total=$((total+1)) + if printf '%s\n' "$new_headings" | grep -Fxq "$h"; then + overlap=$((overlap+1)) + fi + done <<< "$EXPECTED" + + if [[ "$total" -eq 0 ]]; then + printf 'WARN references parsed but had no headings\n'; WARN=$((WARN+1)) + else + pct=$(( overlap * 100 / total )) + if [[ "$pct" -ge 30 ]]; then + printf 'PASS structural overlap with reference DESIGN.md files: %d%% (%d/%d)\n' "$pct" "$overlap" "$total" + PASS=$((PASS+1)) + else + printf 'FAIL structural overlap with reference DESIGN.md files only %d%% (%d/%d) — likely missing required sections\n' "$pct" "$overlap" "$total" + FAIL=$((FAIL+1)) + fi + fi +fi + +# Always-on lightweight checks: +if grep -qE '^(#)[[:space:]]+' "$NEW_FILE"; then + printf 'PASS has at least one H1 heading\n'; PASS=$((PASS+1)) +else + printf 'WARN no H1 heading found — convention is one H1 with the brand/system name\n'; WARN=$((WARN+1)) +fi + +# No relative path escape (../). +if grep -nE '\(\.\./' "$NEW_FILE" >/dev/null; then + printf 'WARN contains ../ relative paths — make sure they resolve once placed at design-systems/<brand>/DESIGN.md\n'; WARN=$((WARN+1)) +fi + +if [[ "$FAIL" -eq 0 ]]; then + printf 'RESULT=pass (passes=%d warns=%d)\n' "$PASS" "$WARN" + exit 0 +else + printf 'RESULT=fail (passes=%d warns=%d fails=%d)\n' "$PASS" "$WARN" "$FAIL" + exit 1 +fi diff --git a/.claude/skills/od-contribute/scripts/validate-markdown.sh b/.claude/skills/od-contribute/scripts/validate-markdown.sh new file mode 100755 index 000000000..e4ab211ba --- /dev/null +++ b/.claude/skills/od-contribute/scripts/validate-markdown.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# Lightweight Markdown validation for i18n / docs / blog contributions. +# +# Usage: validate-markdown.sh <file> [<file> ...] [--reference <orig>] +# +# Checks per file: +# - File is non-empty. +# - Code fences are balanced (count of ``` is even). +# - Newly-introduced relative refs that don't resolve on disk fail. +# Refs that ALREADY exist in the --reference file (the English source for +# a translation, or HEAD's version for a docs edit) are NOT failed even +# if they don't resolve — many OD docs reference website-router slugs +# like `skills/blog-post/` that aren't files in the checked-out repo. +# - External http(s) links return 2xx/3xx (best-effort, capped, 8s timeout). +# +# Without --reference, relative-ref checking is skipped entirely (since we +# can't tell route slugs from file paths in isolation). The other checks +# still run. + +set -uo pipefail +source "$(dirname "$0")/config.sh" +set +e +set -uo pipefail # restore the "accumulate diagnostics" stance after sourcing. + +REFERENCE="" +FILES=() +while (($#)); do + case "$1" in + --reference) REFERENCE="$2"; shift 2 ;; + --) shift; while (($#)); do FILES+=("$1"); shift; done ;; + -*) od::die "unknown flag: $1" ;; + *) FILES+=("$1"); shift ;; + esac +done + +(( ${#FILES[@]} >= 1 )) || od::die "usage: validate-markdown.sh <file> [<file> ...] [--reference <orig>]" + +# Build the "already-broken in source" set of relative refs (newline-delimited +# string for Bash 3 compatibility — no associative arrays). Anything in this +# set is excused from failing the new-file check. +KNOWN_DEAD=$'\n' +if [[ -n "$REFERENCE" ]]; then + if [[ ! -f "$REFERENCE" ]]; then + od::warn "--reference $REFERENCE does not exist; ignoring." + else + ref_dir="$(cd "$(dirname "$REFERENCE")" && pwd -P)" + while IFS= read -r ref; do + [[ -z "$ref" ]] && continue + case "$ref" in http*|mailto:*|\#*|/*) continue ;; esac + target="${ref%%#*}"; target="${target%%\?*}" + [[ -z "$target" ]] && continue + if [[ ! -e "$ref_dir/$target" ]]; then + KNOWN_DEAD+="${ref}"$'\n' + fi + done < <(grep -oE '\!?\[[^]]*\]\([^)]+\)' "$REFERENCE" 2>/dev/null \ + | sed -E 's/.*\(([^)]+)\).*/\1/' \ + | sort -u) + fi +fi + +OVERALL=0 +MAX_HTTP_PER_FILE=20 + +check_file() { + local f="$1" + local fail=0 + printf -- '--- %s ---\n' "$f" + + if [[ ! -f "$f" ]]; then + printf 'FAIL not a file: %s\n' "$f" + return 1 + fi + if [[ ! -s "$f" ]]; then + printf 'FAIL empty file: %s\n' "$f" + return 1 + fi + printf 'PASS exists, non-empty\n' + + # Code fence balance. + local fences + fences="$(grep -cE '^```' "$f" 2>/dev/null)" + if (( fences % 2 == 0 )); then + printf 'PASS code fences balanced (%d)\n' "$fences" + else + printf 'FAIL unbalanced code fences (%d ``` lines)\n' "$fences" + fail=1 + fi + + # Relative refs — tiered check: + # + # Image refs (![alt](path)) — always validate. No website route uses + # image-syntax markdown; if it doesn't resolve on disk, it's broken. + # + # Link refs starting with ./ or ../ — always validate. Explicit relative + # paths are unambiguously file references, not router slugs. + # + # Other link refs (e.g. `skills/blog-post/`) — only validated when + # --reference is supplied (we excuse refs already broken in the source). + # Without --reference we skip these because OD docs use slug-style refs + # for website routes that don't resolve to files in the checkout. + # + # In all cases, refs already broken in --reference (when supplied) are + # excused from failure rather than reported as regressions. + local dir rel_bad=0 rel_excused=0 rel_skipped_ambiguous=0 + dir="$(cd "$(dirname "$f")" && pwd -P)" + while IFS= read -r entry; do + [[ -z "$entry" ]] && continue + # `!?` in grep keeps the leading `!` for image refs; case-detect here. + is_img=0 + case "$entry" in '!'*) is_img=1 ;; esac + # Extract URL: between first `(` and last `)`. + ref="${entry#*\(}" + ref="${ref%\)*}" + case "$ref" in http*|mailto:*|\#*|/*) continue ;; esac + target="${ref%%#*}"; target="${target%%\?*}" + [[ -z "$target" ]] && continue + + # Should we validate this ref? + if (( is_img == 0 )); then + case "$ref" in + ./*|../*) ;; # explicit relative — always validate + *) + # File-like targets (have an obvious file extension) are unambiguously + # on-disk references — `[doc](missing.md)` is not a website route, it + # is a sibling file. Validate without --reference. Otherwise (no + # extension, looks like a slug), only validate when we have a + # reference to compare against. + case "${target##*/}" in + *.md|*.markdown|*.mdx \ + |*.png|*.jpg|*.jpeg|*.gif|*.webp|*.svg|*.ico|*.bmp \ + |*.pdf|*.txt|*.json|*.yaml|*.yml|*.toml \ + |*.sh|*.ts|*.tsx|*.js|*.jsx|*.css|*.html|*.xml \ + |*.csv|*.zip|*.gz) + ;; # file-like — always validate + *) + if [[ -z "$REFERENCE" ]]; then + rel_skipped_ambiguous=$((rel_skipped_ambiguous+1)) + continue + fi + ;; + esac + ;; + esac + fi + + if [[ ! -e "$dir/$target" ]]; then + case "$KNOWN_DEAD" in + *$'\n'"$ref"$'\n'*) rel_excused=$((rel_excused+1)) ;; + *) + printf 'FAIL broken relative reference: %s\n' "$ref" + rel_bad=$((rel_bad+1)) + fail=1 + ;; + esac + fi + done < <(grep -oE '!?\[[^]]*\]\([^)]+\)' "$f" 2>/dev/null | sort -u) + + if (( rel_bad == 0 )); then + msg="PASS relative refs OK" + (( rel_excused > 0 )) && msg+=" (${rel_excused} pre-existing dead refs kept as-is)" + (( rel_skipped_ambiguous > 0 )) && msg+=" (${rel_skipped_ambiguous} slug-style refs skipped — pass --reference to check)" + printf '%s\n' "$msg" + fi + + # External link health (best-effort). + local http_seen=0 http_bad=0 + while IFS= read -r url; do + [[ -z "$url" ]] && continue + (( http_seen >= MAX_HTTP_PER_FILE )) && break + http_seen=$((http_seen+1)) + local code + code="$(curl -sS -o /dev/null -m 8 -L -w '%{http_code}' --head "$url" 2>/dev/null)" + [[ -z "$code" ]] && code="000" + case "$code" in + 2*|3*|000) ;; # OK, or network-flaky — don't punish. + *) + printf 'FAIL external link %s returned %s\n' "$url" "$code" + http_bad=$((http_bad+1)) + fail=1 + ;; + esac + # URL extraction: stop at whitespace, ), ", ', <, >, [, ]. HTML <img src="..."> in + # OD docs would otherwise leak a trailing quote into the URL and cause false 404s. + done < <(grep -oE 'https?://[^][[:space:]"'\''<>)]+' "$f" 2>/dev/null | sort -u) + + if (( http_bad == 0 && http_seen > 0 )); then + printf 'PASS %d external links return 2xx/3xx (or network-skipped)\n' "$http_seen" + fi + + return "$fail" +} + +for f in "${FILES[@]}"; do + if ! check_file "$f"; then + OVERALL=1 + fi +done + +if [[ "$OVERALL" -eq 0 ]]; then + printf 'RESULT=pass\n' + exit 0 +else + printf 'RESULT=fail\n' + exit 1 +fi diff --git a/.claude/skills/od-contribute/scripts/validate-skill-submission.sh b/.claude/skills/od-contribute/scripts/validate-skill-submission.sh new file mode 100755 index 000000000..1694be50d --- /dev/null +++ b/.claude/skills/od-contribute/scripts/validate-skill-submission.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Validate a user-supplied OD skill folder before staging it for PR. +# Usage: validate-skill-submission.sh <skill-folder> +# Checks (each prints PASS/FAIL line on stdout): +# - SKILL.md exists +# - SKILL.md has frontmatter with `name` and `description` +# - `name` matches folder name (warn-only, since OD may rename on merge) +# - all relative paths in SKILL.md resolve to files inside the folder +# - no path escapes the skill folder (../ in references) +# Exit 0 = all PASS or only warnings. Exit 1 = at least one FAIL. + +set -uo pipefail +source "$(dirname "$0")/config.sh" + +SKILL_DIR="${1:?skill folder path required}" +[[ -d "$SKILL_DIR" ]] || od::die "not a directory: $SKILL_DIR" + +ABS_SKILL_DIR="$(cd "$SKILL_DIR" && pwd -P)" +FAIL=0 + +pass() { printf 'PASS %s\n' "$1"; } +warn() { printf 'WARN %s\n' "$1"; } +fail() { printf 'FAIL %s\n' "$1"; FAIL=1; } + +SKILL_MD="$ABS_SKILL_DIR/SKILL.md" +if [[ ! -f "$SKILL_MD" ]]; then + fail "SKILL.md missing — every OD skill folder must contain SKILL.md at its root" + printf 'RESULT=%s\n' "fail" + exit 1 +fi +pass "SKILL.md exists" + +# Frontmatter parse: extract YAML between the first two '---' lines. +# +# The opening fence MUST be on line 1 — both Claude Code's loader and Codex +# CLI's loader (codex-rs/core-skills) parse the top of the file, so a SKILL.md +# that starts with prose, a BOM, or whitespace and only contains a `---` block +# later will load as having no frontmatter, even if this validator picks it up. +# Reject leading content explicitly so the validator can't pass a file the +# real loaders will reject. +FIRST_LINE="$(head -n 1 "$SKILL_MD")" +if [[ ! "$FIRST_LINE" =~ ^---[[:space:]]*$ ]]; then + fail "SKILL.md must start with a YAML frontmatter fence ('---') on line 1 — found: $(printf '%q' "$FIRST_LINE" | head -c 80)" + printf 'RESULT=%s\n' "fail" + exit 1 +fi + +FRONT=$(awk ' + BEGIN { in_fm=0; fence=0 } + /^---[[:space:]]*$/ { + fence++ + if (fence==1) { in_fm=1; next } + if (fence==2) { exit } + } + in_fm { print } +' "$SKILL_MD") + +if [[ -z "$FRONT" ]]; then + fail "SKILL.md has a leading '---' but no closing fence or empty frontmatter" +else + pass "SKILL.md frontmatter present" + + name_line="$(printf '%s' "$FRONT" | grep -E '^name:' | head -1 || true)" + desc_line="$(printf '%s' "$FRONT" | grep -E '^description:' | head -1 || true)" + [[ -n "$name_line" ]] && pass "frontmatter has 'name'" || fail "frontmatter missing 'name:'" + [[ -n "$desc_line" ]] && pass "frontmatter has 'description'" || fail "frontmatter missing 'description:'" + + # Sanity: name should look like a slug. + fm_name="$(printf '%s' "$name_line" | sed -E 's/^name:[[:space:]]*//; s/^["'\''"]//; s/["'\''"]$//')" + folder_name="$(basename "$ABS_SKILL_DIR")" + if [[ -n "$fm_name" && "$fm_name" != "$folder_name" ]]; then + warn "frontmatter name '$fm_name' differs from folder name '$folder_name' (maintainer may rename — OK)" + fi +fi + +# Relative path scan: every non-URL, non-anchor markdown link target must +# resolve inside the skill folder. +# +# We extract ALL markdown links (`[label](target)`) and filter out URLs and +# anchors here, rather than only matching dot-prefixed paths in the regex. +# Plain intra-skill references like `[ref](references/foo.md)` or +# `[script](scripts/run.sh)` are common and must be validated too — the +# contract for SKILL.md says every relative path resolves on disk, regardless +# of whether the author wrote `./references/foo.md` or `references/foo.md`. +# A narrower `\(\.{1,2}/...\)` pattern would silently let bare paths through. +BAD_REFS=0 +ESCAPE=0 +# Lexical escape check: count path segments and ensure no prefix walks above +# the skill root. We do this on the literal target rather than from `cd … && +# pwd -P` so that a missing intermediate directory (which is itself a fail +# we want to report) doesn't masquerade as an escape. +escapes_root() { + local p="$1" depth=0 seg + # Strip a leading "./" if present. + p="${p#./}" + IFS='/' read -r -a _segs <<< "$p" + for seg in "${_segs[@]}"; do + case "$seg" in + ''|.) ;; + ..) depth=$((depth-1)); (( depth < 0 )) && return 0 ;; + *) depth=$((depth+1)) ;; + esac + done + return 1 +} +while IFS= read -r ref; do + # Skip protocol URLs, mailto, anchors-only, and absolute paths. + case "$ref" in + http*|https*|mailto:*|tel:*|\#*|/*) continue ;; + esac + # Strip query and fragment components before resolving. + target="${ref%%#*}" + target="${target%%\?*}" + [[ -z "$target" ]] && continue + if escapes_root "$target"; then + ESCAPE=1 + fail "path escapes skill folder: $ref" + continue + fi + if [[ ! -e "$ABS_SKILL_DIR/$target" ]]; then + BAD_REFS=$((BAD_REFS+1)) + fail "referenced file does not exist: $ref" + fi +done < <(grep -oE '\!?\[[^]]*\]\([^)]+\)' "$SKILL_MD" 2>/dev/null \ + | sed -E 's/.*\(([^)]+)\).*/\1/' \ + | sort -u) + +if [[ "$BAD_REFS" -eq 0 && "$ESCAPE" -eq 0 ]]; then + pass "all relative references resolve inside the skill folder" +fi + +if [[ "$FAIL" -eq 0 ]]; then + printf 'RESULT=%s\n' "pass" + exit 0 +else + printf 'RESULT=%s\n' "fail" + exit 1 +fi diff --git a/.claude/skills/od-contribute/templates/ISSUE-BODY-bug.md b/.claude/skills/od-contribute/templates/ISSUE-BODY-bug.md new file mode 100644 index 000000000..9a318213d --- /dev/null +++ b/.claude/skills/od-contribute/templates/ISSUE-BODY-bug.md @@ -0,0 +1,37 @@ +### What happened? + +{{WHAT_HAPPENED}} + +### Steps to reproduce + +{{STEPS}} + +### Expected behavior + +{{EXPECTED}} + +### Open Design version + +{{OD_VERSION}} + +### Platform + +{{PLATFORM}} + +### Logs (optional) + +``` +{{LOGS}} +``` + +### Screenshots (optional) + +{{SCREENSHOTS}} + +### Additional context + +{{CONTEXT}} + +--- + +_Reported via the `od-contribute` skill. If you can reproduce or have more context, please add a comment — every signal helps narrow the fix._ diff --git a/.claude/skills/od-contribute/templates/PR-BODY-design-system.md b/.claude/skills/od-contribute/templates/PR-BODY-design-system.md new file mode 100644 index 000000000..dfb43d061 --- /dev/null +++ b/.claude/skills/od-contribute/templates/PR-BODY-design-system.md @@ -0,0 +1,37 @@ +## What this PR adds + +A new Design System — **{{BRAND_NAME}}** — at `design-systems/{{BRAND_SLUG}}/DESIGN.md`. + +> {{PITCH}} + +## What this design system covers + +{{COVERAGE_NOTES}} + +## How to try it + +1. `cd open-design` +2. `pnpm tools-dev run web` +3. Start a new project and pick **{{BRAND_NAME}}** from the design system picker. +4. Ask the model: _"{{TRY_PROMPT}}"_ + +{{SCREENSHOT_BLOCK}} + +## What's in this PR + +- `design-systems/{{BRAND_SLUG}}/DESIGN.md` — the canonical design brief OD loads. +- Any supporting assets in `design-systems/{{BRAND_SLUG}}/` are referenced from `DESIGN.md`. + +## Checklist + +- [x] DESIGN.md has the conventional sections (compared against existing OD design systems) +- [x] No `../` path escapes outside the brand folder +- [ ] Maintainer review + +--- + +👋 This is my first OD contribution. Hi! If anything looks off, tell me what to change and I'll happily push a fixup commit. + +If you want to chat (or you're another newcomer reading this and want help shipping your first PR), come hang out in the OD Discord: {{DISCORD_INVITE}} + +_Generated with the `od-contribute` skill._ diff --git a/.claude/skills/od-contribute/templates/PR-BODY-docs.md b/.claude/skills/od-contribute/templates/PR-BODY-docs.md new file mode 100644 index 000000000..7cb7f623c --- /dev/null +++ b/.claude/skills/od-contribute/templates/PR-BODY-docs.md @@ -0,0 +1,32 @@ +## What this PR fixes + +{{ONE_LINE_SUMMARY}} + +## Details + +{{DETAILS}} + +<!-- +Use this for the body when there's nuance: +- which file/section +- the exact sentence/typo/dead link +- what you replaced it with and why +--> + +## Files touched + +{{FILES_LIST}} + +## Checklist + +- [x] Markdown still parses cleanly (no broken fences or structure) +- [x] All links and image paths still resolve +- [ ] Maintainer review + +--- + +👋 This is my first OD contribution. Hi! Small fix, but I figured every typo / dead link costs the next reader 30 seconds, and this saves that. + +If you want to chat or there's something you'd love help getting fixed, come find us in the OD Discord: {{DISCORD_INVITE}} + +_Generated with the `od-contribute` skill._ diff --git a/.claude/skills/od-contribute/templates/PR-BODY-i18n.md b/.claude/skills/od-contribute/templates/PR-BODY-i18n.md new file mode 100644 index 000000000..5e6c1a008 --- /dev/null +++ b/.claude/skills/od-contribute/templates/PR-BODY-i18n.md @@ -0,0 +1,41 @@ +## What this PR translates + +**{{DOC_NAME}}** → **{{LANG_DISPLAY_NAME}}** (`{{LANG_CODE}}`) + +- New file: `{{TRANSLATED_PATH}}` +- Source: `{{ENGLISH_PATH}}` +- Status: {{STATUS}} <!-- "missing" (new translation) or "stale" (refreshed) --> + +## What I preserved + +- Every Markdown structure element (headings, lists, tables, callouts, link/image targets) +- Code blocks — left untranslated +- Brand names and product names — left untranslated +- Internal cross-links — adjusted to point to the localized file when one exists, else to the English source + +## What I changed + +{{TRANSLATION_NOTES}} + +## How to verify + +```bash +# Render preview locally +cd open-design +# (or just open the .md file in any Markdown viewer) +``` + +## Checklist + +- [x] Markdown parses cleanly (code fences balanced, no broken structure) +- [x] All relative links and image paths still resolve +- [x] External links return 2xx/3xx +- [ ] Maintainer review + +--- + +👋 This is my first OD contribution. I'm a native {{LANG_DISPLAY_NAME}} speaker (or close to it!) and want to help OD reach more people in my language. + +If you want to chat or you're another translator reading this, come find us in the OD Discord: {{DISCORD_INVITE}} + +_Generated with the `od-contribute` skill._ diff --git a/.claude/skills/od-contribute/templates/PR-BODY-skill.md b/.claude/skills/od-contribute/templates/PR-BODY-skill.md new file mode 100644 index 000000000..d83809c6d --- /dev/null +++ b/.claude/skills/od-contribute/templates/PR-BODY-skill.md @@ -0,0 +1,37 @@ +## What this PR adds + +A new Skill — **{{SKILL_NAME}}** — at `skills/{{SKILL_SLUG}}/`. + +> {{PITCH}} + +## Why I made it + +{{MOTIVATION}} + +## How to try it + +1. `cd open-design` +2. Run OD locally: `pnpm tools-dev run web` +3. Open a project, start a chat, and ask: _"{{TRY_PROMPT}}"_ + +{{SCREENSHOT_BLOCK}} + +## What's in this PR + +- `skills/{{SKILL_SLUG}}/SKILL.md` — the skill itself (frontmatter + instructions) +- everything else inside `skills/{{SKILL_SLUG}}/` is referenced from `SKILL.md` + +## Checklist + +- [x] `SKILL.md` has a `name` and `description` in the frontmatter +- [x] Every relative path in `SKILL.md` resolves +- [x] No path escapes the skill folder +- [ ] Maintainer review + +--- + +👋 This is my first OD contribution. Hi! If anything looks off, tell me what to change and I'll happily push a fixup commit. + +If you want to chat (or you're another newcomer reading this and want help shipping your first PR), come hang out in the OD Discord: {{DISCORD_INVITE}} + +_Generated with the `od-contribute` skill._ diff --git a/.gitignore b/.gitignore index fdd212e1a..c297514d3 100644 --- a/.gitignore +++ b/.gitignore @@ -42,7 +42,15 @@ tsconfig.tsbuildinfo .cursor/ .agents/ .opencode/ -.claude/ +.claude/* +# Exception: od-contribute skill ships with the repo so the OD app can mount it +# for non-coder contributors. Personal Claude state (sessions, settings, etc.) stays ignored. +!.claude/skills/ +.claude/skills/* +!.claude/skills/od-contribute/ +!.claude/commands/ +.claude/commands/* +!.claude/commands/od-contribute.md .codex/ .deepseek/ .antigravitycli/ From da19ff3ca0758be8fee53641b07ce7be9cacd015 Mon Sep 17 00:00:00 2001 From: lefarcen <935902669@qq.com> Date: Fri, 29 May 2026 15:17:20 +0800 Subject: [PATCH 19/56] feat(mocks): replay-based mock CLIs for 14 of OD's supported agents (opencode/codex/claude/gemini/cursor-agent/deepseek/qwen/grok + ACP family devin/hermes/kilo/kimi/kiro/vibe) (#3241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mocks): replay-based mock CLIs for opencode/claude/codex/deepseek/qwen/grok Drops in a `mocks/` top-level dir that pretends to be the real agent CLIs by streaming pre-recorded sessions in each CLI's native stdout protocol. Zero LLM tokens. ## Use cases - **E2E tests** in `apps/daemon/tests/` — exercise the full chat-server pipeline against a known trace, assert UI events / artifacts. - **Self-validation during dev** — iterate on `claude-stream.ts` / `json-event-stream.ts` parser changes without burning provider budget. - **Regression harness** — replay the same trace before and after a charter / parser change; diff the daemon events the UI surfaces. - **Demo / onboarding** — show what a 17-tool claude editing session looks like end-to-end, offline. ## How - 6 bash wrappers (`mocks/bin/`) shadow the real CLIs when PATH-overlaid. - `mocks/mock-agent.mjs` reads `mocks/recordings/<trace>.jsonl`, picks one via env var (`SYNCLO_EXPLORE_MOCK_TRACE` / `_POOL` / `_BY_PROMPT_HASH`), streams the trace in the requested format. - Each format renderer matches the EXACT JSON shape the OD daemon parser expects, verified line-by-line against `apps/daemon/src/{json-event-stream,claude-stream}.ts`: | CLI | streamFormat | parser source | | ------------------------- | ------------------------- | ------------------------------------------ | | `opencode` | `json-event-stream` | `handleOpenCodeEvent` | | `codex` | `json-event-stream` | `handleCodexEvent` | | `claude` | `claude-stream-json` | `createClaudeStreamHandler` | | `deepseek` `qwen` `grok` | `plain` | `server.ts` (raw stdout) | ## Quick start ```bash export PATH="$PWD/mocks/bin:$PATH" export SYNCLO_EXPLORE_MOCK_TRACE=04097377 # 8-char prefix OK export SYNCLO_EXPLORE_MOCK_NO_DELAY=1 echo "any prompt" | opencode run echo "any prompt" | claude -p --output-format=stream-json echo "any prompt" | codex exec ``` The mock binary announces the picked trace id on stderr: `[mock-opencode] picked 04097377… via fixed`. Recording selection (env, in priority order): - `SYNCLO_EXPLORE_MOCK_TRACE=<id>` — fixed (prefix OK) - `SYNCLO_EXPLORE_MOCK_BY_PROMPT_HASH=1` + stdin prompt — `sha256(prompt) % N` - `SYNCLO_EXPLORE_MOCK_POOL=<tag>` — random within `agent:claude` / `skill:agent-browser` / `outcome:failed` / etc. - (default) uniform random - `SYNCLO_EXPLORE_MOCK_SEED=<str>` — reproducible "random" - `SYNCLO_EXPLORE_MOCK_NO_DELAY=1` — skip inter-event waits ## Dataset 179 anonymized Langfuse traces from this project's own production telemetry: - 9 agents: claude 57 · opencode 41 · codex 38 · gemini 25 · cursor-agent 11 · qwen 2 · copilot 2 · deepseek 2 · antigravity 1 - outcomes: succeeded 144 · failed 35 - skills: default 71 · ad-creative 50 · algorithmic-art 30 · agent-browser 22 · video-hyperframes 2 · plus magazine-web-ppt / brainstorming / data-report / penpot-flutter-design-source 1 each - 124 multi-turn (sessions with ≥2 turns) - 18 produce `<artifact>` output - ~4.5 MB on disk total Anonymization: `/Users/<name>/` → `${HOME}/`, `C:\Users\<name>\` → `%USERPROFILE%\`, project UUIDs → stable `proj-001`, `proj-002`, …. Tool input/output payloads preserved verbatim (templated UI, no cell-level PII). ## Smoke test `bash mocks/scripts/smoke-test.sh` — 6 checks across all 6 agents. All pass on this branch (verified locally): ``` ✓ opencode first event = step_start ✓ codex first event = thread.started ✓ claude first event = system ✓ deepseek emitted plain text (144 chars on first line) ✓ qwen emitted plain text (144 chars on first line) ✓ grok emitted plain text (144 chars on first line) All mock CLIs working. ✅ ``` ## Adding more recordings The exporter that produced this set lives in [nexu-io/agent-pr-explore](https://github.com/nexu-io/agent-pr-explore) (see `cli/src/local/orchestrator/langfuse-import.ts` + the `local langfuse-import` CLI command). Operators with the Langfuse keys can pull more by tag / outcome / artifact / multi-turn filter, then run `local recordings anonymize --out-dir ~/Documents/open-design/mocks/recordings`. `mocks/README.md` has the full instructions. ## Out of scope (follow-ups) - **ACP agents** (`devin`, `hermes`, `kilo`, `kimi`, `kiro`, `vibe`) need a JSON-RPC server on stdio rather than a one-shot stream — separate `format-acp.mjs` module not yet written. - **Per-agent json-event-stream variants** (`cursor-agent`, `gemini`, `qoder`, `copilot`, `pi`) currently fall back to the `plain` renderer; their parsers are in `apps/daemon/src/json-event-stream.ts` and follow the same template as `format-codex.mjs`. ## AGENTS.md updates - Added `mocks/` to the top-level content directories listing - Added a Validation strategy bullet pointing here for agent-stream / parser changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mocks): add opencode-cli/kiro-cli/vibe-acp bin aliases and unref ACP timeout - Add mocks/bin/opencode-cli, kiro-cli, vibe-acp wrappers for the primary RuntimeAgentDef bin names OD resolves before any fallback. Without these, a PATH-overlaid OD daemon run bypasses the mock entirely (opencode-cli, kiro-cli) or cannot find the mock at all (vibe-acp, which has no fallback). - Include opencode-cli, kiro-cli, vibe-acp in the smoke-test ACP/JSON loop so coverage is verified end-to-end. - Call .unref() on the 30s safety timeout in format-acp.mjs so a completed ACP session exits promptly instead of waiting the full 30 seconds. Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code) * feat(mocks): add vela (AMR) — login / models / ACP with strict set_model gate Extends mocks/ to cover OD's own AMR runtime. `vela` is the bin name `apps/daemon/src/runtimes/defs/amr.ts` specifies (`bin: 'vela'`, `streamFormat: 'acp-json-rpc'`). It's richer than the generic ACP agents — covers full login + models + chat-session lifecycle. ### What vela does (mirrored from apps/daemon/tests/fixtures/fake-vela.mjs) 1. `vela login` — writes ~/.amr/config.json with a fake profile (controlKey, runtimeKey, user{email,name,plan}, profile-specific apiUrl/linkUrl). The on-disk projection is what OD's daemon login route + AmrLoginPill poller read; production goes through device-auth, the mock skips straight to the file write. 2. `vela models` — prints the production-shaped public model catalog as newline-separated `public_model_* vela` lines. Override via FAKE_VELA_MODELS env. 3. `vela agent run --runtime opencode` — ACP JSON-RPC server with three vela-specific protocol extensions: a. `initialize` response carries `agentCapabilities` (`promptCapabilities.embeddedContext`) + `models` (`currentModelId` + `availableModels`). b. `session/new` response carries the same `models` block. c. **Strict set_model gate**: `session/prompt` is rejected with JSON-RPC -32602 ("session/set_model must be called before session/prompt") UNLESS `session/set_model` (or `session/set_config_option`) has been called for the current sessionId. Mirrors real vela 0.0.1 contract; catches regressions in `attachAcpSession` that silently skip set_model. ### Error injection envs (in sync with fake-vela.mjs) FAKE_VELA_SESSION_ID - sessionId returned by session/new FAKE_VELA_TEXT - override assistant text FAKE_VELA_THOUGHT - optional thought_chunk before text FAKE_VELA_SESSION_NEW_ERROR - fail session/new FAKE_VELA_SET_MODEL_ERROR - fail session/set_model FAKE_VELA_PROMPT_ERROR - fail session/prompt FAKE_VELA_REQUIRE_SET_MODEL='0' - disable the strict gate (legacy) FAKE_VELA_LOGIN_USER_EMAIL - email written into config profile FAKE_VELA_LOGIN_USER_PLAN - plan written into config profile FAKE_VELA_LOGIN_DELAY_MS - sleep before write (test in-flight) FAKE_VELA_LOGIN_FAIL - print + exit 1 FAKE_VELA_MODELS - override models stdout VELA_PROFILE - profile slot (prod | test | local) ### Components `mocks/lib/format-vela.mjs` (~205 LOC) - Full ACP server with vela protocol extensions - Strict set_model gate - Error injection plumbing `mocks/lib/vela-subcommands.mjs` (~90 LOC) - runVelaLogin() — writes ~/.amr/config.json - runVelaModels() — prints catalog `mocks/bin/vela` — dispatcher wrapper. Forwards `vela <subcmd>` to mock-agent.mjs which routes to login/models or falls through to ACP. `mocks/mock-agent.mjs` — parseArgs now collects positionals so the vela dispatcher can read subcommand from there; switch case added for vela. `mocks/scripts/smoke-test.sh` — +4 assertions: vela models prints ≥10 catalog lines vela login writes ~/.amr/config.json with the requested email vela agent run ACP roundtrip (initialize+models+set_model+stream+result) vela strict set_model gate rejects prompt without prior set_model ### Verified locally ✓ vela models printed 15 catalog lines ✓ vela login wrote ~/.amr/config.json with profile.prod.user.email ✓ vela agent run ACP roundtrip (initialize+models, set_model accepted, prompt streamed) ✓ vela strict set_model gate rejects session/prompt without prior set_model All 21 smoke checks pass (up from 17 with previous P3 ACP commit). ### AGENTS.md + README updates AGENTS.md — mention `vela (AMR — vela CLI)` alongside ACP agents in the directory listing entry. mocks/README.md — protocol table row + dedicated vela section with subcommand contract, strict gate explanation, env-injection cheat sheet. Mock-tree listing updated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mocks): honor REPORT_FILE env when --report-file flag not given Harnesses that spawn the mock without translating their report-path contract to the mock's CLI flag (notably nexu-io/agent-pr-explore's orchestrator, which passes REPORT_FILE as env per the existing opencode/claude/codex agent launchers) wouldn't get a report file written, so the harness's "agent exit 0 but produced no report" check would always fire and mark mock runs as failure even though the stdout stream was complete. Fix: in mock-agent.mjs parseArgs, fall through to process.env.REPORT_FILE when --report-file wasn't provided on argv. Each format renderer already accepts opts.reportFile and writes the recording's final assistant text to it (`format-*.mjs` already had this — only the wiring was missing). Verified: synclo-explore run with `mock=true, mock_trace=04097377` against the opencode wrapper now produces a plan.md with the recording's 17-tool claude editing session report. ~1.5s per run vs ~70s real opencode. * mocks: move recordings to Cloudflare R2; PR→main→Action upload path The 179-recording corpus (~4.5 MB raw, ~280 KB after compression) has been moved off git into Cloudflare R2 at the bucket open-design-mocks under recordings/v1/. The repo now ships: - mocks/manifest.json — the canonical catalog (renamed from recordings/index.json) with sha256 + storage hints; consumers fetch this to discover what exists, then pull individual jsonl files on demand - mocks/scripts/fetch-recordings.sh — parallel, sha256-verified, idempotent puller for the public r2.dev URL - mocks/scripts/add-recording.sh — local maintainer helper that validates a new .jsonl and copies it into recordings-staging/ (no R2 calls; no credentials needed) - mocks/scripts/upload-to-r2.mjs — called only by the CI workflow - mocks/scripts/lib/manifest-utils.mjs — shared sha256/meta/ rebuild-histograms logic, used by both add-recording (preview) and upload-to-r2 (actual write) so the entry shape never drifts - .github/workflows/sync-mocks-to-r2.yml — fires on push to main when mocks/recordings-staging/ changes; uploads to R2, updates manifest, commits cleanup back; serialized via concurrency group Trust model: R2 write credentials (CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID) are repo secrets; nobody can push from a laptop. Read stays public via the r2.dev URL. Why not pnpm install integration: contributors who do not touch agent code do not pay the fetch cost. Fetch happens on first smoke-test run (auto-fallback) or when a mock spawn needs data. Repo size: -4.55 MB net (delete 179 jsonl, +280 KB manifest + scripts). Smoke test (21 checks) still green against the fetched corpus. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * mocks: scope R2 write token to a dedicated secret name Use CLOUDFLARE_R2_MOCKS_TOKEN (instead of reusing the shared CLOUDFLARE_API_TOKEN that landing-page-*.yml uses for Pages deploys) so the R2 write capability can be scoped to just the open-design-mocks bucket without bleeding extra capability into the Pages workflows. Also hardcode the powerformer CF account_id directly in the workflow (account IDs are not secret and the shared CLOUDFLARE_ACCOUNT_ID secret may point at a different account). Workflow now fails fast with an actionable error message + dashboard link if the secret is unset. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * mocks: switch R2 sync to S3-compat API (wrangler getMemberships gate) wrangler 4.x calls /memberships before any r2 action, requiring user:read scope. R2 "Object Read & Write" tokens deliberately lack that scope (defense in depth — a leaked token should not enumerate account-level resources). The workflow now uses the aws CLI talking straight to the R2 S3-compatible endpoint with SigV4, no membership lookup. Secret rotation: CLOUDFLARE_R2_MOCKS_TOKEN (Bearer) is replaced by CLOUDFLARE_R2_MOCKS_AK / CLOUDFLARE_R2_MOCKS_SK (matching the existing CLOUDFLARE_R2_RELEASES_AK/SK naming convention). End-to-end tested locally: PUT recording → manifest rebuild → manifest PUT → staging cleanup all green. aws CLI is pre-installed on ubuntu-latest, so no install step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * mocks: scrub synclo namespace; use OD_MOCKS_* env prefix throughout These mocks were copy-pasted from synclo-explore, where they originated, and inherited the SYNCLO_EXPLORE_MOCK_* env-var convention. That brand-bleed is not appropriate in OD: rename the public env surface to OD_MOCKS_* (matching OD-native prefixes like OD_MOCKS_CACHE_DIR, OD_TRACE_R2_UPLOAD, OD_EXPECT_TIMEOUT_SECONDS). Renames: SYNCLO_EXPLORE_MOCK_TRACE → OD_MOCKS_TRACE SYNCLO_EXPLORE_MOCK_BY_PROMPT_HASH → OD_MOCKS_BY_PROMPT_HASH SYNCLO_EXPLORE_MOCK_POOL → OD_MOCKS_POOL SYNCLO_EXPLORE_MOCK_SEED → OD_MOCKS_SEED SYNCLO_EXPLORE_MOCK_NO_DELAY → OD_MOCKS_NO_DELAY SYNCLO_EXPLORE_MOCK_RECORDINGS_DIR → OD_MOCKS_RECORDINGS_DIR SYNCLO_EXPLORE_MOCK_SMOKE_TRACE → OD_MOCKS_SMOKE_TRACE SYNCLO_OD_MOCKS_I_KNOW_WHAT_IM_DOING → OD_MOCKS_ALLOW_LOCAL_UPLOAD Also drop the inline harvester usage from README. The harvester is an external CLI in nexu-io/agent-pr-explore — its README is the right place for langfuse-import flags, anonymization options, etc. OD only documents its own staging→PR→Action workflow. Smoke test (21 checks) still green; OD_MOCKS_TRACE end-to-end verified to route correctly. Consumers of the OLD env names (notably the orchestrator in nexu-io/agent-pr-explore) need a matching rename. No back-compat shim here — the explore side has zero external users today and a one-line follow-up is cleaner than a permanent deprecation layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * AGENTS.md: align mock env names with mocks/ rename (SYNCLO_* → OD_MOCKS_*) Missed in the prior commit (a30b868a) — only grepped mocks/ subdir. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * mocks: drop staging dir + GH Action; back to local-script upload The staging-dir + Action design (added earlier in this PR) had a flaw the user caught: new recordings briefly entered the repo on their way through staging, leaving them in git history forever even after the Action cleanup commit removed them from HEAD. That defeats the whole point of moving recordings to R2. Replace with the simpler local-maintainer flow: bash mocks/scripts/upload-recording.sh /path/to/<trace>.jsonl # → validates, wrangler r2 put, updates manifest.json, wrangler r2 put manifest git add mocks/manifest.json && git commit && git push # → only the ~200B manifest delta enters git The wrangler-OAuth gate replaces the CI secret + Action duo. For a solo / small maintainer team this collapses the trust chain down to "do you have wrangler login to the powerformer account?" — no GH secrets to rotate, no concurrency window to worry about, no inevitable repo-history bloat. Deletes: - .github/workflows/sync-mocks-to-r2.yml - mocks/scripts/upload-to-r2.mjs (CI-only) - mocks/scripts/add-recording.sh (staging helper, now obsolete) - mocks/recordings-staging/ (empty dir, never to be repopulated) Adds: - mocks/scripts/upload-recording.sh Kept: - mocks/scripts/fetch-recordings.sh - mocks/scripts/lib/manifest-utils.mjs (still used by upload-recording.sh) - mocks/manifest.json (committed; the only mocks artifact in git) End-to-end tested locally: re-upload an existing recording is idempotent, manifest math is stable, fetch + smoke test still green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * mocks: address review — guard allowlist + safe ~/.amr + loud OD_MOCKS_TRACE typo Three concrete issues raised across recent Siri-Ray (Looper) review threads on #3241: 1. scripts/guard.ts only allowlisted mocks/lib/ + mocks/mock-agent.mjs, leaving mocks/scripts/lib/manifest-utils.mjs outside the residual- JS guard. Result: Preflight fail on every push. Extend the allowlist to mocks/scripts/ — same precedent as the lib/ entry directly above. 2. mocks/scripts/smoke-test.sh moved the caller real ~/.amr to ~/.amr-smoke-backup, ran vela login (which writes a fake config), then rm -rf the .amr and restored the backup. Two failure modes: crash mid-run loses the user real config, and re-running before restore overwrites the backup with the fake login. Fix: sandbox vela login into a mktemp -d HOME via env (HOME=$amr_sandbox vela login). Never touches the real ~/.amr at all. trap cleans up. 3. mocks/lib/recording-picker.mjs silently fell through to prompt-hash → pool → random when OD_MOCKS_TRACE was set but did not match any recording (typo, prefix too short, corpus not fetched). Tests using a pinned trace would silently get a different trace, hiding regressions. Fix: throw an explicit error with the failing value + a pointer at fetch-recordings.sh. Verified locally: pnpm guard prints "Residual JavaScript check passed", smoke-test still 21/21, ~/.amr mtime unchanged after run, typo on OD_MOCKS_TRACE now produces "mock-agent: OD_MOCKS_TRACE=... set but no matching recording in <dir>" on stderr. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fetch-recordings: detect empty filter result before line-counting printf '%s\n' on an empty string emits a single empty line, so the previous TOTAL=$(printf ... | grep -c "") math returned 1 on an empty $ENTRIES_TSV — a typo like `--agent no-such-agent` printed "Fetching up to 1 recordings", downloaded zero, and exited 0 ("ready"). Check `-z $ENTRIES_TSV` first. Reproduced + fix verified per the reviewer thread. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * mocks: address mrcfps review — goldens + provenance + contract check Three durability improvements suggested in the PR #3241 top-level review: ## 1. Golden daemon-event snapshots (mocks/golden/*.events.json + apps/daemon/tests/mocks-golden.test.ts) Smoke-test verified that mocks RUN; that catches crashes but not a parser change that semantically reshapes the events the daemon emits. Commit the daemon-event sequence for 3 representative traces: - claude 314d6833 — median-complexity agent-browser session - codex dcdff3b3 — 14-tool refactor - opencode 9a9522ec — 7-tool data-report apps/daemon/tests/mocks-golden.test.ts spawns the mock, feeds stdout through the real createClaudeStreamHandler / createJsonEventStreamHandler, normalizes per-spawn volatile fields (only sessionId today, only on claude), and deep-equals against the committed snapshot. A parser regression fails the test loudly. After an intentional parser change, regenerate: MOCKS_GOLDEN_UPDATE=1 pnpm --filter @open-design/daemon test mocks-golden git diff mocks/golden/ # eyeball; commit if shapes match intent ## 2. Provenance fields on every manifest entry (mocks/scripts/lib/manifest-utils.mjs + mocks/manifest.json) Augment inspectRecording() to write: captured_at — ISO 8601 from existing meta.timestamp cli_version — null until harvester writes it protocol_version — null until harvester writes it anonymization_version — null until harvester writes it captured_at is now populated for all 179 existing entries from the meta event the harvester already emits. The harvester in nexu-io/agent-pr-explore is the next step for cli_version / protocol_version / anonymization_version — once those are populated, consumers can detect when a recording is older than ~1 minor version behind the live CLI and flag for re-harvest. No matrix of (cli_version × agent) recordings — that explodes maintenance. Just metadata per recording so trust decay is visible. ## 3. Real-CLI contract check (mocks/scripts/contract-check.sh + docs/MOCKS-CONTRACT-CHECK.md) Mocks catch parser regressions against recordings; they do NOT catch recordings drifting away from the live agent CLI as that CLI evolves. The contract check spawns the real CLI alongside the mock with a fixed deterministic prompt + diffs top-level event-type distributions. Deliberately human-driven, not cron-scheduled: - costs real LLM tokens per invocation - requires real CLI auth - maintainer reads the output, not a regex Suggested triggers per doc: real-CLI release notes mentioning "output format" / "stream" / "JSON" / "events"; before a parser refactor; ad-hoc when something looks off. ## Coverage note README updated to position mocks as "deterministic protocol/parser coverage" (not "e2e replacement") per mrcfps framing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mocks-golden test): drop import of non-exported ParserKind Use plain string (the type alias is `string` anyway) — Preflight typecheck on a31fa71a failed: tests/mocks-golden.test.ts(29,8): error TS2459: Module "../src/json-event-stream.js" declares "ParserKind" locally, but it is not exported. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * recording-picker: structured OD_MOCKS_POOL + hard-fail no-match Siri-Ray review: \`OD_MOCKS_POOL=outcome:failed\` was documented as a supported selection knob, but the matcher only checked tags and \`meta.agent\` — so the negative-path pool found 0 candidates and silently fell through to global random, validating against any recording instead of a failed trace. Fix: - Parse \`<dim>:<value>\` shape and route each dim to the right meta field: \`outcome\` → \`meta.outcome\`, \`agent\` → \`meta.agent\`, \`skill\` → \`tags[]\`. Bare values still fall back to tag substring. - If the env was set and matched nothing, throw with the failing value and a jq one-liner for inspection. Same loud-fail policy as OD_MOCKS_TRACE — silent fallback was the original bug. Verified locally: outcome:failed, agent:codex, skill:agent-browser all route correctly; outcome:nonsense throws the explicit error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * contract-check.sh: fix lost $PROMPT in mock invocation Siri-Ray review on e576074a: the mock side wrapped its pipeline in `bash -c "printf %s \"\$PROMPT\" | ..."` — but $PROMPT was a parent shell variable, not exported, so the child bash expanded it to an empty string. Result: the contract check sent the real prompt to the real CLI and an empty string to the mock, defeating the same-input invariant the whole script rests on. Also let the mock randomly select a different trace whenever a maintainer happens to have OD_MOCKS_BY_PROMPT_HASH=1 in their env. Fix: drop the inner bash -c entirely; use a subshell that scopes the PATH overlay and pipes printf into the PATH-resolved mock binary directly. The subshell limits the PATH change without var-passing. Verified locally: with prompt-A the mock picks trace 54ec02ee via hash; prompt-B → 2667e851 via hash; empty prompt (old broken behavior) → random — confirms the prompt is now actually reaching the mock under PATH overlay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- AGENTS.md | 3 +- apps/daemon/tests/mocks-golden.test.ts | 113 + docs/MOCKS-CONTRACT-CHECK.md | 108 + mocks/README.md | 475 +++ mocks/bin/claude | 9 + mocks/bin/codex | 9 + mocks/bin/cursor-agent | 4 + mocks/bin/deepseek | 9 + mocks/bin/devin | 4 + mocks/bin/gemini | 4 + mocks/bin/grok | 9 + mocks/bin/hermes | 4 + mocks/bin/kilo | 4 + mocks/bin/kimi | 4 + mocks/bin/kiro | 4 + mocks/bin/kiro-cli | 7 + mocks/bin/opencode | 9 + mocks/bin/opencode-cli | 7 + mocks/bin/qwen | 9 + mocks/bin/vela | 8 + mocks/bin/vibe | 4 + mocks/bin/vibe-acp | 7 + ...33-0377-4ac4-ba11-2b8d7eca5511.events.json | 114 + ...ec-575f-432f-aeed-efc491e900aa.events.json | 187 + mocks/golden/README.md | 56 + ...b3-cd39-4dcd-be83-372830a29639.events.json | 222 + mocks/lib/format-acp.mjs | 204 + mocks/lib/format-claude.mjs | 92 + mocks/lib/format-codex.mjs | 92 + mocks/lib/format-cursor-agent.mjs | 63 + mocks/lib/format-gemini.mjs | 53 + mocks/lib/format-opencode.mjs | 60 + mocks/lib/format-plain.mjs | 40 + mocks/lib/format-vela.mjs | 224 + mocks/lib/recording-picker.mjs | 128 + mocks/lib/vela-subcommands.mjs | 99 + mocks/manifest.json | 3623 +++++++++++++++++ mocks/mock-agent.mjs | 149 + mocks/recordings/.gitignore | 6 + mocks/scripts/contract-check.sh | 113 + mocks/scripts/fetch-recordings.sh | 158 + mocks/scripts/lib/manifest-utils.mjs | 103 + mocks/scripts/smoke-test.sh | 175 + mocks/scripts/upload-recording.sh | 103 + scripts/guard.ts | 11 + 45 files changed, 6888 insertions(+), 1 deletion(-) create mode 100644 apps/daemon/tests/mocks-golden.test.ts create mode 100644 docs/MOCKS-CONTRACT-CHECK.md create mode 100644 mocks/README.md create mode 100755 mocks/bin/claude create mode 100755 mocks/bin/codex create mode 100755 mocks/bin/cursor-agent create mode 100755 mocks/bin/deepseek create mode 100755 mocks/bin/devin create mode 100755 mocks/bin/gemini create mode 100755 mocks/bin/grok create mode 100755 mocks/bin/hermes create mode 100755 mocks/bin/kilo create mode 100755 mocks/bin/kimi create mode 100755 mocks/bin/kiro create mode 100755 mocks/bin/kiro-cli create mode 100755 mocks/bin/opencode create mode 100755 mocks/bin/opencode-cli create mode 100755 mocks/bin/qwen create mode 100755 mocks/bin/vela create mode 100755 mocks/bin/vibe create mode 100755 mocks/bin/vibe-acp create mode 100644 mocks/golden/314d6833-0377-4ac4-ba11-2b8d7eca5511.events.json create mode 100644 mocks/golden/9a9522ec-575f-432f-aeed-efc491e900aa.events.json create mode 100644 mocks/golden/README.md create mode 100644 mocks/golden/dcdff3b3-cd39-4dcd-be83-372830a29639.events.json create mode 100644 mocks/lib/format-acp.mjs create mode 100644 mocks/lib/format-claude.mjs create mode 100644 mocks/lib/format-codex.mjs create mode 100644 mocks/lib/format-cursor-agent.mjs create mode 100644 mocks/lib/format-gemini.mjs create mode 100644 mocks/lib/format-opencode.mjs create mode 100644 mocks/lib/format-plain.mjs create mode 100644 mocks/lib/format-vela.mjs create mode 100644 mocks/lib/recording-picker.mjs create mode 100644 mocks/lib/vela-subcommands.mjs create mode 100644 mocks/manifest.json create mode 100755 mocks/mock-agent.mjs create mode 100644 mocks/recordings/.gitignore create mode 100755 mocks/scripts/contract-check.sh create mode 100755 mocks/scripts/fetch-recordings.sh create mode 100644 mocks/scripts/lib/manifest-utils.mjs create mode 100755 mocks/scripts/smoke-test.sh create mode 100755 mocks/scripts/upload-recording.sh diff --git a/AGENTS.md b/AGENTS.md index 08e752deb..f93912c69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ This file is the single source of truth for agents entering this repository. Rea ## Workspace directories - Workspace packages come from `pnpm-workspace.yaml`: `apps/*`, `packages/*`, `tools/*`, and `e2e`. -- Top-level content directories: `skills/` (functional skills the agent invokes mid-task — utilities, briefs, packagers; see `skills/AGENTS.md`), `design-templates/` (rendering catalogue: decks, prototypes, image/video/audio templates; see `design-templates/AGENTS.md` and `specs/current/skills-and-design-templates.md`), `design-systems/` (brand `DESIGN.md` files), `craft/` (universal brand-agnostic craft rules a skill can opt into via `od.craft.requires`). +- Top-level content directories: `skills/` (functional skills the agent invokes mid-task — utilities, briefs, packagers; see `skills/AGENTS.md`), `design-templates/` (rendering catalogue: decks, prototypes, image/video/audio templates; see `design-templates/AGENTS.md` and `specs/current/skills-and-design-templates.md`), `design-systems/` (brand `DESIGN.md` files), `craft/` (universal brand-agnostic craft rules a skill can opt into via `od.craft.requires`), `mocks/` (replay-based mock CLIs for `opencode`/`claude`/`codex`/`gemini`/`cursor-agent`/`deepseek`/`qwen`/`grok`, the ACP family `devin`/`hermes`/`kilo`/`kimi`/`kiro`/`vibe`, and the AMR `vela` CLI (login + models + ACP), built from anonymized Langfuse traces — PATH-overlay drop-in for tests and self-validation; see `mocks/README.md`). - `apps/web` is the Next.js 16 App Router + React 18 web runtime; do not restore `apps/nextjs`. - `apps/daemon` is the local privileged daemon and `od` bin. It owns `/api/*`, agent spawning, skills, design systems, artifacts, and static serving. - `apps/desktop` is the Electron shell; it discovers the web URL through sidecar IPC. @@ -167,6 +167,7 @@ root `pnpm tools-pr` script without a new explicit maintainer decision. ## Validation strategy - After package, workspace, or command-entry changes, run `pnpm install` so workspace links and generated dist entries stay fresh. +- For agent-stream / parser changes (`apps/daemon/src/claude-stream.ts`, `json-event-stream.ts`, `qoder-stream.ts`, etc.), replay a recorded session through the mock CLIs in `mocks/` to verify event shapes round-trip without burning provider budget. PATH-overlay activation: `export PATH="$PWD/mocks/bin:$PATH" OD_MOCKS_TRACE=<8-char-id> OD_MOCKS_NO_DELAY=1`. See `mocks/README.md` for the trace catalog and selection knobs. - Treat every `pnpm-lock.yaml` change as requiring a Nix pnpm deps hash refresh check. `nix/pnpm-deps.nix` is a generated lock artifact; use `pnpm nix:update-hash` only when intentionally maintaining Nix packaging, then re-run `nix flake check --print-build-logs --keep-going`. Contributors without Nix can rely on the PR `Validate workspace` gate, which now uploads or auto-applies the generated hash-only fix when possible. - Before marking regular work ready, run at least `pnpm guard` and `pnpm typecheck`, plus the package-scoped tests/builds that match the files changed. Do not use or add root `pnpm test`/`pnpm build` aliases. - For local web runtime loops, prefer `pnpm tools-dev run web --daemon-port <port> --web-port <port>`. diff --git a/apps/daemon/tests/mocks-golden.test.ts b/apps/daemon/tests/mocks-golden.test.ts new file mode 100644 index 000000000..ee36c5010 --- /dev/null +++ b/apps/daemon/tests/mocks-golden.test.ts @@ -0,0 +1,113 @@ +// Golden daemon-event snapshots — addresses the regression-signal point +// from review on #3241: smoke-testing that mocks RUN catches only crashes +// or protocol-level garbage; it does NOT catch a parser change that +// semantically reshapes the events the daemon emits to the UI. +// +// This test replays representative recordings through the actual daemon +// stream handlers and asserts the emitted event sequence matches a +// committed `mocks/golden/<trace>.events.json`. A parser tweak that +// drops a tool_result, changes a usage shape, or renames an event type +// fails this test loudly. +// +// Update flow when a parser change is INTENTIONAL: +// MOCKS_GOLDEN_UPDATE=1 pnpm --filter @open-design/daemon test mocks-golden +// then `git diff mocks/golden/` and commit the new shapes. +// +// Auto-skips when the recording corpus hasn't been fetched yet (see +// `mocks/scripts/fetch-recordings.sh`); CI that exercises this test must +// fetch first. + +import { describe, it, expect } from 'vitest'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { createClaudeStreamHandler } from '../src/claude-stream.js'; +import { createJsonEventStreamHandler } from '../src/json-event-stream.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = join(HERE, '../../..'); +const MOCK_AGENT = join(REPO, 'mocks/mock-agent.mjs'); +const GOLDEN_DIR = join(REPO, 'mocks/golden'); +const RECORDINGS_DIR = join(REPO, 'mocks/recordings'); + +// Median-tool-count successful traces per agent (selected from manifest +// 2026-05-29). Each one's `.jsonl` lives in `mocks/recordings/` after +// `bash mocks/scripts/fetch-recordings.sh`. +const CASES: Array<{ agent: 'claude' | 'codex' | 'opencode'; trace: string }> = [ + { agent: 'claude', trace: '314d6833-0377-4ac4-ba11-2b8d7eca5511' }, + { agent: 'codex', trace: 'dcdff3b3-cd39-4dcd-be83-372830a29639' }, + { agent: 'opencode', trace: '9a9522ec-575f-432f-aeed-efc491e900aa' }, +]; + +// Replace per-spawn-volatile fields with stable sentinels so the +// snapshot stays diffable across runs. Currently only `sessionId` — +// claude's mock emits a fresh UUID every spawn. Opencode/codex carry +// the recording's own session/thread id so they're already stable. +function normalizeVolatile(events: unknown[]): unknown[] { + return events.map(e => { + if (!e || typeof e !== 'object') return e; + const rec = e as Record<string, unknown>; + const out: Record<string, unknown> = { ...rec }; + if ('sessionId' in out) out.sessionId = '<normalized>'; + return out; + }); +} + +function runMockAndCollectEvents(agent: string, trace: string): unknown[] { + // Force no-delay so the spawn returns quickly + deterministically. + const proc = spawnSync( + process.execPath, + [MOCK_AGENT, '--as', agent, '--no-delay'], + { + env: { ...process.env, OD_MOCKS_TRACE: trace, OD_MOCKS_NO_DELAY: '1' }, + input: 'golden-test-prompt', + encoding: 'utf-8', + timeout: 30_000, + maxBuffer: 50 * 1024 * 1024, + }, + ); + if (proc.status !== 0) { + throw new Error( + `mock-agent --as ${agent} exit ${proc.status}: ${proc.stderr.slice(0, 500)}`, + ); + } + + const events: unknown[] = []; + const sink = (e: unknown) => events.push(e); + const handler = + agent === 'claude' + ? createClaudeStreamHandler(sink) + : createJsonEventStreamHandler(agent, sink); + handler.feed(proc.stdout); + return normalizeVolatile(events); +} + +const recordingsAvailable = + existsSync(RECORDINGS_DIR) && + CASES.every(c => existsSync(join(RECORDINGS_DIR, `${c.trace}.jsonl`))); + +describe.skipIf(!recordingsAvailable)( + 'mocks goldens — daemon event shape regression', + () => { + for (const { agent, trace } of CASES) { + it(`${agent} ${trace.slice(0, 8)}`, () => { + const events = runMockAndCollectEvents(agent, trace); + const goldenPath = join(GOLDEN_DIR, `${trace}.events.json`); + + if (process.env.MOCKS_GOLDEN_UPDATE === '1') { + mkdirSync(GOLDEN_DIR, { recursive: true }); + writeFileSync( + goldenPath, + JSON.stringify({ agent, trace, events }, null, 2) + '\n', + ); + return; + } + + const golden = JSON.parse(readFileSync(goldenPath, 'utf-8')); + expect({ agent, trace, events }).toEqual(golden); + }); + } + }, +); diff --git a/docs/MOCKS-CONTRACT-CHECK.md b/docs/MOCKS-CONTRACT-CHECK.md new file mode 100644 index 000000000..395bbfec0 --- /dev/null +++ b/docs/MOCKS-CONTRACT-CHECK.md @@ -0,0 +1,108 @@ +# Real-CLI contract check + +The replay mocks under `mocks/` impersonate real agent CLIs by emitting +recorded traces in each CLI's native protocol. They're great for parser +regression coverage but they can silently drift away from the real CLI +when: + +- An agent CLI ships a new event `type` that the mock doesn't know about. +- A field gets renamed (`sessionID` → `sessionId`) and the mock keeps + emitting the old name. OD's parser may have been updated to accept + both, so smoke tests stay green, but new fields aren't surfaced. +- A protocol version bump changes the shape of `usage` / tool calls / + init blocks. + +The contract check is the periodic ritual that catches that drift. + +## Scope + +It is **not** a CI gate. The check: + +- Costs real LLM tokens (a few cents per agent per run). +- Requires the real CLI installed + authenticated locally or on a + maintainer-controlled runner. +- Wants a human to eyeball the output, not a regex. + +Treat it like a maintenance task — monthly is fine, ad-hoc whenever a +relevant CLI publishes a release note about output-format changes. + +## How to run + +```bash +bash mocks/scripts/contract-check.sh claude +bash mocks/scripts/contract-check.sh codex +bash mocks/scripts/contract-check.sh opencode +``` + +The script: + +1. Resolves the real CLI binary (ignoring the `mocks/bin/` PATH overlay). +2. Sends a fixed deterministic prompt: *"List the entries of the current + working directory and tell me how many JSON files are present."* +3. Runs the same prompt through the mock CLI. +4. Prints a side-by-side distribution of top-level event `type` + values from both. +5. Leaves both raw JSONL outputs in `/tmp` for you to `diff`. + +## What to look for + +Compare the two `type` distributions. Acceptable differences: + +- Counts vary slightly (mock plays a single recorded trace, real CLI + may take a different number of turns for the same prompt). +- Mock emits a superset of the real CLI's event types — the recordings + span historical CLI versions. + +**Red flags**: + +- Real CLI emits a `type` value the mock never produces → the mock + needs a new event handler in `mocks/lib/format-<agent>.mjs`. +- Real CLI's event uses different field names than the mock → either + the real CLI changed and the parser may already be out of sync, or + the mock is drifting toward an internal convention. +- Mock crashes / emits nothing → the agent's `--no-delay` path is + broken. + +## Suggested cadence + +No fixed schedule, no automated cron — the check is human-driven: + +- **On real-CLI release**: when Anthropic / OpenAI / OpenCode publishes + a release whose notes mention "output format" / "JSON" / "stream" / + "events" / "API", run the affected agent's check. This is the + highest-signal trigger. +- **Before a parser refactor**: lock the contract before touching + `apps/daemon/src/claude-stream.ts` / `json-event-stream.ts`, so a + post-refactor failure means "I broke the parser" rather than "the + real CLI already drifted and the parser had silently caught it". +- **Ad-hoc**: if something feels off — UI suddenly missing a tool call, + duplicate events, unfamiliar field names in logs — a contract check + is the fast first step. + +Putting this on a cron would burn LLM tokens every run with no human +review of the output, defeating the point. The check is an artifact a +maintainer reads, not a CI gate. + +## Future improvements + +The current script only compares top-level `type` distributions +because a deeper structural diff is hard to do without a schema. +Possible follow-ups: + +1. **JSON-shape schema per agent** — generate a JSON Schema from the + mock formatters' output, run a validator against real-CLI output, + report violations with field paths. +2. **Recorded-then-replayed delta** — capture the real CLI's output + for the fixed prompt, save under `mocks/contracts/<agent>.golden.jsonl`, + then in CI replay that golden through the daemon parser and assert + no parser errors. Cheaper than calling the LLM every CI run but + only catches *parser* drift, not *CLI* drift. + +Neither is implemented today. + +## Related + +- `mocks/scripts/contract-check.sh` — the script itself. +- `apps/daemon/tests/mocks-golden.test.ts` — daemon-event golden + snapshots (catches parser regressions against the mocks, complementary + to this check which catches mock-vs-real drift). diff --git a/mocks/README.md b/mocks/README.md new file mode 100644 index 000000000..605efae23 --- /dev/null +++ b/mocks/README.md @@ -0,0 +1,475 @@ +# `mocks/` — replay-based mock CLIs for OD's supported agents + +A drop-in replacement for the real agent CLIs (`claude`, `opencode`, +`codex`, `gemini`, `cursor-agent`, `deepseek`, `qwen`, `grok`, the +ACP family `devin` / `hermes` / `kilo` / `kimi` / `kiro` / `vibe`, and +the AMR `vela` CLI) that replays pre-recorded sessions in each CLI's +native protocol — stdout streaming for most, JSON-RPC over stdio for +ACP and AMR. **Zero LLM tokens.** + +Used by: + +- **E2E tests** in `apps/daemon/tests/` — run the full chat-server + pipeline against a known agent trace, assert UI events / artifacts. +- **Local self-tests during development** — iterate on `chat-routes.ts`, + `claude-stream.ts`, `json-event-stream.ts` parser changes without + burning provider budget. +- **Demo / onboarding** — show what a 17-tool `claude` editing session + looks like end-to-end, offline. +- **Regression harness** — replay the same trace before and after a + charter / parser change; diff the events the daemon surfaces. + +The recordings are anonymized exports from open-design's Langfuse +project (179 traces across 9 agents and 5+ skills as of this commit). + +--- + +## tl;dr + +```bash +# First-time setup — pull the recording corpus from R2 (~30s, 4.5MB): +bash mocks/scripts/fetch-recordings.sh +# Subsequent runs hit the local cache (sha256-verified, instant). + +# Make the mock CLIs override the real ones for this shell: +export PATH="$PWD/mocks/bin:$PATH" + +# Pick any recording to play back (8-char prefix OK): +export OD_MOCKS_TRACE=04097377 + +# Speed up replay (skip inter-event sleeps): +export OD_MOCKS_NO_DELAY=1 + +# Now anything that spawns opencode/claude/codex gets the recording: +echo "any prompt body" | opencode run +echo "any prompt" | claude -p --output-format=stream-json +echo "any prompt" | codex exec +``` + +The mock binaries are bash wrappers that exec +`node mocks/mock-agent.mjs --as <agent>`. Anything fed to stdin is +discarded by the renderer but used by the recording picker (see hash +mode below). + +## Recordings live on R2, not in this repo + +The 179-recording corpus (~4.5 MB) is hosted on Cloudflare R2 at +`open-design-mocks` and fetched **on demand** — `pnpm install` does NOT +pull them, and the repo stays small. Recordings only land in +`mocks/recordings/` when: + +1. You run `bash mocks/scripts/fetch-recordings.sh` directly, OR +2. `bash mocks/scripts/smoke-test.sh` runs and the dir is empty (auto- + fetch fallback), OR +3. A mock binary spawn finds no data — it errors with a pointer at the + fetch script (no silent failure). + +This is by design: contributors who don't touch agent code don't pay +the fetch cost. CI jobs that DO touch agent code (`apps/daemon/tests/` +parser changes, etc.) run the fetch as a quick pre-step and cache +`mocks/recordings/` between runs. + +```bash +# Fetch everything (parallel, sha256-verified, idempotent): +bash mocks/scripts/fetch-recordings.sh + +# Fetch a subset: +bash mocks/scripts/fetch-recordings.sh --agent claude # 57 claude traces +bash mocks/scripts/fetch-recordings.sh --outcome failed # 35 failed-path traces +bash mocks/scripts/fetch-recordings.sh --skill agent-browser + +# Override cache location (e.g. share across multiple OD checkouts): +OD_MOCKS_CACHE_DIR=~/.cache/od-mocks bash mocks/scripts/fetch-recordings.sh +``` + +Manifest at `mocks/manifest.json` is the committed source of truth — +it lists every recording's `trace_id`, `sha256`, `bytes`, `agent`, +`outcome`, `skills`, `multi_turn`, plus histograms over the corpus. +Tooling reads this; you don't have to. + +### Provenance per recording + +Beyond identity (`trace_id`, `sha256`), each manifest entry carries +fixture-trust signals so consumers can decide whether the recording +is still meaningful as the real CLIs evolve: + +| Field | Meaning | +|---|---| +| `captured_at` | ISO 8601 timestamp of the original session — populated for all 179 current entries | +| `cli_version` | The CLI version the trace was captured against (e.g. `"claude-code 1.0.65"`) — populated only on traces the harvester writes it to, null otherwise | +| `protocol_version` | Stream-format version (`"claude-stream-json/v1"`, `"opencode/json-event-stream"`) — populated by harvester | +| `anonymization_version` | Which anonymizer pass scrubbed the recording — populated by harvester | + +For now most of these are null on the existing 179 — the harvester in +[nexu-io/agent-pr-explore][harvester] is the next thing to teach to +write them. Once a recording's `cli_version` falls behind the actual +CLI by more than one minor version, treat it as a candidate for +re-harvest. + +### Golden daemon-event snapshots + +`mocks/golden/<trace>.events.json` holds the exact event sequence the +OD daemon emits when fed each (mock CLI → handler) pipeline. Diffed +on every `pnpm --filter @open-design/daemon test` run by +`apps/daemon/tests/mocks-golden.test.ts`. + +A parser refactor that semantically changes events (drops a field, +renames `sessionId`, stops emitting `turn_end`) fails the diff loudly. +After an intentional parser change, regenerate: + +```bash +MOCKS_GOLDEN_UPDATE=1 pnpm --filter @open-design/daemon test mocks-golden +git diff mocks/golden/ # eyeball the new shapes +git add mocks/golden/ && git commit -m "mocks: refresh goldens for <parser change>" +``` + +Per-spawn volatile fields (currently just claude's generated +`sessionId`) are stripped to `"<normalized>"` so the snapshot stays +stable. See `mocks/golden/README.md` for the coverage rationale. + +### Real-CLI contract check + +The mocks catch parser regressions against the recordings; they do +**not** catch the recordings themselves drifting away from the live +agent CLIs. For that, `mocks/scripts/contract-check.sh` spawns a real +CLI alongside the mock with a fixed prompt and prints a side-by-side +event-type distribution. + +This is human-driven and costs real LLM tokens — run on a real-CLI +release or before a parser refactor, not on a cron. Full doc: +[`docs/MOCKS-CONTRACT-CHECK.md`](../docs/MOCKS-CONTRACT-CHECK.md). + +--- + +## What gets emitted + +Each renderer matches the EXACT event shapes the OD daemon expects, as +verified line-by-line against the parsers in `apps/daemon/src/`: + +| CLI | OD streamFormat | Parser source | +|---|---|---| +| `opencode` | `json-event-stream` (opencode kind) | `json-event-stream.ts:handleOpenCodeEvent` | +| `codex` | `json-event-stream` (codex kind) | `json-event-stream.ts:handleCodexEvent` | +| `claude` | `claude-stream-json` | `claude-stream.ts:createClaudeStreamHandler` | +| `gemini` | `json-event-stream` (gemini kind) | `json-event-stream.ts:handleGeminiEvent` | +| `cursor-agent` | `json-event-stream` (cursor-agent kind) | `json-event-stream.ts:handleCursorEvent` | +| `deepseek` `qwen` `grok` | `plain` | `server.ts` (raw stdout = final assistant text) | +| `devin` `hermes` `kilo` `kimi` `kiro` `vibe` | `acp-json-rpc` | `acp.ts:attachAcpSession` | +| `vela` (AMR) | `acp-json-rpc` + `login` / `models` subcommands | `runtimes/defs/amr.ts` + `apps/daemon/tests/fixtures/fake-vela.mjs` (sibling stub) | + +> **Note on `gemini` and `cursor-agent`**: OD's parsers for these two +> agents do NOT recognize tool-call events — only init / assistant text / +> usage. The renderers therefore emit ONLY the final assistant text wrapped +> in the expected init/text/usage envelope. Tool calls present in the +> source recording are silently dropped (which matches the real CLI's UI +> behavior — these agents don't surface tools in OD's chat view). + +> **Note on ACP agents** (`devin` / `hermes` / `kilo` / `kimi` / `kiro` / +> `vibe`): These do NOT stream stdout — they speak JSON-RPC v2 over stdio. +> OD's daemon sends `initialize` → `session/new` → (optional `session/set_model`) +> → `session/prompt`; the mock responds in order, streams text via +> `session/update` notifications carrying `agent_message_chunk` parts, +> then responds to the prompt request with usage stats. Tool calls +> aren't part of the ACP protocol on this path (tools surface via MCP or +> other side channels), so they're dropped from playback. + +> **Note on `vela` (AMR)**: vela is the bin OD's AMR runtime spawns. It +> extends the generic ACP shape with `agentCapabilities` + `models` +> blocks in `initialize` / `session/new`, plus a **strict set_model gate** +> — `session/prompt` is rejected with -32602 until `session/set_model` +> (or `session/set_config_option`) has been called for the current +> sessionId, mirroring real vela 0.0.1 contract. +> +> vela also has two non-ACP subcommands: +> +> - `vela login` → writes `~/.amr/config.json` with a fake profile so +> OD's daemon login route + `AmrLoginPill` poller see the same on-disk +> projection production produces. +> - `vela models` → prints the production-shaped `public_model_* vela` +> catalog. +> +> Error injection envs (kept in sync with +> `apps/daemon/tests/fixtures/fake-vela.mjs`): +> `FAKE_VELA_SESSION_NEW_ERROR` / `FAKE_VELA_SET_MODEL_ERROR` / +> `FAKE_VELA_PROMPT_ERROR` / `FAKE_VELA_LOGIN_FAIL` / +> `FAKE_VELA_REQUIRE_SET_MODEL=0`. + +Each tool call from the recording is rendered with the original input +arguments and tool output. The agents' assistant text is rendered as +the final message. + +--- + +## Recording selection + +Driven by env vars, in priority order: + +| Env | Behavior | +|---|---| +| `OD_MOCKS_TRACE=<id>` | Always play this trace. 8-char prefix OK. | +| `OD_MOCKS_BY_PROMPT_HASH=1` + stdin prompt | Deterministic by `sha256(prompt) % len(all)`. Same prompt → same trace. Useful for "stable answer per question" tests. | +| `OD_MOCKS_POOL=<tag>` | Random within the tag pool. Examples: `agent:claude`, `skill:agent-browser`, `outcome:failed`. | +| `OD_MOCKS_SEED=<str>` | Makes "random" picks reproducible across runs. | +| `OD_MOCKS_NO_DELAY=1` | Skip inter-event waits. | +| `OD_MOCKS_RECORDINGS_DIR=<path>` | Override the recordings dir. | + +If none are set, a uniformly random recording is played each invocation. + +The mock binary announces the picked trace id on stderr: + +``` +[mock-opencode] picked 04097377… via fixed +``` + +This line is invisible to OD's stdout parser but useful for "wait, why +did my test get the FAQ-fix trace?" debugging. + +--- + +## Recording catalog + +The recordings live as one JSONL file per Langfuse trace under +`recordings/`. Each file starts with a `meta` event carrying: + +```json +{ + "type": "meta", + "source": {"provider": "langfuse", "trace_id": "...", "project_id": "..."}, + "agent": "claude" | "codex" | "opencode" | "gemini" | "cursor-agent" | "qwen" | "copilot" | "deepseek" | "antigravity", + "model": "...", + "outcome": "succeeded" | "failed" | "errored" | "interrupted", + "duration_ms": 33620, + "tool_call_count": 17, + "error_count": 0, + "total_tokens": 12345, + "tags": ["agent:claude", "skill:agent-browser", "open-design", ...], + "user_input": "...", + "session_id": "..." +} +``` + +Subsequent events are `tool_call`, `tool_result`, and `report` (the +final assistant text). + +### Indexed metadata + +`mocks/manifest.json` is a flat manifest with one entry per recording +plus histograms over all recordings, committed to the repo. It's also +mirrored to R2 alongside the .jsonl files so consumers can fetch the +current catalog without cloning. Query with `jq`: + +```bash +# All multi-turn claude sessions about HTML editing +jq '.entries[] | select(.agent=="claude" and .multi_turn==true)' \ + mocks/manifest.json | head -50 + +# Failed codex traces (negative-path tests) +jq '.entries[] | select(.agent=="codex" and .outcome=="failed") | .trace_id' \ + mocks/manifest.json + +# Agent-browser skill, sorted by tool count desc +jq '[.entries[] | select(.skills | index("agent-browser"))] | sort_by(-.tool_count)' \ + mocks/manifest.json +``` + +### Headline stats (current dataset) + +| Dimension | Distribution | +|---|---| +| Agents | claude 57 · opencode 41 · codex 38 · gemini 25 · cursor-agent 11 · qwen/copilot/deepseek 2 each · antigravity 1 | +| Outcomes | succeeded 144 · failed 35 | +| Skills | default 71 · ad-creative 50 · algorithmic-art 30 · agent-browser 22 · video-hyperframes 2 · magazine-web-ppt / brainstorming / data-report / penpot-flutter 1 each | +| Multi-turn | 124 traces tied to a session with ≥2 turns | +| Artifact | 18 traces produce `<artifact>` output | + +--- + +## Anonymization + +User-specific data has been scrubbed from every recording: + +- `/Users/<name>/…`, `/home/<name>/…`, `C:\Users\<name>\…` + → `${HOME}/…` / `%USERPROFILE%\…` +- Project UUIDs → stable `proj-001`, `proj-002`, … per recording +- meta tag `project:<uuid>` rewritten too + +The anonymizer is idempotent. Tool input/output payloads (HTML, code, +etc.) are preserved verbatim — they're templated UI without cell-level +PII; if a future audit finds otherwise, add specific scrubs in the +harvester repo (see "Adding more recordings" below) and re-run. + +--- + +## Adding more recordings + +Local maintainer flow — the .jsonl never enters the repo. Only the +manifest delta (≈200 B per entry) gets committed. + +### Step 1 — produce an anonymized .jsonl + +The harvester that produced the current 179-trace set lives in a +separate repo, [nexu-io/agent-pr-explore][harvester]. See its README +for how to authenticate against your trace store, filter by skill / +agent / outcome, and anonymize the result. Output is one +`<trace-id>.jsonl` file per recording. + +[harvester]: https://github.com/nexu-io/agent-pr-explore + +### Step 2 — one-shot upload + manifest update + +```bash +# prereq, once: wrangler login (OAuth, no token to manage) +bash mocks/scripts/upload-recording.sh /path/to/<trace-id>.jsonl +``` + +The script validates the file, prints the manifest entry it will add, +uploads the .jsonl to R2, rewrites `mocks/manifest.json` locally, then +uploads the updated manifest to R2 too (so consumers see the new entry +without waiting for the next git push). + +### Step 3 — commit the manifest delta + +```bash +git add mocks/manifest.json +git commit -m "mocks: add recording <trace-id>" +git push # or open a PR — your call +``` + +The only thing in the commit is a ~200-byte JSON edit listing the new +entry's `trace_id`, `sha256`, `bytes`, `agent`, `outcome`, `skills`, +etc. The .jsonl itself stays in R2. + +### Trust model + +- **R2 write is wrangler-OAuth gated.** Maintainers do `wrangler login` + once. The bucket is on the powerformer Cloudflare account (pinned in + the script). No long-lived tokens in repo secrets, no Action to + hijack — just account access. +- **Repo stays small forever.** No .jsonl files ever land in git; the + manifest grows by ~200 B per recording. +- **Read stays public.** Anyone can fetch via the r2.dev URL — see + [Recordings live on R2, not in this repo](#recordings-live-on-r2-not-in-this-repo). + +### Removing a recording + +```bash +# 1. delete from R2 +export CLOUDFLARE_ACCOUNT_ID=64ad4569ffd912432d6b86d5656484c4 +wrangler r2 object delete open-design-mocks/recordings/v1/<trace-id>.jsonl --remote +# 2. drop the entry from manifest.json (edit by hand, or use `jq`) +# 3. re-upload manifest +wrangler r2 object put open-design-mocks/recordings/v1/manifest.json \ + --file mocks/manifest.json --remote +# 4. git add mocks/manifest.json && git commit && git push +``` + +There's no automation for delete because (a) it's rare and (b) you +want a human to think about whether removing a recording would +invalidate any test fixtures that pin it via `OD_MOCKS_TRACE=<id>`. + +--- + +## Usage from OD's test code + +### From a test (Vitest / Jest) + +```ts +import { spawn } from 'node:child_process'; +import { join } from 'node:path'; + +const MOCK_BIN = join(__dirname, '../../mocks/bin'); + +it('parses an opencode session with 4 tool calls into 4 UI events', async () => { + const child = spawn('opencode', ['run'], { + env: { + ...process.env, + PATH: `${MOCK_BIN}:${process.env.PATH}`, + OD_MOCKS_TRACE: '06a9324a', // 4-tool claude session + OD_MOCKS_NO_DELAY: '1', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + child.stdin.write('test prompt'); + child.stdin.end(); + // ... assert events parsed from child.stdout +}); +``` + +### From a manual playback + +```bash +# See what claude's 17-tool "delete v2" session emits to OD: +export PATH=$(git rev-parse --show-toplevel)/mocks/bin:$PATH +export OD_MOCKS_TRACE=04097377 +export OD_MOCKS_NO_DELAY=1 +echo "anything" | claude -p --output-format=stream-json | jq .type | uniq -c +``` + +--- + +## Files + +``` +mocks/ +├── README.md ← you are here +├── mock-agent.mjs ← entry; routes --as <agent> to format renderer +├── lib/ +│ ├── recording-picker.mjs ← env-driven trace selection +│ ├── format-opencode.mjs ← matches handleOpenCodeEvent +│ ├── format-codex.mjs ← matches handleCodexEvent +│ ├── format-claude.mjs ← matches createClaudeStreamHandler +│ ├── format-gemini.mjs ← matches handleGeminiEvent +│ ├── format-cursor-agent.mjs ← matches handleCursorEvent +│ ├── format-acp.mjs ← JSON-RPC server matching attachAcpSession +│ ├── format-vela.mjs ← AMR vela: ACP + models block + set_model gate +│ ├── vela-subcommands.mjs ← `vela login` + `vela models` handlers +│ └── format-plain.mjs ← raw stdout (deepseek/qwen/grok) +├── bin/ +│ ├── opencode claude codex +│ ├── gemini cursor-agent +│ ├── deepseek qwen grok +│ ├── devin hermes kilo kimi kiro vibe +│ └── vela ← 15 bash wrappers, PATH-overlay +├── manifest.json ← committed: 179 entries' metadata + sha256 + provenance + R2 storage hints +├── golden/ ← committed: daemon-event regression snapshots +│ ├── README.md +│ └── *.events.json ← 3 representative traces (claude/codex/opencode) +├── scripts/ +│ ├── smoke-test.sh ← 21 checks; auto-fetches recordings if empty +│ ├── fetch-recordings.sh ← pull from R2 (parallel, sha256-verified, idempotent) +│ ├── upload-recording.sh ← maintainer-local: validate + wrangler put + manifest update +│ ├── contract-check.sh ← real-CLI vs mock protocol drift check (manual) +│ └── lib/ +│ └── manifest-utils.mjs ← shared sha256 / meta-parse / manifest-rebuild logic +└── recordings/ ← populated at runtime, gitignored .jsonl + └── .gitignore ← recordings come via fetch +``` + +No external dependencies. Pure node:`fs`/`crypto`/`child_process`. Works +under any Node ≥18. + +--- + +## Limitations + +- `copilot`, `qoder`, `pi` (the niche `copilot-stream-json` / + `qoder-stream-json` / `pi-rpc` formats) are recorded but not yet + rendered as their native protocols — they fall back to the plain + renderer for now. If you need them, add a `format-<agent>.mjs` + following the same pattern as `format-codex.mjs`; the parsers are + in `apps/daemon/src/{copilot-stream,qoder-stream}.ts` and the pi-rpc + handler inside `apps/daemon/src/server.ts`. +- The mock does not honor CLI flags that change semantics (`--model`, + `--permission-mode`, `--allowed-tools`). They're silently ignored. + +--- + +## Provenance / safety + +All recordings come from open-design's own Langfuse project (the +`open-design` project under the `powerformer` org). Users opted into +telemetry when they installed the desktop client. The anonymizer +removed user-identifying paths and project UUIDs before checking in. + +If you find a recording that includes content that should be redacted, +follow the [Removing a recording](#removing-a-recording) flow above. diff --git a/mocks/bin/claude b/mocks/bin/claude new file mode 100755 index 000000000..8b22e4f1e --- /dev/null +++ b/mocks/bin/claude @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Mock wrapper for claude — runs the bundled mock-agent in this agent's +# stdout protocol. The wrapper accepts (and ignores) any flags the real +# CLI would take; the mock doesn't honor model selection, tool gates, or +# permission modes — recording selection is via OD_MOCKS_* +# env vars (see ../README.md). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as claude "$@" diff --git a/mocks/bin/codex b/mocks/bin/codex new file mode 100755 index 000000000..7a1cfed1c --- /dev/null +++ b/mocks/bin/codex @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Mock wrapper for codex — runs the bundled mock-agent in this agent's +# stdout protocol. The wrapper accepts (and ignores) any flags the real +# CLI would take; the mock doesn't honor model selection, tool gates, or +# permission modes — recording selection is via OD_MOCKS_* +# env vars (see ../README.md). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as codex "$@" diff --git a/mocks/bin/cursor-agent b/mocks/bin/cursor-agent new file mode 100755 index 000000000..f1c764d7e --- /dev/null +++ b/mocks/bin/cursor-agent @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as cursor-agent "$@" diff --git a/mocks/bin/deepseek b/mocks/bin/deepseek new file mode 100755 index 000000000..d18f2f86d --- /dev/null +++ b/mocks/bin/deepseek @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Mock wrapper for deepseek — runs the bundled mock-agent in this agent's +# stdout protocol. The wrapper accepts (and ignores) any flags the real +# CLI would take; the mock doesn't honor model selection, tool gates, or +# permission modes — recording selection is via OD_MOCKS_* +# env vars (see ../README.md). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as deepseek "$@" diff --git a/mocks/bin/devin b/mocks/bin/devin new file mode 100755 index 000000000..28545a799 --- /dev/null +++ b/mocks/bin/devin @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as devin "$@" diff --git a/mocks/bin/gemini b/mocks/bin/gemini new file mode 100755 index 000000000..8cca67dbe --- /dev/null +++ b/mocks/bin/gemini @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as gemini "$@" diff --git a/mocks/bin/grok b/mocks/bin/grok new file mode 100755 index 000000000..becd0e970 --- /dev/null +++ b/mocks/bin/grok @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Mock wrapper for grok — runs the bundled mock-agent in this agent's +# stdout protocol. The wrapper accepts (and ignores) any flags the real +# CLI would take; the mock doesn't honor model selection, tool gates, or +# permission modes — recording selection is via OD_MOCKS_* +# env vars (see ../README.md). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as grok "$@" diff --git a/mocks/bin/hermes b/mocks/bin/hermes new file mode 100755 index 000000000..ae273eba1 --- /dev/null +++ b/mocks/bin/hermes @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as hermes "$@" diff --git a/mocks/bin/kilo b/mocks/bin/kilo new file mode 100755 index 000000000..9d4b3aad1 --- /dev/null +++ b/mocks/bin/kilo @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as kilo "$@" diff --git a/mocks/bin/kimi b/mocks/bin/kimi new file mode 100755 index 000000000..d9464a79e --- /dev/null +++ b/mocks/bin/kimi @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as kimi "$@" diff --git a/mocks/bin/kiro b/mocks/bin/kiro new file mode 100755 index 000000000..d733cae4d --- /dev/null +++ b/mocks/bin/kiro @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as kiro "$@" diff --git a/mocks/bin/kiro-cli b/mocks/bin/kiro-cli new file mode 100755 index 000000000..3f6a55199 --- /dev/null +++ b/mocks/bin/kiro-cli @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Alias wrapper for kiro-cli — the primary bin name OD resolves for the +# kiro RuntimeAgentDef (bin: 'kiro-cli'). +# Delegates to the same mock as mocks/bin/kiro. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as kiro "$@" diff --git a/mocks/bin/opencode b/mocks/bin/opencode new file mode 100755 index 000000000..3fdfabec3 --- /dev/null +++ b/mocks/bin/opencode @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Mock wrapper for opencode — runs the bundled mock-agent in this agent's +# stdout protocol. The wrapper accepts (and ignores) any flags the real +# CLI would take; the mock doesn't honor model selection, tool gates, or +# permission modes — recording selection is via OD_MOCKS_* +# env vars (see ../README.md). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as opencode "$@" diff --git a/mocks/bin/opencode-cli b/mocks/bin/opencode-cli new file mode 100755 index 000000000..216d64425 --- /dev/null +++ b/mocks/bin/opencode-cli @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Alias wrapper for opencode-cli — the primary bin name OD resolves for +# the opencode RuntimeAgentDef (bin: 'opencode-cli', fallbackBins: ['opencode']). +# Delegates to the same mock as mocks/bin/opencode. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as opencode "$@" diff --git a/mocks/bin/qwen b/mocks/bin/qwen new file mode 100755 index 000000000..132bafc36 --- /dev/null +++ b/mocks/bin/qwen @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Mock wrapper for qwen — runs the bundled mock-agent in this agent's +# stdout protocol. The wrapper accepts (and ignores) any flags the real +# CLI would take; the mock doesn't honor model selection, tool gates, or +# permission modes — recording selection is via OD_MOCKS_* +# env vars (see ../README.md). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as qwen "$@" diff --git a/mocks/bin/vela b/mocks/bin/vela new file mode 100755 index 000000000..b43af65c8 --- /dev/null +++ b/mocks/bin/vela @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# vela mock CLI — dispatches by first argv: +# vela login → write ~/.amr/config.json (fake credentials) +# vela models → print public model catalog +# vela agent run --runtime opencode → ACP JSON-RPC server (default) +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as vela "$@" diff --git a/mocks/bin/vibe b/mocks/bin/vibe new file mode 100755 index 000000000..1ada0ee77 --- /dev/null +++ b/mocks/bin/vibe @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as vibe "$@" diff --git a/mocks/bin/vibe-acp b/mocks/bin/vibe-acp new file mode 100755 index 000000000..5a465205d --- /dev/null +++ b/mocks/bin/vibe-acp @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Alias wrapper for vibe-acp — the primary bin name OD resolves for the +# vibe RuntimeAgentDef (bin: 'vibe-acp'). +# Delegates to the same mock as mocks/bin/vibe. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec node "$HERE/../mock-agent.mjs" --as vibe "$@" diff --git a/mocks/golden/314d6833-0377-4ac4-ba11-2b8d7eca5511.events.json b/mocks/golden/314d6833-0377-4ac4-ba11-2b8d7eca5511.events.json new file mode 100644 index 000000000..88e764715 --- /dev/null +++ b/mocks/golden/314d6833-0377-4ac4-ba11-2b8d7eca5511.events.json @@ -0,0 +1,114 @@ +{ + "agent": "claude", + "trace": "314d6833-0377-4ac4-ba11-2b8d7eca5511", + "events": [ + { + "type": "status", + "label": "initializing", + "model": null, + "sessionId": "<normalized>" + }, + { + "type": "tool_use", + "id": "314d6833-0377-4ac4-ba11-2b8d7eca5511-tool-toolu_bdrk_01W4ChHzyM5SX3pnfNPzvvQe", + "name": "Read", + "input": { + "file_path": "${HOME}/Library/Application Support/Open Design/namespaces/release-stable/data/projects/proj-001/ref-b-tickets-v3.html", + "offset": 100, + "limit": 40 + } + }, + { + "type": "turn_end", + "stopReason": "tool_use" + }, + { + "type": "tool_result", + "toolUseId": "314d6833-0377-4ac4-ba11-2b8d7eca5511-tool-toolu_bdrk_01W4ChHzyM5SX3pnfNPzvvQe", + "content": "100\t .led-grid .led { width: 100%; aspect-ratio: 1 / 1; max-height: 22px; min-height: 12px;\n101\t display: grid; place-items: center; font-size: 8px; font-weight: 700; }\n102\t .led.up { background: rgba(0,255,102,0.18); color: var(--green); border: 1px solid rgba(0,255,102,0.55); box-shadow: inset 0 0 6px rgba(0,255,102,0.35); }\n103\t .led.dn { background: rgba(255,51,68,0.16); color: var(--red); border: 1px solid rgba(255,51,68,0.5); box-shadow: inset 0 0 6px rgba(255,51,68,0.3); }\n104\t .led.flat { background: rgba(112,112,112,0.12); color: var(--muted); border: 1px solid var(--line-strong); }\n105\t .led-grid .net { font-size: 10px; color: var(--fg-strong); font-weight: 700; text-align: right; letter-spacing: 0.04em; }\n106\t .led-grid .net.up { color: var(--green); }\n107\t .led-grid .net.dn { color: var(--red); }\n108\t .led-legend { display: flex; gap: 12px; margin-top: 8px; font-size: 9px; color: var(--muted); letter-spacing: 0.08em; }\n109\t .led-legend .lg { display: flex; align-items: center; gap: 4px; }\n110\t .led-legend i { width: 10px; height: 10px; }\n111\t .led-legend i.up { background: rgba(0,255,102,0.45); border: 1px solid var(--green); }\n112\t .led-legend i.dn { background: rgba(255,51,68,0.4); border: 1px solid var(--red); }\n113\t .led-legend i.flat { background: rgba(112,112,112,0.2); border: 1px solid var(--muted); }\n114\t\n115\t /* MOD 2: Activity log */\n116\t .log-list { font-size: 10px; line-height: 1.5; letter-spacing: 0.02em; }\n117\t .log-row { display: grid; grid-template-columns: 56px 38px 1fr auto; gap: 8px; padding: 4px 0; border-bottom: 1px dotted var(--line); }\n118\t .log-row:last-child { border-bottom: 0; }\n119\t .log-time { color: var(--dim); }\n120\t .log-type { color: var(--amber); font-weight: 700; letter-spacing: 0.06em; }\n121\t .log-type.cl { color: var(--green); }\n122\t .log-type.cr { color: var(--cyan); }\n123\t .log-msg { color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n124\t .log-d { font-weight: 700; }\n125\t .log-d.up { color: var(--green); }\n126\t .log-d.dn { color: var(--red); }\n127\t\n128\t /* MOD 3: ASCII heatmap */\n129\t .heat-wrap { font-family: var(--font-mono); font-size: 10px; line-height: 1.55; color: var(--fg); }\n130\t .heat-wrap .heat-head, .heat-wrap .heat-row { display: grid; grid-template-columns: 36px repeat(4, 1fr); gap: 6px; }\n131\t .heat-wrap .heat-head { color: var(--muted); font-size: 9px; letter-spacing: 0.1em; padding-bottom: 4px; border-bottom: 1px dotted var(--line-strong); margin-bottom: 4px; }\n132\t .heat-wrap .heat-row { padding: 3px 0; }\n133\t .heat-wrap .heat-row .m { color: var(--muted); font-weight: 600; letter-spacing: 0.06em; }\n134\t .heat-cell { padding: 2px 4px; text-align: center; font-weight: 700; letter-spacing: 0.04em; }\n135\t .heat-cell.h0 { background: #0f0f0f; color: var(--dim); }\n136\t .heat-cell.h1 { background: rgba(255,176,0,0.12); color: var(--amber-dim); }\n137\t .heat-cell.h2 { background: rgba(255,176,0,0.28); color: var(--amber); }\n138\t .heat-cell.h3 { background: rgba(255,176,0,0.5); color: #fff; }\n139\t .heat-cell.h4 { background: rgba(255,176,0,0.78); color: #050505; }", + "isError": false + }, + { + "type": "tool_use", + "id": "314d6833-0377-4ac4-ba11-2b8d7eca5511-tool-toolu_bdrk_01SifooERKXmRaxihtaFixFU", + "name": "Grep", + "input": { + "pattern": "kpi-cell::before|kpi-cell\\s*\\{|\\.kpi-strip|\\.kpi-cell", + "path": "${HOME}/Library/Application Support/Open Design/namespaces/release-stable/data/projects/proj-001/ref-b-tickets-v3.html", + "output_mode": "content", + "-n": true + } + }, + { + "type": "turn_end", + "stopReason": "tool_use" + }, + { + "type": "tool_result", + "toolUseId": "314d6833-0377-4ac4-ba11-2b8d7eca5511-tool-toolu_bdrk_01SifooERKXmRaxihtaFixFU", + "content": "55: .kpi-strip { background: var(--bg-2); border-bottom: 1px solid var(--line); display: grid; grid-template-columns: repeat(4, 1fr); }\n56: .kpi-cell { padding: 14px 22px; border-right: 1px solid var(--line); position: relative; }\n57: .kpi-cell:last-child { border-right: 0; }\n58: .kpi-cell::before { content:''; position: absolute; left: 0; top: 12px; bottom: 12px; width: 3px; }\n59: .kpi-cell.cust-create::before { background: var(--cyan); }\n60: .kpi-cell.cust-close::before { background: var(--green); }\n61: .kpi-cell.int-create::before { background: var(--amber); }\n62: .kpi-cell.int-close::before { background: var(--magenta); }", + "isError": false + }, + { + "type": "tool_use", + "id": "314d6833-0377-4ac4-ba11-2b8d7eca5511-tool-toolu_bdrk_0163uAuJyfBhuHWtxXoSwi3N", + "name": "Edit", + "input": { + "file_path": "${HOME}/Library/Application Support/Open Design/namespaces/release-stable/data/projects/proj-001/ref-b-tickets-v3.html", + "old_string": " .kpi-cell::before { content:''; position: absolute; left: 0; top: 12px; bottom: 12px; width: 3px; }", + "new_string": " .kpi-cell::before { content:''; position: absolute; left: 0; top: 14px; bottom: 14px; width: 3px; }" + } + }, + { + "type": "turn_end", + "stopReason": "tool_use" + }, + { + "type": "tool_result", + "toolUseId": "314d6833-0377-4ac4-ba11-2b8d7eca5511-tool-toolu_bdrk_0163uAuJyfBhuHWtxXoSwi3N", + "content": "The file ${HOME}/Library/Application Support/Open Design/namespaces/release-stable/data/projects/proj-001/ref-b-tickets-v3.html has been updated successfully. (file state is current in your context — no need to Read it back)", + "isError": false + }, + { + "type": "tool_use", + "id": "314d6833-0377-4ac4-ba11-2b8d7eca5511-tool-toolu_bdrk_01QdvPxPh8Qk9Ch4bgAgGZxT", + "name": "Grep", + "input": { + "-n": true, + "output_mode": "content", + "path": "${HOME}/Library/Application Support/Open Design/namespaces/release-stable/data/projects/proj-001/ref-b-tickets-v3.html", + "pattern": "topbar|\\.bar\\s" + } + }, + { + "type": "turn_end", + "stopReason": "tool_use" + }, + { + "type": "tool_result", + "toolUseId": "314d6833-0377-4ac4-ba11-2b8d7eca5511-tool-toolu_bdrk_01QdvPxPh8Qk9Ch4bgAgGZxT", + "content": "40: /* topbar */\n41: .topbar { background: var(--bg-2); border-bottom: 1px solid var(--line); display: grid;\n44: .topbar .lbl { color: var(--muted); }\n45: .topbar .v { color: var(--amber); }\n46: .topbar .v.up { color: var(--green); }\n47: .topbar .v.dn { color: var(--red); }\n48: .topbar .sep { color: var(--dim); }\n214: <header class=\"topbar\">", + "isError": false + }, + { + "type": "text_delta", + "delta": "让我看一下 KPI 色条的当前样式定位。让我找 KPI 色条样式的定义。找到了。当前 `top: 12px; bottom: 12px` — KPI cell 高度是 78px(来自 stage grid `32px 78px 1fr 44px` 的第二行),padding 是 `14px 22px`。\n\n色条 `top:12 / bottom:12` 意味着色条上下各留 12px,色条本身高度 = 78 - 12 - 12 = 54px。\n\n但 KPI cell 的 padding 是 `14px 22px`(上下 14px),意味着内容区从 14px 开始。色条 `top:12px` 比内容区上沿(14px)高 2px,bottom 同理。这看起来挺合理啊...\n\n等等,可能是用户感觉\"统一往下偏移几像素\"是因为色条上端比 KPI 数字(在 padding 14px 后开始)更高 2px,但下端比 KPI 文字下边缘更深 — 视觉上整条色条的中点跟内容的中点错开了。\n\n让我让色条跟 KPI 内容(padding 14 上下)完全对齐:`top:14px / bottom:14px`。改完。色条上下从 12px 改成 14px,跟 KPI cell 的 padding(14px)完全对齐 — 现在色条的上下端正好齐内容区的上下沿,不再有 2px 错位。\n\n但你说\"统一往下偏移几个像素\" — 还有一种可能是色条整体被往下推了。让我再看一下是不是有别的样式干扰。topbar 32px 高度正常。color bar 现在 `top:14 / bottom:14` 应该对齐到 KPI 内容区的上下沿了。\n\n刷新 ref-b-tickets-v3.html 看看现在对齐了没?如果还是觉得偏移,告诉我具体是色条整体往下移、还是上下不对称,我再精调。" + }, + { + "type": "turn_end", + "stopReason": "end_turn" + }, + { + "type": "usage", + "usage": { + "input_tokens": 0, + "output_tokens": 1594, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0 + }, + "costUsd": 0, + "durationMs": 40885, + "stopReason": "end_turn" + } + ] +} diff --git a/mocks/golden/9a9522ec-575f-432f-aeed-efc491e900aa.events.json b/mocks/golden/9a9522ec-575f-432f-aeed-efc491e900aa.events.json new file mode 100644 index 000000000..df8bc6834 --- /dev/null +++ b/mocks/golden/9a9522ec-575f-432f-aeed-efc491e900aa.events.json @@ -0,0 +1,187 @@ +{ + "agent": "opencode", + "trace": "9a9522ec-575f-432f-aeed-efc491e900aa", + "events": [ + { + "type": "status", + "label": "running" + }, + { + "type": "tool_use", + "id": "9a9522ec-575f-432f-aeed-efc491e900aa-tool-call_00_9F86gxR0AA5Ea5dwfJQA0062", + "name": "read", + "input": { + "filePath": "%USERPROFILE%\\AppData\\Roaming\\Open Design\\namespaces\\release-stable-win\\data\\projects\\proj-001\\evidguard-pitch-deck.html" + } + }, + { + "type": "tool_result", + "toolUseId": "9a9522ec-575f-432f-aeed-efc491e900aa-tool-call_00_9F86gxR0AA5Ea5dwfJQA0062", + "content": "<path>%USERPROFILE%\\AppData\\Roaming\\Open Design\\namespaces\\release-stable-win\\data\\projects\\proj-001\\evidguard-pitch-deck.html</path>\n<type>file</type>\n<content>\n1: \n2: <!doctype html>\n3: <html lang=\"es\">\n4: <head>\n5: <meta charset=\"utf-8\" />\n6: <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n7: <title>EvidGuard · Pitch deck\n8: \n\n\n
\n
\n
\n
QueueCare
\n

Doctor queues without appointment slots.

\n

Patients choose a live doctor queue, receive a token, track how many people are ahead, and check in only when it is worth leaving.

\n
\n
\n
Patient firstThe core promise is a held queue position, not a fixed time that clinics cannot honor.
\n
Live queue stateEvery doctor card carries patients ahead, expected call, token availability, and clinic notes.
\n
Native adaptationiOS uses compact tab navigation; Android uses Material-style top actions, bottom nav, and a floating queue shortcut.
\n
\n
\n\n
\n
\n

iOS flow

\n

Discovery, doctor queue detail, and active token are separate app screens inside the iPhone frame.

\n
\n
\n
\n

Find queues

Open
\n \n
\n
\n

Join queue

Open
\n \n
\n
\n

Track token

Open
\n \n
\n
\n
\n\n
\n
\n

Android flow

\n

The same product adapts to Android density, action placement, and Material-style controls.

\n
\n
\n
\n

Find queues

Open
\n \n
\n
\n

Join queue

Open
\n \n
\n
\n

Track token

Open
\n \n
\n
\n
\n
\n\n\n" + }, + { + "type": "usage", + "usage": { + "input_tokens": 0, + "output_tokens": 1982076, + "cached_read_tokens": 0 + } + } + ] +} diff --git a/mocks/lib/format-acp.mjs b/mocks/lib/format-acp.mjs new file mode 100644 index 000000000..ca590608b --- /dev/null +++ b/mocks/lib/format-acp.mjs @@ -0,0 +1,204 @@ +// OD-faithful ACP (agent-client-protocol) renderer. +// +// Unlike the streaming formats (opencode/codex/claude/gemini/cursor-agent), +// ACP is a real JSON-RPC server over stdio. The mock has to: +// +// 1. Listen on stdin for newline-delimited JSON-RPC messages from OD. +// 2. Respond to `initialize` (id=1) with the protocol version. +// 3. Respond to `session/new` (id=2) with a synthetic sessionId. +// 4. Optionally respond to `session/set_model` (id=3) with {}. +// 5. When `session/prompt` (id=N) arrives, push a series of +// `session/update` NOTIFICATIONS carrying agent_message_chunk +// content from the recording, then respond to the prompt request +// with a final usage stats result. +// 6. Stay alive until OD closes stdin or the request completes. +// +// Verified against the spec implementation at +// apps/daemon/src/acp.ts (attachAcpSession + ACP_PROTOCOL_VERSION). +// +// Tool events from the recording are dropped — OD's ACP parser only +// recognizes thought_chunk + message_chunk. Tool-use surfaces in the +// real ACP agents (Hermes/Kimi/Kilo/Kiro/Vibe/Devin) come through +// MCP or other side channels, not the stdio protocol. + +import { writeFile } from 'node:fs/promises'; + +const PROTOCOL_VERSION = 1; +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +function writeRpcResult(out, id, result) { + out.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n'); +} + +function writeNotification(out, method, params) { + out.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n'); +} + +/** Split a long text into approximately N-char chunks at word boundaries + * so OD's UI sees a streamed message rather than one giant burst. */ +function chunkText(text, targetChunkSize = 400) { + if (text.length <= targetChunkSize) return [text]; + const chunks = []; + let pos = 0; + while (pos < text.length) { + let end = Math.min(text.length, pos + targetChunkSize); + if (end < text.length) { + // Round to the next whitespace so we don't split mid-word + const ws = text.indexOf(' ', end); + if (ws !== -1 && ws - end < 200) end = ws + 1; + } + chunks.push(text.slice(pos, end)); + pos = end; + } + return chunks; +} + +/** + * Run the ACP server on stdin/stdout. Returns a promise that resolves + * when the prompt round-trip completes. + */ +export async function runAcpServer(events, opts = {}) { + const out = opts.emit ?? process.stdout; + const writeFn = typeof out === 'function' ? out : s => out.write(s); + const writeLine = obj => writeFn(JSON.stringify(obj) + '\n'); + + const meta = events.find(e => e.type === 'meta'); + const sessionId = opts.sessionId ?? `mock-acp-${Date.now()}`; + const reportEvent = events.find(e => e.type === 'report'); + const reportText = reportEvent?.content ?? ''; + + // Collect any thought_chunks from tool_call inputs that look like + // "I should..." style reasoning. For our recordings, the recording + // doesn't separate thinking from tool calls — so we just stream the + // final report as message chunks. Future enhancement could split out + // thinking-style text. + void meta; + + // Buffer for incoming JSON-RPC lines. + let buf = ''; + let aborted = false; + + // Track which request we owe a response to. + let lastPromptId = null; + + // Chunks generator — used after session/prompt arrives. + const startPromptResponse = async (id) => { + lastPromptId = id; + const chunks = chunkText(reportText, 400); + for (const chunk of chunks) { + if (aborted) break; + if (!opts.noDelay) await sleep(50); + writeLine({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: chunk }, + }, + }, + }); + } + // Final prompt response carries usage stats. ACP parser reads: + // formatUsage({inputTokens, outputTokens, cachedReadTokens, totalTokens}) + writeRpcResult(out, id, { + stopReason: 'end_of_turn', + usage: { + inputTokens: 0, + outputTokens: meta?.total_tokens ?? 0, + cachedReadTokens: 0, + totalTokens: meta?.total_tokens ?? 0, + }, + }); + + if (opts.reportFile) await writeFile(opts.reportFile, reportText).catch(() => {}); + }; + + // Dispatch incoming RPC messages. + const handleRpc = async (obj) => { + if (!obj || typeof obj !== 'object') return; + const { id, method } = obj; + if (method === 'initialize') { + writeRpcResult(out, id, { + protocolVersion: PROTOCOL_VERSION, + capabilities: { + loadSession: false, + // Tool calls aren't supported via stdio in this mock — + // matches the actual ACP agents on OD's side. + tools: false, + }, + }); + return; + } + if (method === 'session/new') { + writeRpcResult(out, id, { + sessionId, + configOptions: [ + { + configId: 'model', + category: 'model', + currentValue: meta?.model ?? 'default', + values: [meta?.model ?? 'default'], + }, + ], + }); + return; + } + if (method === 'session/set_model' || method === 'session/set_config_option') { + writeRpcResult(out, id, {}); + return; + } + if (method === 'session/prompt') { + // Fire-and-forget the chunk streaming; the prompt request gets + // responded to once the last chunk has been streamed. + void startPromptResponse(id); + return; + } + if (method === 'session/cancel') { + aborted = true; + // OD doesn't expect a response to cancel notifications. + return; + } + // Any other method: respond with an empty result to keep the parser happy. + if (id !== undefined && id !== null) { + writeRpcResult(out, id, {}); + } + }; + + // Promise that resolves when the prompt response has been written. + return new Promise((resolve) => { + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', async (data) => { + buf += data; + let nl; + while ((nl = buf.indexOf('\n')) !== -1) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (!line) continue; + let obj; + try { obj = JSON.parse(line); } catch { continue; } + await handleRpc(obj); + } + }); + process.stdin.on('end', () => { + // OD closed stdin after our prompt response — graceful shutdown. + resolve(); + }); + process.stdin.on('error', () => resolve()); + + const onSigint = () => { aborted = true; resolve(); }; + process.once('SIGINT', onSigint); + process.once('SIGTERM', onSigint); + + // Safety timeout — if OD never sends `initialize` within 30s, + // bail out so we don't hang in CI. .unref() lets the process exit + // promptly once the prompt round-trip completes normally. + setTimeout(() => { + if (lastPromptId === null) { + process.stderr.write('[mock-acp] no prompt received within 30s; exiting\n'); + resolve(); + } + }, 30_000).unref(); + }); +} diff --git a/mocks/lib/format-claude.mjs b/mocks/lib/format-claude.mjs new file mode 100644 index 000000000..0ae0353f3 --- /dev/null +++ b/mocks/lib/format-claude.mjs @@ -0,0 +1,92 @@ +// OD-faithful claude-stream-json renderer. Matches OD's +// `claude-stream.ts:createClaudeStreamHandler` parser. +// +// Each tool call lives in its own assistant message wrapper (the +// "finalized blocks" path — simpler than stream_event deltas, identical +// semantics). + +import { writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +export async function renderAsClaude(events, opts = {}) { + const emit = opts.emit ?? (s => process.stdout.write(s)); + const maxSleep = opts.maxSleepMs ?? 3000; + const meta = events.find(e => e.type === 'meta'); + const sessionId = opts.sessionId ?? randomUUID(); + + emit(JSON.stringify({ + type: 'system', + subtype: 'init', + model: meta?.model ?? null, + session_id: sessionId, + }) + '\n'); + + const results = new Map(); + for (const e of events) if (e.type === 'tool_result') results.set(e.obs_id, e); + + let lastT = 0; + for (const e of events) { + if (e.type === 'meta' || e.type === 'stdout' || e.type === 'tool_result') continue; + const t = typeof e.t_ms === 'number' ? e.t_ms : undefined; + if (!opts.noDelay && t !== undefined) { + const delta = Math.min(maxSleep, Math.max(0, t - lastT)); + if (delta > 0) await sleep(delta); + lastT = t; + } + if (e.type === 'tool_call') { + const result = results.get(e.obs_id); + const messageId = `msg_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + emit(JSON.stringify({ + type: 'assistant', + message: { + id: messageId, + role: 'assistant', + content: [{ + type: 'tool_use', id: e.obs_id, name: e.name, input: e.input ?? {}, + }], + stop_reason: 'tool_use', + }, + }) + '\n'); + emit(JSON.stringify({ + type: 'user', + message: { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: e.obs_id, + content: result?.output ?? '', + is_error: result?.status === 'error', + }], + }, + }) + '\n'); + } else if (e.type === 'report') { + const messageId = `msg_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + emit(JSON.stringify({ + type: 'assistant', + message: { + id: messageId, + role: 'assistant', + content: [{ type: 'text', text: e.content }], + stop_reason: 'end_turn', + }, + }) + '\n'); + if (opts.reportFile) await writeFile(opts.reportFile, e.content).catch(() => {}); + } + } + + emit(JSON.stringify({ + type: 'result', + subtype: 'success', + usage: { + input_tokens: 0, + output_tokens: meta?.total_tokens ?? 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + total_cost_usd: 0, + duration_ms: meta?.duration_ms ?? 0, + stop_reason: 'end_turn', + }) + '\n'); +} diff --git a/mocks/lib/format-codex.mjs b/mocks/lib/format-codex.mjs new file mode 100644 index 000000000..09362087c --- /dev/null +++ b/mocks/lib/format-codex.mjs @@ -0,0 +1,92 @@ +// OD-faithful codex renderer. Emits the shape OD's +// `json-event-stream.ts:handleCodexEvent` parser accepts. +// Codex parser only knows command_execution (Bash) + agent_message — +// non-Bash tools are translated to a shell-equivalent command. + +import { writeFile } from 'node:fs/promises'; + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +function commandFor(name, input) { + if (!input || typeof input !== 'object') return `# ${name}`; + const o = input; + switch (name) { + case 'Bash': + return typeof o.command === 'string' ? o.command : '# Bash'; + case 'Read': { + const fp = typeof o.file_path === 'string' ? o.file_path : ''; + const o2 = typeof o.offset === 'number' ? ` # offset=${o.offset}` : ''; + const lim = typeof o.limit === 'number' ? ` limit=${o.limit}` : ''; + return `cat "${fp}"${o2}${lim}`; + } + case 'Grep': { + const pat = typeof o.pattern === 'string' ? o.pattern : ''; + const path = typeof o.path === 'string' ? o.path : '.'; + return `grep -n "${pat.replace(/"/g, '\\"')}" "${path}"`; + } + case 'Glob': { + const pat = typeof o.pattern === 'string' ? o.pattern : ''; + return `find . -path "${pat}" -print`; + } + case 'Edit': + case 'Write': + return `# ${name} ${typeof o.file_path === 'string' ? o.file_path : ''}`; + default: + return `# ${name} ${JSON.stringify(o).slice(0, 200)}`; + } +} + +export async function renderAsCodex(events, opts = {}) { + const emit = opts.emit ?? (s => process.stdout.write(s)); + const maxSleep = opts.maxSleepMs ?? 3000; + const meta = events.find(e => e.type === 'meta'); + + const results = new Map(); + for (const e of events) if (e.type === 'tool_result') results.set(e.obs_id, e); + + emit(JSON.stringify({ type: 'thread.started' }) + '\n'); + emit(JSON.stringify({ type: 'turn.started' }) + '\n'); + + let lastT = 0; + let agentMessageCount = 0; + for (const e of events) { + if (e.type === 'meta' || e.type === 'stdout' || e.type === 'tool_result') continue; + const t = typeof e.t_ms === 'number' ? e.t_ms : undefined; + if (!opts.noDelay && t !== undefined) { + const delta = Math.min(maxSleep, Math.max(0, t - lastT)); + if (delta > 0) await sleep(delta); + lastT = t; + } + if (e.type === 'tool_call') { + const result = results.get(e.obs_id); + const command = commandFor(e.name, e.input); + emit(JSON.stringify({ + type: 'item.started', + item: { type: 'command_execution', id: e.obs_id, command }, + }) + '\n'); + emit(JSON.stringify({ + type: 'item.completed', + item: { + type: 'command_execution', + id: e.obs_id, + command, + aggregated_output: result?.output ?? '', + exit_code: result?.status === 'error' ? 1 : 0, + status: result?.status === 'error' ? 'failed' : 'completed', + }, + }) + '\n'); + } else if (e.type === 'report') { + agentMessageCount += 1; + emit(JSON.stringify({ + type: 'item.completed', + item: { type: 'agent_message', id: `mock-msg-${agentMessageCount}`, text: e.content }, + }) + '\n'); + if (opts.reportFile) await writeFile(opts.reportFile, e.content).catch(() => {}); + } + } + + emit(JSON.stringify({ + type: 'turn.completed', + usage: { input_tokens: 0, output_tokens: meta?.total_tokens ?? 0, cached_input_tokens: 0 }, + }) + '\n'); +} diff --git a/mocks/lib/format-cursor-agent.mjs b/mocks/lib/format-cursor-agent.mjs new file mode 100644 index 000000000..1a110bf30 --- /dev/null +++ b/mocks/lib/format-cursor-agent.mjs @@ -0,0 +1,63 @@ +// OD-faithful cursor-agent renderer. +// +// Matches the JSONL shape OD's `json-event-stream.ts:handleCursorEvent` +// parser accepts: +// {"type":"system","subtype":"init","model":"..."} +// {"type":"assistant","message":{ +// "content":[{"type":"text","text":"..."}, ...]}, +// "timestamp_ms": ...} +// {"type":"result","usage":{"inputTokens","outputTokens","cacheReadTokens","cacheWriteTokens"}, +// "duration_ms": ...} +// +// Cursor's parser handles delta-vs-replacement detection itself: when a +// later text block STARTS WITH the prior accumulated text, the parser +// strips the prefix and emits only the delta. So we can either emit a +// single message containing the full text, OR emit progressive chunks +// where each chunk is the cumulative-so-far. We use the single-message +// form — simplest, no risk of state desync. +// +// Like gemini, the cursor-agent parser does NOT recognize tool events. +// Tool calls in the recording are ignored; only the final assistant +// text is rendered. + +import { writeFile } from 'node:fs/promises'; + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +export async function renderAsCursorAgent(events, opts = {}) { + const emit = opts.emit ?? (s => process.stdout.write(s)); + const maxSleep = opts.maxSleepMs ?? 2000; + const meta = events.find(e => e.type === 'meta'); + + emit(JSON.stringify({ + type: 'system', + subtype: 'init', + model: meta?.model ?? 'cursor-default', + }) + '\n'); + + if (!opts.noDelay) await sleep(Math.min(maxSleep, 200)); + + for (const e of events) { + if (e.type === 'report') { + emit(JSON.stringify({ + type: 'assistant', + message: { + content: [{ type: 'text', text: e.content }], + }, + timestamp_ms: Date.now(), + }) + '\n'); + if (opts.reportFile) await writeFile(opts.reportFile, e.content).catch(() => {}); + } + } + + emit(JSON.stringify({ + type: 'result', + usage: { + inputTokens: 0, + outputTokens: meta?.total_tokens ?? 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + duration_ms: meta?.duration_ms ?? 0, + }) + '\n'); +} diff --git a/mocks/lib/format-gemini.mjs b/mocks/lib/format-gemini.mjs new file mode 100644 index 000000000..b498a9cac --- /dev/null +++ b/mocks/lib/format-gemini.mjs @@ -0,0 +1,53 @@ +// OD-faithful gemini renderer. +// +// Matches the JSONL shape OD's `json-event-stream.ts:handleGeminiEvent` +// parser accepts. The parser only recognizes THREE event types: +// {"type":"init","model":"..."} → status:initializing +// {"type":"message","role":"assistant","content":"…"} → text_delta +// {"type":"result","stats":{...}} → usage +// +// Notably ABSENT: any tool-call event shape. OD's gemini surface doesn't +// render tool calls in the UI — they're stripped at the parser layer. +// So our renderer only emits the final assistant text wrapped in the +// init/message/result envelope. Tool calls in the recording are ignored. + +import { writeFile } from 'node:fs/promises'; + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +export async function renderAsGemini(events, opts = {}) { + const emit = opts.emit ?? (s => process.stdout.write(s)); + const maxSleep = opts.maxSleepMs ?? 2000; + const meta = events.find(e => e.type === 'meta'); + + emit(JSON.stringify({ + type: 'init', + model: meta?.model ?? 'gemini-2.5-pro', + }) + '\n'); + + // Stream the report text as one assistant message. Optionally we could + // chunk by token-count for a more "live streaming" feel — but OD's + // gemini parser accepts multi-chunk too (each emits as text_delta). + if (!opts.noDelay) await sleep(Math.min(maxSleep, 200)); + for (const e of events) { + if (e.type === 'report') { + emit(JSON.stringify({ + type: 'message', + role: 'assistant', + content: e.content, + }) + '\n'); + if (opts.reportFile) await writeFile(opts.reportFile, e.content).catch(() => {}); + } + } + + // Final stats wrapper. + emit(JSON.stringify({ + type: 'result', + stats: { + input_tokens: 0, + output_tokens: meta?.total_tokens ?? 0, + cached: 0, + duration_ms: meta?.duration_ms ?? 0, + }, + }) + '\n'); +} diff --git a/mocks/lib/format-opencode.mjs b/mocks/lib/format-opencode.mjs new file mode 100644 index 000000000..2672ac11b --- /dev/null +++ b/mocks/lib/format-opencode.mjs @@ -0,0 +1,60 @@ +// OD-faithful opencode renderer. Emits the JSONL shape OD's +// `json-event-stream.ts:handleOpenCodeEvent` parser accepts. + +import { writeFile } from 'node:fs/promises'; + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +export async function renderAsOpencode(events, opts = {}) { + const emit = opts.emit ?? (s => process.stdout.write(s)); + const maxSleep = opts.maxSleepMs ?? 3000; + const sessionId = opts.sessionId ?? `mock-${Date.now()}`; + const meta = events.find(e => e.type === 'meta'); + + const results = new Map(); + for (const e of events) if (e.type === 'tool_result') results.set(e.obs_id, e); + + emit(JSON.stringify({ type: 'step_start' }) + '\n'); + + let lastT = 0; + for (const e of events) { + if (e.type === 'meta' || e.type === 'stdout' || e.type === 'tool_result') continue; + const t = typeof e.t_ms === 'number' ? e.t_ms : undefined; + if (!opts.noDelay && t !== undefined) { + const delta = Math.min(maxSleep, Math.max(0, t - lastT)); + if (delta > 0) await sleep(delta); + lastT = t; + } + if (e.type === 'tool_call') { + const result = results.get(e.obs_id); + const isErr = result?.status === 'error'; + emit(JSON.stringify({ + type: 'tool_use', + sessionID: sessionId, + part: { + tool: e.name, + callID: e.obs_id, + state: { + input: e.input ?? null, + status: isErr ? 'failed' : 'completed', + output: result?.output ?? '', + }, + }, + }) + '\n'); + } else if (e.type === 'report') { + emit(JSON.stringify({ + type: 'text', + part: { text: e.content }, + }) + '\n'); + if (opts.reportFile) await writeFile(opts.reportFile, e.content).catch(() => {}); + } + } + + emit(JSON.stringify({ + type: 'step_finish', + part: { + tokens: { input: 0, output: meta?.total_tokens ?? 0, reasoning: 0, cache: { read: 0, write: 0 } }, + cost: 0, + }, + }) + '\n'); +} diff --git a/mocks/lib/format-plain.mjs b/mocks/lib/format-plain.mjs new file mode 100644 index 000000000..ddc548601 --- /dev/null +++ b/mocks/lib/format-plain.mjs @@ -0,0 +1,40 @@ +// Plain (raw stdout) renderer. OD's `plain` streamFormat (deepseek / +// qwen / grok / grok-build) treats stdout as the final assistant +// response. We emit ONLY the report content. + +import { writeFile } from 'node:fs/promises'; + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +export async function renderAsPlain(events, opts = {}) { + const emit = opts.emit ?? (s => process.stdout.write(s)); + const maxSleep = opts.maxSleepMs ?? 2000; + + if (opts.includeToolTrace) { + const tools = events.filter(e => e.type === 'tool_call'); + if (tools.length > 0) { + emit(`\n`); + } + } + + for (const e of events) { + if (e.type === 'report') { + if (!opts.noDelay) await sleep(Math.min(maxSleep, 200)); + emit(e.content); + if (!e.content.endsWith('\n')) emit('\n'); + if (opts.reportFile) await writeFile(opts.reportFile, e.content).catch(() => {}); + } + } +} diff --git a/mocks/lib/format-vela.mjs b/mocks/lib/format-vela.mjs new file mode 100644 index 000000000..95bccc1db --- /dev/null +++ b/mocks/lib/format-vela.mjs @@ -0,0 +1,224 @@ +// OD-faithful vela renderer for AMR (Agent Management Runtime). +// +// vela is the bin OD's AMR runtime spawns; `bin: 'vela'` in +// apps/daemon/src/runtimes/defs/amr.ts. The protocol is ACP JSON-RPC +// over stdio, but vela's spec extends the generic ACP shape with: +// +// 1. `initialize` response carries `agentCapabilities` and `models` +// (currentModelId + availableModels). +// 2. `session/new` response carries the same `models` block. +// 3. `session/prompt` is REJECTED unless `session/set_model` (or +// `session/set_config_option`) has been called for the current +// sessionId — the strict-set_model gate. This is the contract real +// vela 0.0.1 enforces; we mirror it so a regression in +// attachAcpSession that silently skips set_model gets caught. +// 4. Final prompt response includes `usage.{inputTokens, outputTokens, +// totalTokens}` (no cachedReadTokens — vela doesn't surface those). +// +// Error-injection envs (mirror apps/daemon/tests/fixtures/fake-vela.mjs): +// FAKE_VELA_SESSION_NEW_ERROR — fail session/new with this message +// FAKE_VELA_SET_MODEL_ERROR — fail session/set_model +// FAKE_VELA_PROMPT_ERROR — fail session/prompt +// FAKE_VELA_REQUIRE_SET_MODEL='0' — disable the strict gate (legacy) +// +// Content envs: +// FAKE_VELA_SESSION_ID — the sessionId returned by session/new +// FAKE_VELA_TEXT — override the assistant text (defaults to recording's +// report content) +// FAKE_VELA_THOUGHT — optional thought_chunk emitted before text + +import { writeFile } from 'node:fs/promises'; + +const PROTOCOL_VERSION = 1; +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +const DEFAULT_AVAILABLE_MODELS = [ + { modelId: 'openai/gpt-5.4-mini', name: 'gpt-5.4-mini' }, + { modelId: 'anthropic/claude-3.7-sonnet', name: 'claude-3.7-sonnet' }, + { modelId: 'deepseek-v3.2', name: 'deepseek-v3.2' }, + { modelId: 'glm-5.1', name: 'glm-5.1' }, +]; + +function writeRpcResult(out, id, result) { + out.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n'); +} + +function writeRpcError(out, id, message, code = -32603) { + out.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n'); +} + +function writeNotification(out, method, params) { + out.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n'); +} + +/** Split long text on word boundaries — chunk size mirrors the real vela + * agent server's typical streaming cadence (the existing fake-vela.mjs + * uses 16-char chunks; we use 400 to match the higher-throughput + * recordings without flooding the test harness). */ +function chunkText(text, target = 400) { + if (text.length <= target) return [text]; + const chunks = []; + let pos = 0; + while (pos < text.length) { + let end = Math.min(text.length, pos + target); + if (end < text.length) { + const ws = text.indexOf(' ', end); + if (ws !== -1 && ws - end < 200) end = ws + 1; + } + chunks.push(text.slice(pos, end)); + pos = end; + } + return chunks; +} + +/** + * Run the vela ACP server on stdin/stdout. Returns a promise that + * resolves when stdin closes or the prompt round-trip completes. + */ +export async function runVelaAcpServer(events, opts = {}) { + const out = opts.emit ?? process.stdout; + const meta = events.find(e => e.type === 'meta'); + const reportEvent = events.find(e => e.type === 'report'); + const reportText = process.env.FAKE_VELA_TEXT ?? reportEvent?.content ?? ''; + const thoughtText = process.env.FAKE_VELA_THOUGHT ?? ''; + + const sessionId = opts.sessionId ?? process.env.FAKE_VELA_SESSION_ID ?? `fake-vela-session-${Date.now()}`; + const strict = process.env.FAKE_VELA_REQUIRE_SET_MODEL !== '0'; + const SESSION_NEW_ERROR = process.env.FAKE_VELA_SESSION_NEW_ERROR ?? ''; + const SET_MODEL_ERROR = process.env.FAKE_VELA_SET_MODEL_ERROR ?? ''; + const PROMPT_ERROR = process.env.FAKE_VELA_PROMPT_ERROR ?? ''; + + let currentModelId = null; + const sessionsWithModel = new Set(); + let buf = ''; + let aborted = false; + + const startPromptResponse = async (id, sid) => { + if (thoughtText) { + writeNotification(out, 'session/update', { + sessionId: sid, + update: { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: thoughtText }, + }, + }); + } + const chunks = chunkText(reportText, 400); + for (const chunk of chunks) { + if (aborted) break; + if (!opts.noDelay) await sleep(30); + writeNotification(out, 'session/update', { + sessionId: sid, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: chunk }, + }, + }); + } + writeRpcResult(out, id, { + stopReason: 'end_turn', + usage: { + inputTokens: 12, + outputTokens: meta?.total_tokens ?? 7, + totalTokens: (meta?.total_tokens ?? 7) + 12, + }, + }); + if (opts.reportFile) await writeFile(opts.reportFile, reportText).catch(() => {}); + }; + + const handleRpc = async (obj) => { + if (!obj || typeof obj !== 'object') return; + const { id, method, params } = obj; + switch (method) { + case 'initialize': + writeRpcResult(out, id, { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { promptCapabilities: { embeddedContext: false } }, + models: { + currentModelId, + availableModels: DEFAULT_AVAILABLE_MODELS, + }, + }); + return; + case 'session/new': { + if (SESSION_NEW_ERROR) { + writeRpcError(out, id, SESSION_NEW_ERROR); + return; + } + writeRpcResult(out, id, { + sessionId, + models: { currentModelId, availableModels: DEFAULT_AVAILABLE_MODELS }, + }); + return; + } + case 'session/set_model': { + if (SET_MODEL_ERROR) { + writeRpcError(out, id, SET_MODEL_ERROR, -32099); + return; + } + const next = typeof params?.modelId === 'string' ? params.modelId.trim() : ''; + const sid = typeof params?.sessionId === 'string' ? params.sessionId : sessionId; + if (next) currentModelId = next; + sessionsWithModel.add(sid); + writeRpcResult(out, id, {}); + return; + } + case 'session/set_config_option': { + // Treat config-option model selection as set_model for the + // strict-set_model gate. + const sid = typeof params?.sessionId === 'string' ? params.sessionId : sessionId; + sessionsWithModel.add(sid); + writeRpcResult(out, id, {}); + return; + } + case 'session/prompt': { + if (PROMPT_ERROR) { + writeRpcError(out, id, PROMPT_ERROR, -32602); + return; + } + const sid = typeof params?.sessionId === 'string' ? params.sessionId : sessionId; + if (strict && !sessionsWithModel.has(sid)) { + writeRpcError(out, id, 'session/set_model must be called before session/prompt', -32602); + return; + } + void startPromptResponse(id, sid); + return; + } + case 'session/cancel': + aborted = true; + return; + default: + if (id !== undefined && id !== null) { + writeRpcError(out, id, `unknown method: ${method}`, -32601); + } + } + }; + + return new Promise((resolve) => { + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', async (data) => { + buf += data; + let nl; + while ((nl = buf.indexOf('\n')) !== -1) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (!line) continue; + let obj; + try { obj = JSON.parse(line); } catch { continue; } + await handleRpc(obj); + } + }); + process.stdin.on('end', () => resolve()); + process.stdin.on('error', () => resolve()); + + const onSig = () => { aborted = true; resolve(); }; + process.once('SIGINT', onSig); + process.once('SIGTERM', onSig); + + // 30s safety timeout; .unref() so a completed session exits promptly. + setTimeout(() => { + process.stderr.write('[mock-vela] no prompt received within 30s; exiting\n'); + resolve(); + }, 30_000).unref(); + }); +} diff --git a/mocks/lib/recording-picker.mjs b/mocks/lib/recording-picker.mjs new file mode 100644 index 000000000..324141b80 --- /dev/null +++ b/mocks/lib/recording-picker.mjs @@ -0,0 +1,128 @@ +// Pick which recording to play back, driven by env vars. +// +// Priority order: +// 1. OD_MOCKS_TRACE → fixed trace id (or prefix) +// 2. OD_MOCKS_BY_PROMPT_HASH=1 → hash(prompt) → trace +// 3. OD_MOCKS_POOL= → random within tag pool +// 4. (default) → random across all +// +// OD_MOCKS_SEED gives reproducible "random" selection. +// OD_MOCKS_RECORDINGS_DIR overrides the default recordings dir +// (defaults to ../recordings/ relative to this file). + +import { readdir, readFile, stat } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +export const DEFAULT_RECORDINGS_DIR = + process.env.OD_MOCKS_RECORDINGS_DIR || + join(HERE, '..', 'recordings'); + +async function listRecordings(dir) { + try { + return (await readdir(dir)) + .filter(f => f.endsWith('.jsonl')) + .map(f => f.replace(/\.jsonl$/, '')) + .sort(); + } catch { + return []; + } +} + +async function readMeta(dir, traceId) { + try { + const txt = await readFile(join(dir, `${traceId}.jsonl`), 'utf-8'); + const firstLine = txt.split('\n', 1)[0]; + return JSON.parse(firstLine); + } catch { + return null; + } +} + +function pickRandom(arr, seed) { + if (arr.length === 0) return null; + if (!seed) return arr[Math.floor(Math.random() * arr.length)]; + const h = parseInt(createHash('sha256').update(String(seed)).digest('hex').slice(0, 12), 16); + return arr[h % arr.length]; +} + +export async function pickRecording({ prompt } = {}) { + const dir = DEFAULT_RECORDINGS_DIR; + const all = await listRecordings(dir); + if (all.length === 0) return null; + + // 1. fixed — if the env is set, refuse to fall through to random / pool / + // hash selection. A typo in `OD_MOCKS_TRACE` should surface loudly, + // not silently produce a different trace and silently poison a test. + const fixed = process.env.OD_MOCKS_TRACE; + if (fixed) { + const hit = all.find(id => id === fixed) ?? all.find(id => id.startsWith(fixed)); + if (hit) return { traceId: hit, path: join(dir, `${hit}.jsonl`), method: 'fixed' }; + throw new Error( + `OD_MOCKS_TRACE="${fixed}" set but no matching recording in ${dir}. ` + + `8-char id prefix is supported; check spelling and that the corpus has been fetched ` + + `(\`bash mocks/scripts/fetch-recordings.sh\`).` + ); + } + + // 2. prompt-hash + if (process.env.OD_MOCKS_BY_PROMPT_HASH === '1' && prompt) { + const picked = pickRandom(all, prompt); + if (picked) return { traceId: picked, path: join(dir, `${picked}.jsonl`), method: 'hash' }; + } + + // 3. pool by tag — supports structured `:` shortcuts + // documented in README (agent:claude, skill:agent-browser, + // outcome:failed). The dimension routes to the right meta field; + // bare values fall back to tag substring match. Mirrors the + // OD_MOCKS_TRACE policy: if the env is set and matches nothing, + // refuse to fall through to global random — surface the typo. + const pool = process.env.OD_MOCKS_POOL; + if (pool) { + const colonIdx = pool.indexOf(':'); + const dim = colonIdx >= 0 ? pool.slice(0, colonIdx) : null; + const value = colonIdx >= 0 ? pool.slice(colonIdx + 1) : null; + + const candidates = []; + for (const id of all) { + const meta = await readMeta(dir, id); + if (!meta) continue; + const tags = meta.tags ?? []; + + let match = false; + if (dim === 'outcome' && meta.outcome === value) match = true; + else if (dim === 'agent' && meta.agent === value) match = true; + else if (dim === 'skill' && tags.some(t => t === `skill:${value}`)) match = true; + else if (tags.includes(pool)) match = true; + else if (meta.agent === pool) match = true; + else if (tags.some(t => typeof t === 'string' && t.includes(pool))) match = true; + + if (match) candidates.push(id); + } + if (candidates.length === 0) { + throw new Error( + `OD_MOCKS_POOL="${pool}" matched no recordings in ${dir}. ` + + `Supported shapes: agent:, skill:, outcome:, ` + + `or any tag substring. Check candidates with ` + + `\`jq '[.entries[] | {agent, outcome, skills}] | unique' mocks/manifest.json\`.`, + ); + } + const picked = pickRandom(candidates, process.env.OD_MOCKS_SEED); + if (picked) return { traceId: picked, path: join(dir, `${picked}.jsonl`), method: 'pool', pool }; + } + + // 4. random + const picked = pickRandom(all, process.env.OD_MOCKS_SEED); + if (!picked) return null; + return { traceId: picked, path: join(dir, `${picked}.jsonl`), method: 'random' }; +} + +export async function readRecording(path) { + const text = await readFile(path, 'utf-8'); + return text + .split('\n') + .filter(Boolean) + .map(line => JSON.parse(line)); +} diff --git a/mocks/lib/vela-subcommands.mjs b/mocks/lib/vela-subcommands.mjs new file mode 100644 index 000000000..ee0bac685 --- /dev/null +++ b/mocks/lib/vela-subcommands.mjs @@ -0,0 +1,99 @@ +// vela subcommand handlers (login + models) used by the mock CLI when +// argv[1] is not the default 'agent' (which falls through to the +// ACP server in format-vela.mjs). +// +// Mirrors the contract that +// apps/daemon/tests/fixtures/fake-vela.mjs implements — kept in sync +// because both feed the same daemon-side login route + status reader +// (apps/daemon/src/integrations/vela-profile.ts + +// apps/web/src/components/amrLoginPolling.ts). + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +const DEFAULT_MODELS_STDOUT = [ + 'public_model_deepseek_v3_2 vela', + 'public_model_deepseek_v4_flash vela', + 'public_model_deepseek_v4_pro vela', + 'public_model_gemini_2_5_flash vela', + 'public_model_gemini_3_1_flash_lite_preview vela', + 'public_model_gemini_3_1_pro_preview vela', + 'public_model_gpt_5_4 vela', + 'public_model_gpt_5_4_mini vela', + 'public_model_glm_5 vela', + 'public_model_glm_5_1 vela', + 'public_model_gpt_image_2 vela', + 'public_model_kimi_k2_6 vela', + 'public_model_minimax_m2_7 vela', + 'public_model_qwen3_235b_a22b vela', + 'public_model_seedance_2 vela', +].join('\n'); + +/** + * `vela login` — writes the AMR config file the daemon's status reader + * + AmrLoginPill component expect on disk. The real vela goes through a + * device-authorization browser approval; we skip the loop and just + * project the same on-disk artifact a successful real login produces. + * + * Envs (compat with fake-vela.mjs): + * VELA_PROFILE — profile slot to populate (prod|test|local) + * FAKE_VELA_LOGIN_DELAY_MS — sleep before the write (test in-flight states) + * FAKE_VELA_LOGIN_USER_EMAIL — email written into the profile + * FAKE_VELA_LOGIN_USER_PLAN — plan written into the profile + * FAKE_VELA_LOGIN_FAIL — when set, prints to stderr + exits 1 + */ +export async function runVelaLogin() { + if (process.env.FAKE_VELA_LOGIN_FAIL) { + process.stderr.write(`${process.env.FAKE_VELA_LOGIN_FAIL}\n`); + process.exit(1); + } + const allowed = new Set(['prod', 'test', 'local']); + const requested = (process.env.VELA_PROFILE || 'prod').trim() || 'prod'; + const profile = allowed.has(requested) ? requested : 'prod'; + if (!allowed.has(requested)) { + process.stderr.write(`[mock-vela] unknown profile ${requested}; defaulting to prod\n`); + } + const delayMs = Number(process.env.FAKE_VELA_LOGIN_DELAY_MS) || 0; + const userEmail = process.env.FAKE_VELA_LOGIN_USER_EMAIL || 'fake-user@example.com'; + const userPlan = process.env.FAKE_VELA_LOGIN_USER_PLAN || 'free'; + + const write = () => { + const file = join(homedir(), '.amr', 'config.json'); + mkdirSync(dirname(file), { recursive: true }); + const payload = { + profiles: { + [profile]: { + // Fake credentials — never used by real vela traffic since the + // daemon's link client points at the same FAKE_VELA_API_URL. + controlKey: 'fake-control-key-0000000000000000000000', + runtimeKey: 'fake-runtime-key-0000000000000000000000', + apiUrl: profile === 'local' ? 'http://localhost:18080' : '', + linkUrl: profile === 'local' ? 'http://localhost:18081' : '', + user: { + id: 'fake-user-id', + email: userEmail, + name: 'Fake User', + plan: userPlan, + }, + }, + }, + }; + writeFileSync(file, JSON.stringify(payload, null, 2), 'utf8'); + process.stdout.write(`Login successful for ${userEmail}.\n`); + process.exit(0); + }; + + if (delayMs > 0) setTimeout(write, delayMs); + else write(); +} + +/** + * `vela models` — prints the production-shaped public model catalog. + * Override via FAKE_VELA_MODELS env (newline-separated lines). + */ +export function runVelaModels() { + const out = process.env.FAKE_VELA_MODELS || DEFAULT_MODELS_STDOUT; + process.stdout.write(`${out}\n`); + process.exit(0); +} diff --git a/mocks/manifest.json b/mocks/manifest.json new file mode 100644 index 000000000..be4c579a1 --- /dev/null +++ b/mocks/manifest.json @@ -0,0 +1,3623 @@ +{ + "schema_version": 1, + "generated_at": "2026-05-29T06:21:39.088Z", + "storage": { + "provider": "cloudflare-r2", + "bucket": "open-design-mocks", + "public_url_base": "https://pub-89c3624d8b4a48018c37b212dbc4b945.r2.dev", + "object_prefix": "recordings/v1/" + }, + "entries": [ + { + "trace_id": "00e83799-a64b-4ce4-9a4e-2b09b72c7859", + "sha256": "244f93875170e602b91c7e0c0d5261322d0199335649a4f5132059837f40ee1f", + "bytes": 30840, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 7, + "duration_ms": 85188, + "skills": [ + "default" + ], + "session_id": "a4fd69eb-912e-4969-a732-70d58d44370b", + "user_input_preview": "请优化 Futuro Edu 项目中的手机号登录页面,要求如下: 1、页面顶部放置 Futuro Edu 的 Logo ,Logo旁边为Futuro Edu文案 2、只需要手机号和验证码,方便下次继续练唱。文案改为:随时随地,想唱就唱,位置在logo的下方 3、手机号输入框左边固定为+86,提示语:请输入11位手机号改为请输入手机号 4、提示语:请输入6位验证码改为请输入验证码 5、去掉提示语:前", + "multi_turn": false, + "captured_at": "2026-05-28T16:32:03.773Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "03e7bf7a-12ed-45a9-a17f-a8d8f31538f6", + "sha256": "a4068a85eceaab64d19b773dcf2f6cd27e4411531b355e145219447b22080112", + "bytes": 477, + "agent": "claude", + "model": null, + "outcome": "failed", + "tool_count": 0, + "duration_ms": 837, + "skills": [ + "default" + ], + "session_id": "97d627b9-c997-48c4-b976-1be8c982c2f4", + "user_input_preview": "你能设计App页面么", + "multi_turn": true, + "captured_at": "2026-05-28T15:50:09.893Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "04097377-d26e-4f04-b308-5e4e9e99fc76", + "sha256": "8ba97c1072f46d5145fc776724c51a55cd130bfe60acb47dfaf8f6ba6bdeaf2b", + "bytes": 46321, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 17, + "duration_ms": 111204, + "skills": [ + "agent-browser" + ], + "session_id": "70959fe1-a613-4b0f-a120-49762a4e1a26", + "user_input_preview": "давай удалим 2ю версию", + "multi_turn": false, + "captured_at": "2026-05-28T15:48:32.349Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "0425c974-4002-4e7e-93d4-fc9ab026ef84", + "sha256": "1fbfd42758c450b39be952317656c5146696cf639dc2b0f06af5fc4a0dd35bb0", + "bytes": 1253, + "agent": "claude", + "model": null, + "outcome": "failed", + "tool_count": 0, + "duration_ms": 19525, + "skills": [ + "ad-creative" + ], + "session_id": "b54e56d6-b1e0-4e24-b713-c063de5e1a14", + "user_input_preview": "can we try, to make it in to a motion graphic?", + "multi_turn": true, + "captured_at": "2026-05-28T15:49:17.045Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "04a7f865-1dad-452f-8fe5-c408196dd347", + "sha256": "e839ffb55e9608adacc3013901fc8fe053540e80a66826fc8322df68dfbc4453", + "bytes": 4316, + "agent": "gemini", + "model": null, + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 155003, + "skills": [ + "default" + ], + "session_id": "ef1df4e9-1bb0-4e81-a16d-72846d90348a", + "user_input_preview": "[form answers — task-type] - What should I build?: Prototype - Target platform: iOS app (iPhone 15 Pro) - Who is this for?: (skipped) - Brand context: Use the Royal Violet spec provided in the brief [", + "multi_turn": true, + "captured_at": "2026-05-28T18:35:28.248Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "0580c870-0310-44dd-9036-4c79074ce7cb", + "sha256": "b1b7fa5e36288ca4c80e86416f26fd58097881082c31d3a1d1a50ae942726121", + "bytes": 16302, + "agent": "opencode", + "model": null, + "outcome": "succeeded", + "tool_count": 7, + "duration_ms": 45421, + "skills": [ + "default" + ], + "session_id": "929f2ab5-4584-4875-8c35-cd2f83df64cf", + "user_input_preview": "On the very bottom you have to instances of Google Maps. Remove the Google Maps on the right hand side. And center: Find Us Carriagetown Neighborhood 328 N Grand Traverse St Flint, MI 48503 Hours", + "multi_turn": false, + "captured_at": "2026-05-28T16:34:54.901Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "06a9324a-a117-405a-980f-a0c730e0a3b5", + "sha256": "5963a3e4d44c0929e4d758b066c484249712be0de6fdb1e67ba236d4beda2830", + "bytes": 8880, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 4, + "duration_ms": 27250, + "skills": [ + "default" + ], + "session_id": "364e2ac1-815c-4dcb-9412-24d260631481", + "user_input_preview": "P7界面,验证闭环: 15项评审清单中去掉所有的✅", + "multi_turn": true, + "captured_at": "2026-05-28T15:44:36.263Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "072ae827-f4b6-42e3-93e2-6c2c3a157366", + "sha256": "b2111665528421889767a0043e9d4f2c1e7d52de3fb6b7429f6008c65edf23c5", + "bytes": 8467, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 12, + "duration_ms": 34759, + "skills": [ + "ad-creative" + ], + "session_id": "b54e56d6-b1e0-4e24-b713-c063de5e1a14", + "user_input_preview": "no no remove the latest image I gave you, not a good one, we need to work with the earlier ones and designs and assets etc", + "multi_turn": true, + "captured_at": "2026-05-28T15:50:01.872Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "07560066-0da6-46d5-859e-0bf0cc268f47", + "sha256": "18b499c73d90f5a4c2b42857aa871de0a2c588c8a71dcd6f4d65a8af7be083ce", + "bytes": 62326, + "agent": "opencode", + "model": "default", + "outcome": "succeeded", + "tool_count": 20, + "duration_ms": 316248, + "skills": [ + "default" + ], + "session_id": "7f1484bf-9553-4c41-b83c-84f74d6ee6d8", + "user_input_preview": "Continue the remaining unfinished tasks from the previous run. Do not redo completed work. Focus only on these unfinished todos: 1. [in_progress] Audit current slide layouts for edge bleed and croppe", + "multi_turn": false, + "captured_at": "2026-05-28T16:28:31.545Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "0a06cb0c-4a3c-4200-8a73-d1d4f192cc39", + "sha256": "2e9b953bffea0600ef034827d300bb61404a537cbca89ae74163d2385acaf1e6", + "bytes": 13881, + "agent": "claude", + "model": "opus", + "outcome": "succeeded", + "tool_count": 4, + "duration_ms": 51062, + "skills": [ + "agent-browser" + ], + "session_id": "df5bbbff-38e1-4030-892e-38ce96e7a529", + "user_input_preview": "We need to remove all \"+n mores\" from plan details and workout picker details. The coach needs to see the full workout plan of the user", + "multi_turn": false, + "captured_at": "2026-05-28T15:47:55.705Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "0cee30c9-04b4-484f-ad39-192c2198d37d", + "sha256": "9bd179a4462d33599466beab69e797cf8132333b3f9f08c3136f4402ad95b78b", + "bytes": 851, + "agent": "cursor-agent", + "model": "composer-2-fast", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 77580, + "skills": [ + "algorithmic-art" + ], + "session_id": "8c252113-3802-4020-bb00-171c1576ebd8", + "user_input_preview": "Let's shorten \"Every county sends vehicle counts and miles traveled into a LightGBM ensemble; predictions return as stacked emissions you can explore by year\" too \"Every county sends vehicle counts an", + "multi_turn": true, + "captured_at": "2026-05-28T15:59:31.540Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "0cfd3766-40a1-48e9-b3a5-d420bf5551b8", + "sha256": "215f05be726bb46bf53712d756544fffb44e9a29f0fea75a2d25e9341ea02aae", + "bytes": 36929, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 7, + "duration_ms": 101998, + "skills": [ + "default" + ], + "session_id": "b591fc28-d2fb-41e6-b5bb-ac9d0d5edf86", + "user_input_preview": "check and fix the line breaks as per the thumb rules, like \"for\", \"and\" , conjunted twowords with \"-\" hyphens. etc.,", + "multi_turn": false, + "captured_at": "2026-05-28T16:27:12.008Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "0fd0e45d-9e38-46c8-b8f7-3cb0244ac428", + "sha256": "d08011601d1bbb7e671a6a94ae50f12e06755f2325d433dcf74e05a3a19bbe6a", + "bytes": 5122, + "agent": "gemini", + "model": null, + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 27849, + "skills": [ + "default" + ], + "session_id": "ef1df4e9-1bb0-4e81-a16d-72846d90348a", + "user_input_preview": "# 📱 TASKORA — COMPLETE OPEN DESIGN UI MASTER DOCUMENT # 🎯 MAIN DESIGN GOAL Design style must feel like: ✅ Smart productivity workspace ✅ Interactive timeline dashboard ✅ Floating modern utility s", + "multi_turn": true, + "captured_at": "2026-05-28T18:33:57.359Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "108c20c0-4fbb-47b8-b9f2-625aaf394f88", + "sha256": "ac1038309f122857213417515df301b94fb51938b4e88cfaa85ca439d8b9bd23", + "bytes": 4132, + "agent": "cursor-agent", + "model": "composer-2-fast", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 137817, + "skills": [ + "algorithmic-art" + ], + "session_id": "8c252113-3802-4020-bb00-171c1576ebd8", + "user_input_preview": null, + "multi_turn": true, + "captured_at": "2026-05-28T16:12:46.683Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "10f9c7ea-4d4c-4993-9914-df1680d9abbf", + "sha256": "df02e9458edbae5721ba7c30a303bdf63eff74d68f959a9f3b11fd2efecd52c1", + "bytes": 534, + "agent": "cursor-agent", + "model": "auto", + "outcome": "failed", + "tool_count": 0, + "duration_ms": 693, + "skills": [ + "default" + ], + "session_id": "40e92b93-443d-4000-a03c-a0d490807da8", + "user_input_preview": "i need to work on the prototypes pf the following designs", + "multi_turn": false, + "captured_at": "2026-05-28T16:31:31.342Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "1151b2f9-634e-471b-9479-9df5fddbe9bc", + "sha256": "ba407318b2259c10a562ea01251468e611ad6ce94e4c4d9e6c848b6b1561479b", + "bytes": 4567, + "agent": "cursor-agent", + "model": "composer-2-fast", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 142587, + "skills": [ + "algorithmic-art" + ], + "session_id": "8c252113-3802-4020-bb00-171c1576ebd8", + "user_input_preview": "Great work. Note that on mobile, it's still really hard to read. Suggest that you make Vehicle Sourcetypes menu minimizable, and minimized by default when opening from mobile (but open by default on", + "multi_turn": true, + "captured_at": "2026-05-28T15:29:22.841Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "11705d3e-a233-4829-9f2b-340c93c43da5", + "sha256": "e03e95542ca57b036c44513e846e978d9b1d90a6be0d804f9e123b9dba0e907f", + "bytes": 4133, + "agent": "cursor-agent", + "model": "composer-2-fast", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 127446, + "skills": [ + "algorithmic-art" + ], + "session_id": "8c252113-3802-4020-bb00-171c1576ebd8", + "user_input_preview": "Thank you. Literally can't see the halo at all, no matter how much I adjust . Is the halo somehow black? it looks like the coastline borders don't show up or show up black perhaps. Also, in the \"What", + "multi_turn": true, + "captured_at": "2026-05-28T15:52:55.793Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "12d88deb-dfca-4ff8-b773-e39cce1e387d", + "sha256": "afe8a423474c7d16b215c077f233d722a752e3179ca8958f503a61f2b29b62c4", + "bytes": 1119, + "agent": "gemini", + "model": null, + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 44423, + "skills": [ + "algorithmic-art" + ], + "session_id": "73dd5a51-118c-45e6-aa7a-680ab6795510", + "user_input_preview": "I only have Gemini and Claude, I do not use OpenAI", + "multi_turn": true, + "captured_at": "2026-05-28T15:29:37.225Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "131b0c5c-c55c-413d-a706-04cbedef29a5", + "sha256": "95c3786bce5e1a83e20e27d0a0b860946e82081ac3a23447342d86ff9d7f1a5c", + "bytes": 48746, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 15, + "duration_ms": 246725, + "skills": [ + "magazine-web-ppt" + ], + "session_id": "d18d4153-d55e-43fa-9d5a-c9007e156d62", + "user_input_preview": "继续", + "multi_turn": false, + "captured_at": "2026-05-28T16:31:36.649Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "13903bf5-abd3-4502-8843-df44a19e9386", + "sha256": "e4059649ef737ee9d954e5eb045f24f6dbe1c3581f8972e0656f385ab439e3a1", + "bytes": 2427, + "agent": "cursor-agent", + "model": "composer-2-fast", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 103267, + "skills": [ + "algorithmic-art" + ], + "session_id": "8c252113-3802-4020-bb00-171c1576ebd8", + "user_input_preview": null, + "multi_turn": true, + "captured_at": "2026-05-28T15:13:41.745Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "139bebed-aebb-4901-85fb-1f9081a98758", + "sha256": "02fd7636860cb8b1a051c9d0690d74957852684b0bd9c236ccb60a5e5705dd1a", + "bytes": 24757, + "agent": "codex", + "model": null, + "outcome": "failed", + "tool_count": 18, + "duration_ms": 441360, + "skills": [ + "default" + ], + "session_id": "976333ac-966e-49bc-a5e7-d6c903bd1a01", + "user_input_preview": null, + "multi_turn": true, + "captured_at": "2026-05-28T16:25:42.211Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "15a7afd9-ff65-4ae3-a787-c474ea31073a", + "sha256": "3ea01016ee4c4ca216bc533700a08ce56d5e3485d7b89e92cef4a8b551d96cd4", + "bytes": 40958, + "agent": "opencode", + "model": null, + "outcome": "succeeded", + "tool_count": 10, + "duration_ms": 144873, + "skills": [ + "default" + ], + "session_id": "b95f8261-731b-4fdc-8a16-86b489a7a917", + "user_input_preview": "crea un post para columna 2: **1. Post estático Manifiesto** Texto grande centrado sobre fondo negro: \"Tú dominas tu conocimiento. Nosotros lo convertimos en ventas.\" Pie: Escale It Group | Performa", + "multi_turn": false, + "captured_at": "2026-05-28T16:14:13.859Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "15ccb8f8-177b-4db4-b8bb-2998d4c32881", + "sha256": "3ff2aaeb5f7d78ec637b1fb8b7725f92098e22ef17fb3ad1804b85cd76faacf0", + "bytes": 548, + "agent": "gemini", + "model": "gemini-3-pro-preview", + "outcome": "failed", + "tool_count": 0, + "duration_ms": 32460, + "skills": [ + "default" + ], + "session_id": "1ee7c118-e305-4914-a459-10d56e321b4a", + "user_input_preview": "continue please", + "multi_turn": false, + "captured_at": "2026-05-28T16:31:19.675Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "15f00160-3d85-4bf3-b469-2b51ea86761f", + "sha256": "ad144855539f1ee2e06f003d3bf3457092a13c29b1227d58aad4d49a7be41f99", + "bytes": 54800, + "agent": "opencode", + "model": "deepseek/deepseek-v4-pro", + "outcome": "succeeded", + "tool_count": 17, + "duration_ms": 407242, + "skills": [ + "brainstorming" + ], + "session_id": "3020ab35-cff7-4601-86fa-2a83d67571c4", + "user_input_preview": "usa @brainstorming para mejorar el diseño de la app pero usa como referencia el documento adjunto que es el como funciona mi app real , para que tengas una guia y no inventar cualquier cosa", + "multi_turn": false, + "captured_at": "2026-05-28T16:22:56.614Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "169fa62e-6000-41da-871a-83bbee11baab", + "sha256": "dccb3d617a7f5ac75921b9f1477419c3279c1fb804f6bb207d06ee7f36c4b0c5", + "bytes": 15169, + "agent": "opencode", + "model": "opencode-go/kimi-k2.5", + "outcome": "succeeded", + "tool_count": 6, + "duration_ms": 23348, + "skills": [ + "default" + ], + "session_id": "32b36014-fc99-4881-8592-e1885a2ac4f6", + "user_input_preview": "design md nya udh oke ga dengan color dan style design yang sekarang", + "multi_turn": false, + "captured_at": "2026-05-28T16:22:19.925Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "17d864af-b648-40c1-8cb0-753d1702beec", + "sha256": "4ffabd0e99ac86241e65b8f50ed656b812b46ddc28521db0555f28f7a670d375", + "bytes": 85299, + "agent": "claude", + "model": "default", + "outcome": "succeeded", + "tool_count": 50, + "duration_ms": 944824, + "skills": [ + "ad-creative" + ], + "session_id": "8fbb6ad4-4c3d-4745-956c-502b09af5e2f", + "user_input_preview": "continua", + "multi_turn": true, + "captured_at": "2026-05-28T01:42:07.143Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "18f2b6fd-cff3-4bb3-bd8f-dd6b35f1501e", + "sha256": "55cdd3253175fee49d84f6edb5a833a1c4eadad80be880f1935d019d3ecbad56", + "bytes": 55144, + "agent": "opencode", + "model": "opencode/big-pickle", + "outcome": "succeeded", + "tool_count": 13, + "duration_ms": 157374, + "skills": [ + "default" + ], + "session_id": "a557cd88-6582-4c49-a4f3-46015bd43f1b", + "user_input_preview": "[form answers — task-type] - What should I build?: Prototype - Who is this for?: me, local use - Brand context: Pick a direction for me [value: pick_direction] - Roughly how much?: 1 page, spa - Any i", + "multi_turn": false, + "captured_at": "2026-05-28T16:29:52.095Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "1a35cbef-bbda-4ea9-899e-313da2b594fe", + "sha256": "9e71677acc04a77be5a6ebe499564872b48a49a6276dcf39b94529da9e36d563", + "bytes": 31213, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 11, + "duration_ms": 123249, + "skills": [ + "default" + ], + "session_id": "c64c0d19-9e6c-465c-ad34-94e17827c04f", + "user_input_preview": "同意,继续", + "multi_turn": true, + "captured_at": "2026-05-28T16:20:49.469Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "1aa82807-305b-4c6e-8818-951a31bd84d9", + "sha256": "5671d7e030a0eae1788b47cf2fbeeaa51ed97ae40cd5d532f52833558d537842", + "bytes": 75800, + "agent": "codex", + "model": "gpt-5.5", + "outcome": "succeeded", + "tool_count": 33, + "duration_ms": 757099, + "skills": [ + "default" + ], + "session_id": "02af3152-9a28-4441-a613-216830bc642e", + "user_input_preview": "[form answers — discovery] - 目标平台: Desktop web - 复盘类型: A 股交易复盘 - 页面规模: 多个独立页面 - 特别要求: (skipped)", + "multi_turn": false, + "captured_at": "2026-05-28T16:19:28.936Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "1ad1412e-07bd-4672-90dd-7ce258faa8c0", + "sha256": "99202c4ddb090ce14936292dee6c61e05e41a0f2d2b496ca0e5b77d47edc299f", + "bytes": 24033, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 10, + "duration_ms": 93562, + "skills": [ + "ad-creative" + ], + "session_id": "4baaa2cb-68cb-4897-941f-39db5f3f835b", + "user_input_preview": null, + "multi_turn": true, + "captured_at": "2026-05-26T04:33:02.705Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "1b622172-97ba-4e47-9eb2-7868eb2fc59b", + "sha256": "e0d9614962b5e7bc8ad8800f90be28ec99ba6a5bea813d99de7c79d8a91f616a", + "bytes": 928, + "agent": "qwen", + "model": "qwen3-coder-plus", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 168728, + "skills": [ + "ad-creative" + ], + "session_id": "0a51c2ff-bc6c-4d0e-bdb7-ae8bfaf4fe00", + "user_input_preview": "[form answers — discovery] - ¿Para quién son estos iconos?: (skipped) - ¿Qué estilo visual prefieres?: Microsoft Fluent — contornos limpios, geometría precisa, stroke-based [value: microsoft] - ¿Tamañ", + "multi_turn": true, + "captured_at": "2026-05-27T23:34:51.002Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "1c572030-146b-4054-b2fe-7161b1b168f0", + "sha256": "a0781927ee3905eed67d50b8edb898b38972679bbab462a352cce1e35bbcd892", + "bytes": 2972, + "agent": "opencode", + "model": "default", + "outcome": "succeeded", + "tool_count": 3, + "duration_ms": 13445, + "skills": [ + "ad-creative" + ], + "session_id": "179fc5ef-f34b-4160-88d8-a430e6ce96f8", + "user_input_preview": "i need landing", + "multi_turn": false, + "captured_at": "2026-05-27T17:56:46.640Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "1de734bc-84ae-4e35-a83d-bcf175cea0e2", + "sha256": "b3cab0330cdf3126ad726476929dbc27022faaf9df63b5bed387850f13898b9c", + "bytes": 9498, + "agent": "opencode", + "model": "opencode-go/qwen3.7-max", + "outcome": "succeeded", + "tool_count": 3, + "duration_ms": 26876, + "skills": [ + "algorithmic-art" + ], + "session_id": "6f4bc605-2293-47f4-babe-3248a051fab1", + "user_input_preview": "Make the edges rotate randomly", + "multi_turn": true, + "captured_at": "2026-05-28T16:03:05.908Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "1eec2c85-589f-4f8a-9f1f-17c0d0b517c8", + "sha256": "22a081d3a8eb570bd3648c69d6a9cd22197e95d8004ce4ea6f20a35c3b3442bf", + "bytes": 124556, + "agent": "codex", + "model": "default", + "outcome": "failed", + "tool_count": 26, + "duration_ms": 360819, + "skills": [ + "default" + ], + "session_id": "afeeb0bb-5b98-465e-8e9f-e2a19c9124c3", + "user_input_preview": "[form answers — discovery] - Qual alvo principal?: Responsive web, Desktop web - O que você quer que eu melhore agora?: Transformar em versão mais premium - Escopo: Editar só a home atual - Algo impor", + "multi_turn": false, + "captured_at": "2026-05-28T16:19:16.560Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "2053209f-ca46-430c-a142-fc4a76191cea", + "sha256": "28477c54192e86f58422abb0c4a9862530dc333eb14cf781b1b208786c47e065", + "bytes": 35942, + "agent": "opencode", + "model": null, + "outcome": "succeeded", + "tool_count": 16, + "duration_ms": 75930, + "skills": [ + "default" + ], + "session_id": "df080b61-929d-43ee-9cbe-47b51215c953", + "user_input_preview": "terapkan di semua page dan logo di buat yang proper pada penempatannya", + "multi_turn": true, + "captured_at": "2026-05-28T16:24:37.134Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "21747360-31fa-46b3-9615-46140386a0d1", + "sha256": "7f3c1296298f2f6b088aea97590fbdc0c1e90451d09184ac37919da2d7264857", + "bytes": 48560, + "agent": "claude", + "model": null, + "outcome": "failed", + "tool_count": 52, + "duration_ms": 698178, + "skills": [ + "ad-creative" + ], + "session_id": "8fbb6ad4-4c3d-4745-956c-502b09af5e2f", + "user_input_preview": "[form answers — discovery] - ¿Cómo quieres la estructura de archivos?: Un solo archivo HTML con scroll (todo en index.html) [value: single_page] - Contenido del menú / carta: Usa contenido representat", + "multi_turn": true, + "captured_at": "2026-05-27T21:52:00.399Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "242d7238-b206-4cdb-a02e-5edf3c73864c", + "sha256": "ea89f0e85a26b07ad7d4c341f5370d3a5d46f789b91d365064b19054c76e4fb0", + "bytes": 15476, + "agent": "opencode", + "model": "ollama-proxy/kimi-k2.6", + "outcome": "succeeded", + "tool_count": 5, + "duration_ms": 59388, + "skills": [ + "agent-browser" + ], + "session_id": "81526dba-0a4c-4805-a731-ed8f9b4f0639", + "user_input_preview": "this is landing page for ecommerce SaaS. we need to redesign this landing page, make it look modern for SaaS software. based on the existing design, re-design the app to look more moden, support lig", + "multi_turn": true, + "captured_at": "2026-05-28T16:24:13.103Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "24565b22-5230-40cd-8905-1c7936b54aef", + "sha256": "2f9098b0666c5fe2018b351e116013fd2a4c5ca692bd9762052e4e3a67dd0c68", + "bytes": 500, + "agent": "claude", + "model": null, + "outcome": "failed", + "tool_count": 0, + "duration_ms": 3267, + "skills": [ + "default" + ], + "session_id": "75ab5caa-6b7d-4d01-ac03-b2ece54e9398", + "user_input_preview": "اه", + "multi_turn": true, + "captured_at": "2026-05-28T16:35:02.029Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "2667e851-1a26-4231-8b2b-1f78d829170b", + "sha256": "a56b9091efc98b5884e4faf2fe9becde33f256ad35fdf6fb9c8f206aa90e9c7f", + "bytes": 31701, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 13, + "duration_ms": 182000, + "skills": [ + "agent-browser" + ], + "session_id": "23db0c86-1bac-46cd-9b85-16a154fcaf71", + "user_input_preview": "can we make the app more minimal and allow for dark mode?", + "multi_turn": true, + "captured_at": "2026-05-28T16:19:10.438Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "26b69aa4-1569-4ca8-bc65-db728a88e8cb", + "sha256": "0ed816c8a03dc9f9e6d29b37087181e48ecde9c622968084ba2b0454d9905a02", + "bytes": 14316, + "agent": "claude", + "model": "default", + "outcome": "succeeded", + "tool_count": 6, + "duration_ms": 68000, + "skills": [ + "algorithmic-art" + ], + "session_id": "7f2343d7-07f6-4d71-89bf-6a86ade4bb8d", + "user_input_preview": "Le projet est de réaliser un nouveau maillot pour le club de volley d'Antony voici le brief du client et ce qu'il a généré via gémini", + "multi_turn": true, + "captured_at": "2026-05-28T15:40:29.221Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "274baae0-41ef-432d-97b5-0e9e6cb85cec", + "sha256": "3c1fcad433cdea7eea0fbff4ac2993ad0642d9222e140934be5ffd1befaa819f", + "bytes": 16025, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 10, + "duration_ms": 58879, + "skills": [ + "ad-creative" + ], + "session_id": "b54e56d6-b1e0-4e24-b713-c063de5e1a14", + "user_input_preview": "Include a new variant for the creation of this ad please", + "multi_turn": true, + "captured_at": "2026-05-28T15:43:23.080Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "282e25db-1e89-4c11-b3f1-29ff43c2f193", + "sha256": "a268c2082a2c49dce3d5ae1fe9f46ecbc6ba4eeb8f7659ca1288d2c367476320", + "bytes": 587, + "agent": "gemini", + "model": "gemini-3-flash", + "outcome": "failed", + "tool_count": 0, + "duration_ms": 11723, + "skills": [ + "ad-creative" + ], + "session_id": "3ea05316-0c9d-47d2-9ab0-f4b2cb2b15da", + "user_input_preview": "still to small, make it like 250px", + "multi_turn": true, + "captured_at": "2026-05-26T13:29:14.339Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "28ca87cc-3b38-4813-ae91-39d2d818bfa1", + "sha256": "7e6598b7d9193ab855f004d5903deed9574dfda85ca6c627e737df020fabfd30", + "bytes": 54884, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 21, + "duration_ms": 359113, + "skills": [ + "default" + ], + "session_id": "58734cb3-6bfb-4ab6-91d4-576f76e6fd9c", + "user_input_preview": "tôi cần file expression-sheet là thu nhỏ lại từ raw, hiện tại để cắt cho đủ size dẫn đến hình bị mất đầu, thông tin khác,.... Review và thực hiện lại", + "multi_turn": false, + "captured_at": "2026-05-28T16:25:44.070Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "29989d5f-5380-4920-b578-484e4180367e", + "sha256": "e2da3644d51ae02df800b421101dc8fd382f84375da14ebbb8077258d1b9caab", + "bytes": 895, + "agent": "gemini", + "model": null, + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 15705, + "skills": [ + "algorithmic-art" + ], + "session_id": "73dd5a51-118c-45e6-aa7a-680ab6795510", + "user_input_preview": "[form answers — discovery] - What is the specific topic of the discussion panel?: Successful Entrepeneurs - Brand context: I have a brand spec — I'll share it [value: brand_spec]", + "multi_turn": true, + "captured_at": "2026-05-28T15:25:22.020Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "2c40378a-e966-490d-b899-4d6cd81ce12b", + "sha256": "b8e4c461ed32f6717d349a8cfc6e58e71e76b00c354767b2d7fb0c2dfa6a2780", + "bytes": 17439, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 5, + "duration_ms": 45673, + "skills": [ + "agent-browser" + ], + "session_id": "899d9e9f-0cb6-4429-b975-450f61400d7e", + "user_input_preview": "改成订单", + "multi_turn": true, + "captured_at": "2026-05-28T16:30:25.150Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "2d88d27f-71f7-4f0c-8e76-608ccccbd8db", + "sha256": "1adeb5ae87327d2e52c6d212175c19cc6e8b1e270dc0fe096268bbbd5919a492", + "bytes": 57471, + "agent": "claude", + "model": "sonnet", + "outcome": "succeeded", + "tool_count": 26, + "duration_ms": 364912, + "skills": [ + "ad-creative" + ], + "session_id": "67b092e5-6aac-4977-93d7-339ba0321f18", + "user_input_preview": "To be honest I don't like this design, okay. https://v0.app/templates/auralink-saas-landing-page-zoQPxUaTqvE?b=v0-preview-b_7ZXtHjzn40x&f=1&path=%2F can you use the Browser to see this website fully.", + "multi_turn": false, + "captured_at": "2026-05-28T10:06:20.138Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "2e526bda-1899-4e70-a541-af8102ea9f9d", + "sha256": "826cfc7b024a6c4c97c8475d28466776c4e1b9fb66cdc1b25af6d5d8416a9bc7", + "bytes": 515, + "agent": "claude", + "model": "sonnet", + "outcome": "failed", + "tool_count": 0, + "duration_ms": 5924, + "skills": [ + "agent-browser" + ], + "session_id": "3a3aeb61-2638-4971-8181-75a40b800eb9", + "user_input_preview": "继续", + "multi_turn": true, + "captured_at": "2026-05-28T16:32:50.119Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "2e69fc24-4d76-475f-aa5d-f9eb07a1b195", + "sha256": "eeca071e71709e2e065caef775662499e437bece6581a503cabbf0615276e0f4", + "bytes": 4030, + "agent": "cursor-agent", + "model": "composer-2-fast", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 146115, + "skills": [ + "algorithmic-art" + ], + "session_id": "8c252113-3802-4020-bb00-171c1576ebd8", + "user_input_preview": "Thank you. Better. You still have not added a visible outer halo. We need a much much thicker, more visible halo around the border. Please add a tweaks menu that lets me adjust the level of halo. Als", + "multi_turn": true, + "captured_at": "2026-05-28T15:47:30.663Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "2e821ee7-80c3-43ff-81d7-153515917ee6", + "sha256": "88c0c549cd86cc59f6f136171dcd1cb9c06b594fcc8a272744756ed56097a0f3", + "bytes": 11173, + "agent": "claude", + "model": "sonnet", + "outcome": "succeeded", + "tool_count": 10, + "duration_ms": 60382, + "skills": [ + "default" + ], + "session_id": "bc17a7ca-299f-4c4f-b8c4-f6ff8663a50c", + "user_input_preview": "i want to see where that message is. i never said that, this is the only chat we've had.", + "multi_turn": true, + "captured_at": "2026-05-28T15:48:02.249Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "314d6833-0377-4ac4-ba11-2b8d7eca5511", + "sha256": "3038c98c72deb641dd5eb4a32f5947dd7e4d555bdfd0e5c1f60b2e00d33dcfef", + "bytes": 10839, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 8, + "duration_ms": 40885, + "skills": [ + "default" + ], + "session_id": "2b9deee3-a148-420b-84f4-5e893a68cd87", + "user_input_preview": "然后顶部的那些彩色竖条还是没对齐现在是长度差不多合适了,但是统一往下偏移了几个像素,你看看这是怎么回事?", + "multi_turn": false, + "captured_at": "2026-05-28T15:50:03.845Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "3470e198-34df-4a75-8bfb-57bdc35e9b17", + "sha256": "cefccd0b6cf9629be82e34d758a26664e8c75327ffa2a8f4f7609f1c10209afc", + "bytes": 7116, + "agent": "gemini", + "model": null, + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 348350, + "skills": [ + "default" + ], + "session_id": "ef1df4e9-1bb0-4e81-a16d-72846d90348a", + "user_input_preview": "Create a complete iOS app prototype for TASKORA with all 15 screens as standalone HTML files. Each screen must be self-contained with no external file dependencies. Start with: 1. Splash Screen 2. H", + "multi_turn": true, + "captured_at": "2026-05-28T18:46:18.487Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "35671ee2-9b53-43af-ac50-4dfbdb1994b6", + "sha256": "078db76f020902989eb26028ec9d1934096f48c372e0e6ad59d63ce95480b432", + "bytes": 2281, + "agent": "claude", + "model": "claude-opus-4-5", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 18897, + "skills": [ + "default" + ], + "session_id": "fb1bcfb7-36df-4997-ba78-80223a73067e", + "user_input_preview": "Make a website for an organization responsible for monitoring the government. It will monitor all available informnation including budget, audits, investigations, etc, and will issue papers and notifi", + "multi_turn": false, + "captured_at": "2026-05-28T15:44:51.155Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "37af2802-d34e-4d27-abdf-f7a93ea6e8a4", + "sha256": "cc5ac32e208426ac2cdccb32cc36aa1fc83845021d627911beb56518df68fd3f", + "bytes": 19910, + "agent": "opencode", + "model": "deepseek/deepseek-v4-pro", + "outcome": "succeeded", + "tool_count": 6, + "duration_ms": 219138, + "skills": [ + "default" + ], + "session_id": "cbfb3883-529a-44b9-ac30-e4ef54be29af", + "user_input_preview": null, + "multi_turn": false, + "captured_at": "2026-05-28T16:22:17.755Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "3881647e-18c3-4e55-85e3-5e74ae8c15b2", + "sha256": "a9cfc6c27363d05fcea589e3c30ce95f822ad0608626f8fb57f4aad137a1ac9c", + "bytes": 27790, + "agent": "opencode", + "model": "github-copilot/gpt-5.5", + "outcome": "failed", + "tool_count": 13, + "duration_ms": 145002, + "skills": [ + "penpot-flutter-design-source" + ], + "session_id": "60ac3f82-be6c-476e-bbc5-0034c626a03d", + "user_input_preview": "@penpot-mcp @design-review @brainstorming Current State: See some description text overflow issues at first glance. Please thoroughly audit, analyze, review, verify and fix & polish all of the remaini", + "multi_turn": false, + "captured_at": "2026-05-28T16:25:42.365Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "39f6d514-a5a0-48a4-8298-6c7fed7ea875", + "sha256": "29b054d1b456e71ed14d2fae91a06891f6fb56a81e70844d6aad70d55ba5caf3", + "bytes": 1270, + "agent": "gemini", + "model": "default", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 89651, + "skills": [ + "ad-creative" + ], + "session_id": "3ea05316-0c9d-47d2-9ab0-f4b2cb2b15da", + "user_input_preview": "still to small, make it like 250px", + "multi_turn": true, + "captured_at": "2026-05-26T13:29:42.117Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "3a885250-f8a8-43f7-946e-fbb2f1617c42", + "sha256": "b99ca7e3de5705a8c7d3d11667304a7fc535d28e9b917422c481f3fc7fb913c9", + "bytes": 1529, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 2, + "duration_ms": 35577, + "skills": [ + "ad-creative" + ], + "session_id": "b54e56d6-b1e0-4e24-b713-c063de5e1a14", + "user_input_preview": "do page sized", + "multi_turn": true, + "captured_at": "2026-05-28T15:31:49.927Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "3d6f3f38-5f74-428a-8f3b-7ab09da01aee", + "sha256": "53ddee15049870d727b8e6c007f23c3ddc71905c829eb3417580b66df76eaf4a", + "bytes": 128031, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 36, + "duration_ms": 591338, + "skills": [ + "ad-creative" + ], + "session_id": "5cb1aba3-6090-496d-8357-9cb33db9c4dc", + "user_input_preview": "https://www.institutomarketing.es/master-marketing-digital-inteligencia-artificial/ quiero hacer banners para google ads para esta web que me ayuden a verder el master de Máster Marketing Digital + IA", + "multi_turn": false, + "captured_at": "2026-05-27T06:33:13.252Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "3eae4b3b-3fc7-41d2-b162-7f15f1c731d7", + "sha256": "c7a7188d93ac9d705a841ed17dd73f0cb57fc90f0b2eab5373a7c14709d049ed", + "bytes": 2532, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 39056, + "skills": [ + "default" + ], + "session_id": "281fc730-1731-43cd-b265-0ed6a9ea0ced", + "user_input_preview": "你好", + "multi_turn": false, + "captured_at": "2026-05-28T15:46:04.838Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "4033d68a-bf58-42b3-b4a8-0b4c0f70ba09", + "sha256": "6a74117cdc1d3690121579854627ccd1d5a24f5d7ccf40c498cd6029ee9e3515", + "bytes": 1625, + "agent": "gemini", + "model": "gemini-3.1-pro-preview", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 788923, + "skills": [ + "ad-creative" + ], + "session_id": "3ea05316-0c9d-47d2-9ab0-f4b2cb2b15da", + "user_input_preview": "for the content make it center-center", + "multi_turn": true, + "captured_at": "2026-05-28T14:55:49.807Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "411b6259-976f-4e6e-952e-51114269ad2c", + "sha256": "c9aa3406610eb95071c58a77181cb091cb2c366f9dba3033cca402d0ae01189e", + "bytes": 27452, + "agent": "opencode", + "model": "poe/anthropic/claude-opus-4.7", + "outcome": "succeeded", + "tool_count": 6, + "duration_ms": 97894, + "skills": [ + "default" + ], + "session_id": "e50d4f9d-546c-4d09-9ce8-f3a379fffd03", + "user_input_preview": "want to build momentum posting more on our instagram don't know what else to post", + "multi_turn": false, + "captured_at": "2026-05-28T16:27:26.020Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "429e54cc-827c-411d-97d5-4e3828dddb66", + "sha256": "91dca5d536f7ce9ae39d11e4c7c3842c3e849af6efebdfa574255db328659c66", + "bytes": 38869, + "agent": "opencode", + "model": "opencode/big-pickle", + "outcome": "succeeded", + "tool_count": 21, + "duration_ms": 94840, + "skills": [ + "ad-creative" + ], + "session_id": "b282d8d9-7052-4955-998f-ae5c0c945491", + "user_input_preview": "adelante", + "multi_turn": true, + "captured_at": "2026-05-28T01:47:47.579Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "4650fd02-73b3-4536-a1c8-9304cdb14c40", + "sha256": "73429910d8984d297ae53df1a92bf7efae6798e21559c9a9b9bd04aab7ad0496", + "bytes": 30765, + "agent": "codex", + "model": null, + "outcome": "failed", + "tool_count": 10, + "duration_ms": 165235, + "skills": [ + "default" + ], + "session_id": "089b1ec0-c042-437d-b523-c9d8a4d1752f", + "user_input_preview": "y porque solo 5 reviews? a mi me gustaria que se vean todos", + "multi_turn": false, + "captured_at": "2026-05-28T16:31:40.778Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "4668cb89-9222-497b-addd-e1fa800ed33b", + "sha256": "57e2d39b16b2df8a872f5d7127af4df11a263f6c8badcf3869a62c04119d5b4b", + "bytes": 2221, + "agent": "copilot", + "model": "gpt-5.2-codex", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 22745, + "skills": [ + "ad-creative" + ], + "session_id": "d3eb9812-b37f-4b49-93a5-f336a21b4b33", + "user_input_preview": "continue", + "multi_turn": true, + "captured_at": "2026-05-28T08:30:08.153Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "483e01ea-546d-4e8b-ac57-1a9f4a204266", + "sha256": "86400267dc9de97aa64e83e24e514f997904dfde7694292f264b2176212947a4", + "bytes": 8219, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 4, + "duration_ms": 22785, + "skills": [ + "default" + ], + "session_id": "364e2ac1-815c-4dcb-9412-24d260631481", + "user_input_preview": "每个界面的英文标题由绿色修改为蓝色", + "multi_turn": true, + "captured_at": "2026-05-28T15:45:41.651Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "488cf76d-f6f3-4aa1-b2d6-c63b8558d334", + "sha256": "95507ca7d5e145a53c9ff41bff145301391f071332e342525117a8f73e4ef6e1", + "bytes": 2366, + "agent": "claude", + "model": "sonnet", + "outcome": "succeeded", + "tool_count": 4, + "duration_ms": 21461, + "skills": [ + "default" + ], + "session_id": "bc17a7ca-299f-4c4f-b8c4-f6ff8663a50c", + "user_input_preview": "delete one", + "multi_turn": true, + "captured_at": "2026-05-28T15:46:08.356Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "48ea64c1-7056-41e2-8618-e88e4a01130a", + "sha256": "1ef19ead5af4850bf96795814ecdd99916f92abe387920d61d8c8ac1745734c9", + "bytes": 14833, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 10, + "duration_ms": 61075, + "skills": [ + "ad-creative" + ], + "session_id": "b54e56d6-b1e0-4e24-b713-c063de5e1a14", + "user_input_preview": "[form answers — variant-c-copy] - How should the new copy live with the image?: Overlay on whitespace edges — accept some proximity to baked copy - What copy? (headline / sub / CTA — or leave blank fo", + "multi_turn": true, + "captured_at": "2026-05-28T15:46:32.759Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "4b7ae329-2145-4c5d-beb9-dd8b0fff9e78", + "sha256": "2c851e5b6101ed327c8d870d55454024812b954b319f51ac1d2774cf6e3374b9", + "bytes": 23775, + "agent": "codex", + "model": null, + "outcome": "failed", + "tool_count": 7, + "duration_ms": 85209, + "skills": [ + "default" + ], + "session_id": "913e4b29-34a2-46fd-892e-842b027b112c", + "user_input_preview": "[form answers — task-type] - What should I build?: Prototype - Who is this for?: (skipped) - Brand context: Pick a direction for me [value: pick_direction] - Roughly how much?: (skipped) - Any importa", + "multi_turn": false, + "captured_at": "2026-05-28T16:23:15.099Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "4e9340c6-f090-4900-9e94-1b86c800a2cc", + "sha256": "fd9a7243b6b59053979e01c4c391f5db6b83dec3a13a937b18b66fcf64b0fa94", + "bytes": 2199, + "agent": "claude", + "model": "sonnet", + "outcome": "succeeded", + "tool_count": 2, + "duration_ms": 17359, + "skills": [ + "default" + ], + "session_id": "85ac9b77-bd5c-4767-ae1f-7085c96b6baf", + "user_input_preview": "Delete Design System", + "multi_turn": true, + "captured_at": "2026-05-28T15:44:51.383Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "4ea84290-d2d4-4332-91f0-887c6fb00a57", + "sha256": "9322820a568ec835a32ef5af7cccaa8d24dfa0c56282da30fb12cc90cbbca90e", + "bytes": 477, + "agent": "claude", + "model": null, + "outcome": "failed", + "tool_count": 0, + "duration_ms": 706, + "skills": [ + "default" + ], + "session_id": "97d627b9-c997-48c4-b976-1be8c982c2f4", + "user_input_preview": "你能设计App页面么", + "multi_turn": true, + "captured_at": "2026-05-28T15:48:00.811Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "51c95383-64b9-4d62-9f0d-ec7b2e2b3dfb", + "sha256": "e023cbf76a5322d02d68945b3d3226c8d93a00681fb22fb74632f6184160f633", + "bytes": 24941, + "agent": "opencode", + "model": "azure/gpt-5", + "outcome": "succeeded", + "tool_count": 6, + "duration_ms": 98145, + "skills": [ + "agent-browser" + ], + "session_id": "000cb696-1bd1-454d-abd7-ced41f710ed4", + "user_input_preview": "Continue the remaining unfinished tasks from the previous run. Do not redo completed work. Focus only on these unfinished todos: 1. [in_progress] Critique on 5D radar and tighten spacing/hierarchy; e", + "multi_turn": true, + "captured_at": "2026-05-28T16:20:11.832Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "5322db99-d1eb-453b-9ef9-af960b3b9bcc", + "sha256": "f3e6ecf43ecee88bcebce97fc213595865b6a47318d0545b5e870cc8c1798267", + "bytes": 34269, + "agent": "codex", + "model": "gpt-5.5", + "outcome": "succeeded", + "tool_count": 9, + "duration_ms": 158497, + "skills": [ + "agent-browser" + ], + "session_id": "23db0c86-1bac-46cd-9b85-16a154fcaf71", + "user_input_preview": "its still really busy...", + "multi_turn": true, + "captured_at": "2026-05-28T16:30:35.525Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "54ec02ee-d6d1-4400-8267-fb09f904105c", + "sha256": "d3c301ce7fbd79261bf4787f6dc3a003d28bf9bd08839d84d81e0f58470ed388", + "bytes": 117611, + "agent": "opencode", + "model": "opencode-go/qwen3.7-max", + "outcome": "succeeded", + "tool_count": 15, + "duration_ms": 401600, + "skills": [ + "default" + ], + "session_id": "b4e20f57-f2f9-4677-9d82-249aaffaa3f3", + "user_input_preview": "[form answers — task-type] - What should I build?: Prototype - Who is this for?: mvp for starting alpha test - Brand context: Pick a direction for me [value: pick_direction] - Roughly how much?: about", + "multi_turn": false, + "captured_at": "2026-05-28T16:14:26.367Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "5573d3a0-a7ef-4b0b-a395-f3733905b036", + "sha256": "aa07abcdf7bebe928a6c597aadc664e60c6ea418a06111339b55d4f126f36964", + "bytes": 30582, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 20, + "duration_ms": 231607, + "skills": [ + "ad-creative" + ], + "session_id": "4baaa2cb-68cb-4897-941f-39db5f3f835b", + "user_input_preview": "[form answers — discovery] - ¿Dónde se publicará?: Instagram post 1080×1350 - ¿A quién queremos invitar?: Padres de familia - ¿Qué categoría debe empujar?: HLG general - Llamado a la acción: Únete al", + "multi_turn": true, + "captured_at": "2026-05-26T04:21:37.911Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "576e7665-3d46-479e-b591-afdc37ff5a84", + "sha256": "97272c985d9e29e948e553fb1cd0c670f55b0ccc6f21d4d46b0c2a340c67ca6e", + "bytes": 4471, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 2, + "duration_ms": 26760, + "skills": [ + "ad-creative" + ], + "session_id": "b54e56d6-b1e0-4e24-b713-c063de5e1a14", + "user_input_preview": "yeah I wanted the same thing, but with differnet copy on it, can we do that?", + "multi_turn": true, + "captured_at": "2026-05-28T15:45:41.624Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "58d86d0a-e669-4ab3-a808-8791b4fc3dbf", + "sha256": "45e0e19c721fbb8fbc2e63e8d645ac96c871fe6a4ad3da766d5ccd82a6989c70", + "bytes": 30020, + "agent": "codex", + "model": "gpt-5.3-codex", + "outcome": "succeeded", + "tool_count": 9, + "duration_ms": 74273, + "skills": [ + "default" + ], + "session_id": "e0f9af93-631a-4a5d-9480-f6f4fb14ff23", + "user_input_preview": "VAMOS MEXER NA ABA DE EMPRESAS PARCEIRAS, ESTÁ FALTANDO A TAG: CONFIANÇA E COLOQUE A LOGO DA HAPIVIDA E SBPNL NO LUGAR DA MITUTOYO E MAG LOG, AMBAS DA MESMA COR PRA NÃO FUGIR DA NOESSA PALETA, PODE SE", + "multi_turn": false, + "captured_at": "2026-05-28T16:30:10.154Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "5b35725c-cbfe-404f-a421-e3d43f6a8f18", + "sha256": "75d5f93a95b41764df416020676905022d76edc30540f7ebfca70e696aa0157f", + "bytes": 26419, + "agent": "opencode", + "model": "opencode-go/deepseek-v4-pro", + "outcome": "succeeded", + "tool_count": 7, + "duration_ms": 82503, + "skills": [ + "default" + ], + "session_id": "1357e16b-a021-4843-873f-5c78639eb35a", + "user_input_preview": "en el header esta 2 veces el menu y eso rompe la visibilidad arreglalo quita eso", + "multi_turn": false, + "captured_at": "2026-05-28T16:24:23.225Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "5b74d70b-27b7-428f-99eb-467b2fd86b4d", + "sha256": "5f9b538c4cad5211f8029eb36dd7cd4eea6ded654e2c7953e394db6a2ae1f845", + "bytes": 3062, + "agent": "claude", + "model": "claude-sonnet-4-5", + "outcome": "failed", + "tool_count": 0, + "duration_ms": 1996, + "skills": [ + "default" + ], + "session_id": "e68b32d2-709e-4ffd-a571-7169f059b32b", + "user_input_preview": "# 📱 TASKORA — COMPLETE OPEN DESIGN UI MASTER DOCUMENT # 🎯 MAIN DESIGN GOAL Design style must feel like: ✅ Smart productivity workspace ✅ Interactive timeline dashboard ✅ Floating modern utility s", + "multi_turn": false, + "captured_at": "2026-05-28T18:31:55.503Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "5de25819-6be2-475a-bb51-b309424a3793", + "sha256": "3da365fa81be33e09b87cc7d0bd9b9c1ae85b8cc60b4b0ce08cf8a6b33e0831b", + "bytes": 7593, + "agent": "opencode", + "model": "opencode/big-pickle", + "outcome": "succeeded", + "tool_count": 2, + "duration_ms": 10969, + "skills": [ + "ad-creative" + ], + "session_id": "b282d8d9-7052-4955-998f-ae5c0c945491", + "user_input_preview": "/search", + "multi_turn": true, + "captured_at": "2026-05-28T01:40:30.156Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "5fa034f0-ff41-42c0-8e7a-8b18219ca067", + "sha256": "5715b72e5824435c9f5550bce2c39d34f7c47ecdebae6a6231c9b963c9c98f32", + "bytes": 7839, + "agent": "claude", + "model": null, + "outcome": "failed", + "tool_count": 8, + "duration_ms": 30048, + "skills": [ + "ad-creative" + ], + "session_id": "b54e56d6-b1e0-4e24-b713-c063de5e1a14", + "user_input_preview": "use the ad-creative skill please to redesigns these ads", + "multi_turn": true, + "captured_at": "2026-05-28T15:12:02.288Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "652e573b-512d-40ed-9e1b-fc81c576bb58", + "sha256": "45cacffa08a8dab6e38ac114b3cc0eaee0ecb587ddad2e93cd80d6d2e741adb5", + "bytes": 104309, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 66, + "duration_ms": 308056, + "skills": [ + "ad-creative" + ], + "session_id": "b54e56d6-b1e0-4e24-b713-c063de5e1a14", + "user_input_preview": "[form answers — psych-redesign] - Apply the full psych framework above, or pick one lever to run across all 8?: Full framework — different lever per A/B pair (recommended for real learning) - Anything", + "multi_turn": true, + "captured_at": "2026-05-28T15:20:44.901Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "66c4e9aa-1afd-4a6b-abf7-6146a818d040", + "sha256": "454641f49ca3dd0fb01ccdf81cf8cdf091ebf7caa6ce8f1eaad3e27b572e8e42", + "bytes": 37409, + "agent": "opencode", + "model": null, + "outcome": "succeeded", + "tool_count": 7, + "duration_ms": 119811, + "skills": [ + "default" + ], + "session_id": "df080b61-929d-43ee-9cbe-47b51215c953", + "user_input_preview": "Design the mobile landing hero + sticky nav for Photoganic. - Sticky top nav: \"PHOTOGANIC.\" wordmark left (heading font), anchor links right (profile, vision, products, portfolio, locations, contact);", + "multi_turn": true, + "captured_at": "2026-05-28T16:18:49.366Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "66e93859-5ed4-47d8-8fa1-e2b96167ac54", + "sha256": "56ef88efe36bf0abf2ab3c2db2131282aac48ea22df752c44213223271075a91", + "bytes": 14081, + "agent": "opencode", + "model": "opencode/deepseek-v4-flash-free", + "outcome": "succeeded", + "tool_count": 5, + "duration_ms": 49963, + "skills": [ + "algorithmic-art" + ], + "session_id": "e8b3a0ef-705c-44f1-859a-a7284c4ea16a", + "user_input_preview": "Me entregaram esse design system mas preciso pensar em 3 opções de conceitos e logos para o cliente. Me ajuda? É um estúdio de mmotion/produtora mas que usam AI para criar seus vídeos. A temática apli", + "multi_turn": false, + "captured_at": "2026-05-28T14:57:50.079Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "69afd671-5632-4a85-8108-819163e5294a", + "sha256": "541f1a46a65262da2acddd4dc13f751592541e73822b418ffc568efa9ce31e45", + "bytes": 2231, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 14019, + "skills": [ + "ad-creative" + ], + "session_id": "b54e56d6-b1e0-4e24-b713-c063de5e1a14", + "user_input_preview": "I think my mac is not allowing you to download? How to enable? because clicking the share button and export it as a pdf does not include the images", + "multi_turn": true, + "captured_at": "2026-05-28T15:35:13.989Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "6ad53dbb-44ee-4f5a-a7d1-ea9e6d8f7816", + "sha256": "c8c25c8f997badc3f1c90e49d7ff21d6f2e41e06dc00dc220d0d46c9d93b4030", + "bytes": 2394, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 18671, + "skills": [ + "ad-creative" + ], + "session_id": "b54e56d6-b1e0-4e24-b713-c063de5e1a14", + "user_input_preview": "now, how could we make a nice motion graphics as well that could fit this campaign?", + "multi_turn": true, + "captured_at": "2026-05-28T15:51:35.143Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "6affd98c-eb60-45d9-95a6-9ddad3a1f5d8", + "sha256": "f62d51c5a3e68868e0ea9768b527ca49f745c7dff693961550e20c9409ff485f", + "bytes": 928, + "agent": "gemini", + "model": "gemini-3.1-pro-preview", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 43823, + "skills": [ + "ad-creative" + ], + "session_id": "3ea05316-0c9d-47d2-9ab0-f4b2cb2b15da", + "user_input_preview": "use this logo files mpm0xns9-Logo-peoplez-digital-1.png", + "multi_turn": true, + "captured_at": "2026-05-26T13:21:15.509Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "6b5f8096-c9b0-42e7-8656-c92b161bad62", + "sha256": "89c02d613a3abdf083062489f783edc66b8393befc0a6af167f44ce96f510098", + "bytes": 603, + "agent": "gemini", + "model": "gemini-3.1-pro-preview", + "outcome": "failed", + "tool_count": 0, + "duration_ms": 14838, + "skills": [ + "ad-creative" + ], + "session_id": "3ea05316-0c9d-47d2-9ab0-f4b2cb2b15da", + "user_input_preview": "still to small, make it like 250px", + "multi_turn": true, + "captured_at": "2026-05-26T13:26:57.935Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "6c5ac01b-950c-484f-847c-e997049d42b7", + "sha256": "ec768c185ab786db5d89c8c16d9e34f2009734ec77a4e0ed7ac68350e6f406f3", + "bytes": 3988, + "agent": "cursor-agent", + "model": "composer-2-fast", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 191857, + "skills": [ + "algorithmic-art" + ], + "session_id": "8c252113-3802-4020-bb00-171c1576ebd8", + "user_input_preview": null, + "multi_turn": true, + "captured_at": "2026-05-28T15:16:44.847Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "6ef2fd62-cdb9-48f9-9cee-c4a12d5d7c99", + "sha256": "bb1f4528fb968209eaa91e8c0d8b73ee5a68581fc78b26392ad0bcacaa503fc9", + "bytes": 6129, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 2, + "duration_ms": 226577, + "skills": [ + "algorithmic-art" + ], + "session_id": "73bdc051-ad43-4bbd-8db2-9fa10f41953d", + "user_input_preview": "the corner should be expandly logo like this. the previous one corner is really good", + "multi_turn": true, + "captured_at": "2026-05-28T14:37:08.090Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "71210cec-8df6-43ad-a769-2bac5abd29a7", + "sha256": "7cbc87d152dd761bfc1c5742a9b49b46dea5b39527101b133bb3420dd21c31fe", + "bytes": 12227, + "agent": "opencode", + "model": "opencode/big-pickle", + "outcome": "succeeded", + "tool_count": 5, + "duration_ms": 22137, + "skills": [ + "video-hyperframes" + ], + "session_id": "c9a3e11b-ea5a-45ae-bcbd-5aab9ae204a3", + "user_input_preview": "sim quewro um video 16:9", + "multi_turn": true, + "captured_at": "2026-05-28T16:30:27.869Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "71ae54cb-ac92-4199-8524-de95f4eddb74", + "sha256": "67422e98350d24c1fde9484092bee42419e5979211c86720fc7d42339884c9aa", + "bytes": 29529, + "agent": "claude", + "model": "default", + "outcome": "failed", + "tool_count": 20, + "duration_ms": 752672, + "skills": [ + "algorithmic-art" + ], + "session_id": "7f2343d7-07f6-4d71-89bf-6a86ade4bb8d", + "user_input_preview": "[form answers — discovery] - Que veux-tu que je produise ?: Une variante créative différente (nouvelle direction) - Quelle(s) vue(s) générer ?: Kit complet (maillot + short) - Corrections vs. les visu", + "multi_turn": true, + "captured_at": "2026-05-28T15:42:38.685Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "728e40bd-2ead-4171-8e78-f127ea065b7b", + "sha256": "48d0857e9a5b3d93c45609416793f07a1ed9bf00a1ff04c9d5abb8c2bd709dfd", + "bytes": 11150, + "agent": "copilot", + "model": "gpt-5.2-codex", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 627740, + "skills": [ + "ad-creative" + ], + "session_id": "d3eb9812-b37f-4b49-93a5-f336a21b4b33", + "user_input_preview": "[form answers — discovery] - What kind of prototype is this?: Landing / marketing page - Target platform: Responsive web, Desktop web - Roughly how much?: you decide - Key content or sections to inclu", + "multi_turn": true, + "captured_at": "2026-05-28T08:31:04.996Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "74ed5b5a-4514-4d69-ab59-1060b842ac42", + "sha256": "00b518710cb390c16cd8223797d9d2ce2be39e8d38d3fdd266994fc3d774fb37", + "bytes": 580, + "agent": "deepseek", + "model": null, + "outcome": "failed", + "tool_count": 0, + "duration_ms": 430, + "skills": [ + "agent-browser" + ], + "session_id": "7a1dc886-2445-4dee-aa8b-88f91b8430e3", + "user_input_preview": "revisa este cuadro de precios para una estrategia de ventas en eventos sociales (bodas)", + "multi_turn": true, + "captured_at": "2026-05-28T16:31:41.576Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "7718bfa3-c6e6-4333-928c-f1364c18ba00", + "sha256": "8c5eb1e51d9c4fed46141760676cd73ed312c88a8d00f74915904173b4f21d73", + "bytes": 32141, + "agent": "opencode", + "model": "opencode-go/qwen3.7-max", + "outcome": "succeeded", + "tool_count": 6, + "duration_ms": 190905, + "skills": [ + "algorithmic-art" + ], + "session_id": "6f4bc605-2293-47f4-babe-3248a051fab1", + "user_input_preview": "[form answers — task-type] - What should I build?: Live artifact - Image model: gpt-image-2 - Aspect ratio: 1:1 - Visual style: Exploded / deconstructed — cubes floating apart - Any constraints?: (ski", + "multi_turn": true, + "captured_at": "2026-05-28T15:59:26.769Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "7811c5b8-d75e-48dc-91e3-88ed42856a18", + "sha256": "365e0350c61ba26b5cf9556ba62a5d3b036bc153fbe46312f7c84e239f820158", + "bytes": 30190, + "agent": "codex", + "model": "default", + "outcome": "succeeded", + "tool_count": 10, + "duration_ms": 160041, + "skills": [ + "agent-browser" + ], + "session_id": "5db5aadf-1c22-42c6-b7da-6cc97a153997", + "user_input_preview": "あと、本文記事が右下の端っこなので小さく見づらいわかりずらいです、URLを貼るところと記事一覧などをマトマタ処においたり、少し狭めて、本文を大きくして欲しい", + "multi_turn": false, + "captured_at": "2026-05-28T16:20:05.931Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "7a644531-42e4-4dfd-9071-e8b3787fda42", + "sha256": "907ae23a18f9a0c8ba548bffcad3dc04594a5818ec80a185cbf267be7674d0b4", + "bytes": 846, + "agent": "gemini", + "model": null, + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 35251, + "skills": [ + "algorithmic-art" + ], + "session_id": "73dd5a51-118c-45e6-aa7a-680ab6795510", + "user_input_preview": "They are done", + "multi_turn": true, + "captured_at": "2026-05-28T15:28:26.831Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "7d2fe0ea-2dfc-452e-b6d8-e60fc4daec83", + "sha256": "4721a269add1b2620a61ed552fb4baacc6cfe07b1a2f1e1b2f16cc63caa9dc1d", + "bytes": 63149, + "agent": "opencode", + "model": "opencode/mimo-v2.5-free", + "outcome": "succeeded", + "tool_count": 12, + "duration_ms": 210246, + "skills": [ + "default" + ], + "session_id": "ff030e8b-138a-43fb-880a-d7cd3fab0cc8", + "user_input_preview": "the notes related icons should flow in the background. and the color blobs are sticky, they should be floating, even when no scroll happens. And 1 big pencil whch does different action when scrolled,", + "multi_turn": false, + "captured_at": "2026-05-28T16:29:25.113Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "80f9124b-be2b-4a6b-bb7d-647d523c10ac", + "sha256": "602f299f7a5c81c4571846ab76617f79f2f09d59fa68862cc493eed2ec47fb52", + "bytes": 100352, + "agent": "codex", + "model": "gpt-5.3-codex", + "outcome": "succeeded", + "tool_count": 21, + "duration_ms": 395507, + "skills": [ + "agent-browser" + ], + "session_id": "bf4a6929-1fc1-4c41-bf43-2750e158d58e", + "user_input_preview": "[form answers — discovery] - Any sharper audience slice?: fitness coaches - Which screens should I deliver first?: all - Priority user flows: all - Copy/content source: Use the PRD as primary copy sou", + "multi_turn": false, + "captured_at": "2026-05-28T16:10:34.189Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "8222687a-3886-421c-811f-ef8cf127fb37", + "sha256": "656b66b383947715d5dbfa6a78861dcf3814a54aa36b165bfd8136e545272df0", + "bytes": 2716, + "agent": "claude", + "model": "sonnet", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 37924, + "skills": [ + "default" + ], + "session_id": "bc17a7ca-299f-4c4f-b8c4-f6ff8663a50c", + "user_input_preview": "\"My memory already flags this — I have a saved memory note that says: \"User wants to see where that message is.\" That note exists because you apparently raised this concern before — which means this a", + "multi_turn": true, + "captured_at": "2026-05-28T15:49:13.895Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "88be3ec6-164a-4fd1-bfdc-41db48a93f5b", + "sha256": "54e187263c0f3780947f96d8d6bc93253ddd2eb02981a4bb0eb6f4e486fe6241", + "bytes": 5792, + "agent": "codex", + "model": null, + "outcome": "failed", + "tool_count": 2, + "duration_ms": 771177, + "skills": [ + "algorithmic-art" + ], + "session_id": "73bdc051-ad43-4bbd-8db2-9fa10f41953d", + "user_input_preview": "from the expandly-poster-faq-link, add orange tone to it. this one is much more cleaner than the recent one. also the clarification at the bottom should be 1 liner", + "multi_turn": true, + "captured_at": "2026-05-28T14:51:41.458Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "8a03cb14-c398-4e68-8ee5-a580b2836167", + "sha256": "ecdc578780042c947d9302ac7d509ac51a05205375b6b6373a139b1c9d8b4b1e", + "bytes": 43982, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 10, + "duration_ms": 827107, + "skills": [ + "default" + ], + "session_id": "a4a0e73e-5ce6-4e5a-aaa2-6470d99b38fd", + "user_input_preview": "[form answers — task-type] - What should I build?: Live artifact - Which surface(s)?: S4 — /dashboard (ActionQueue + domain grid) - Fidelity target: High-fidelity (production-ready HTML, full design s", + "multi_turn": false, + "captured_at": "2026-05-28T16:07:24.119Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "8be934bd-1ec3-44e5-9a67-f65c18524409", + "sha256": "9a69b2975e1afa207bc849462252d6bd193b4d1f1b79ef9bced13f2134dc4116", + "bytes": 519, + "agent": "gemini", + "model": null, + "outcome": "failed", + "tool_count": 0, + "duration_ms": 147640, + "skills": [ + "algorithmic-art" + ], + "session_id": "9adb48c8-0744-47ee-a408-1f037c56d5aa", + "user_input_preview": "Please try again", + "multi_turn": true, + "captured_at": "2026-05-28T14:53:27.403Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "8c393b12-cf8d-4e2f-881c-bcd26ed1848e", + "sha256": "43afca261f8b9fb9559bd9849747da9b21ac5abd936abd10fb8197d0a51e3342", + "bytes": 18126, + "agent": "opencode", + "model": "opencode/big-pickle", + "outcome": "succeeded", + "tool_count": 5, + "duration_ms": 58614, + "skills": [ + "algorithmic-art" + ], + "session_id": "125ac378-0e9e-463e-bc90-91ee5f6a4e1e", + "user_input_preview": "Вставь в карточки фотографии из интернета", + "multi_turn": true, + "captured_at": "2026-05-28T14:51:23.492Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "8d9d8101-0cbb-4416-b204-918fc96af999", + "sha256": "adfb5c55f58e51063d9cb8dbea9a3035f0148dd156e253b74ddd1b9d6e431ef1", + "bytes": 2033, + "agent": "gemini", + "model": "gemini-3.1-pro-preview", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 445704, + "skills": [ + "ad-creative" + ], + "session_id": "3ea05316-0c9d-47d2-9ab0-f4b2cb2b15da", + "user_input_preview": "Take a look, it's so breaking", + "multi_turn": true, + "captured_at": "2026-05-28T14:46:43.498Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "8e3f6786-9041-42f9-9de8-b9fe5734493d", + "sha256": "0c7a8a3f711dbf1b7ab35bad1891d782f47326066395812af6b07f2e574f97fc", + "bytes": 5391, + "agent": "codex", + "model": "gpt-5.5", + "outcome": "failed", + "tool_count": 0, + "duration_ms": 46103, + "skills": [ + "default" + ], + "session_id": "e07b2547-0be5-41b8-8654-0476d5fea61b", + "user_input_preview": "下面这版按「可以拉产品、设计、研发、算法、运营一起过需求评审」的粒度来写。它不是完整技术 PRD,但已经包含目标、范围、流程、规则、页面、数据、风控和排期,足够进入评审讨论。 **需求名称** **脉脉愚人节活动:大厂生存局** **一句话** 用户扮演一名空降大厂的普通员工,通过 9 轮高压职场选择,在组织危机中求生,最终尝试晋升为部门负责人。高失败率制造胜负欲,失败和通关结果都可分享至社区", + "multi_turn": false, + "captured_at": "2026-05-28T16:31:03.752Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "8eea78fd-1ec3-4b57-a432-f3f2839ff8c9", + "sha256": "4430039ae9fdfd874f5620e1639b5560afcc45b3843ab4add6ca578fb38e9c55", + "bytes": 5066, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 6, + "duration_ms": 25891, + "skills": [ + "agent-browser" + ], + "session_id": "d89144a7-acfe-4d59-a732-b7737e73f165", + "user_input_preview": "Apply the --accent swap to the footer surface? Yes — swap to --accent at 4%", + "multi_turn": true, + "captured_at": "2026-05-28T15:49:57.514Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "8f93cb40-495e-43b7-9940-b99f080aca8f", + "sha256": "e64c2a32d00d4e0734454b3f52d3b23714a22923afb6750d399c83991639a334", + "bytes": 6843, + "agent": "opencode", + "model": "opencode/big-pickle", + "outcome": "succeeded", + "tool_count": 2, + "duration_ms": 16404, + "skills": [ + "algorithmic-art" + ], + "session_id": "125ac378-0e9e-463e-bc90-91ee5f6a4e1e", + "user_input_preview": "ВИП карточки, фон самих карт сделай чуть темнее", + "multi_turn": true, + "captured_at": "2026-05-28T14:54:49.872Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "98d2b062-ac29-4df6-8763-38df3eed138e", + "sha256": "a40cf3fd59b4e33bf1a2255b99e2dbbce02351b4b29d653db00f177f29afc651", + "bytes": 71465, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 33, + "duration_ms": 333203, + "skills": [ + "agent-browser" + ], + "session_id": "2cafde7f-3be0-41c4-8497-b1484c34eb9d", + "user_input_preview": "Crie o protótipo dessa tela", + "multi_turn": false, + "captured_at": "2026-05-28T16:13:44.089Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "9a9522ec-575f-432f-aeed-efc491e900aa", + "sha256": "5cc1b9b198a5ced430438bcbc23c3c861254862a88bab8ee2cedeca700ccf93e", + "bytes": 23671, + "agent": "opencode", + "model": "opencode/deepseek-v4-flash-free", + "outcome": "succeeded", + "tool_count": 7, + "duration_ms": 73681, + "skills": [ + "default" + ], + "session_id": "efd35882-da45-49d0-b730-db507f7bdca6", + "user_input_preview": "Necesito que lo dejes mas minimalista y con animaciones para que quede mas profesional.", + "multi_turn": true, + "captured_at": "2026-05-28T16:29:26.125Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "9b6d85bb-5d36-4dfd-85ae-9169882fe0ea", + "sha256": "5c09596ee4edd6215f080d5a15b39f91067ba586e2a717d4bf8cb8f76e36a596", + "bytes": 2813, + "agent": "qwen", + "model": "qwen3-coder-plus", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 51439, + "skills": [ + "ad-creative" + ], + "session_id": "0a51c2ff-bc6c-4d0e-bdb7-ae8bfaf4fe00", + "user_input_preview": "Crea iconos vectoriales , al Estilo Apple y Microsoft en Formato SVG descargables que sean minimalistas y editables en cualquier programa vectorial los iconos serian , Home , Configuracion, Libro, Web", + "multi_turn": true, + "captured_at": "2026-05-27T23:33:39.382Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "9c38efe9-e27e-479b-a9a9-a7e7d2e4fec5", + "sha256": "8373b0f3fbccfb69e623297d1dc947bb65b8d5aa0e6de6c0928db65ace443855", + "bytes": 1119, + "agent": "gemini", + "model": null, + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 46117, + "skills": [ + "algorithmic-art" + ], + "session_id": "73dd5a51-118c-45e6-aa7a-680ab6795510", + "user_input_preview": "Choose the most suitable one, I am new to this", + "multi_turn": true, + "captured_at": "2026-05-28T15:30:57.503Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "9d148493-79fb-4d3b-a9ca-a8224effdfca", + "sha256": "106108e4ba89a3c8081b5cb3e7519b4a3822b0b8cd025712c202f800be5b484b", + "bytes": 1686, + "agent": "gemini", + "model": "gemini-3.1-pro-preview", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 244277, + "skills": [ + "ad-creative" + ], + "session_id": "3ea05316-0c9d-47d2-9ab0-f4b2cb2b15da", + "user_input_preview": "remove this SMM-S2 Studio Cyan-to-teal gradient with centered typography and standardized vertical spacing. Hook / Big Stat 3 JAM/HARI Use a slash (/) to create a white sub-element automatically. Mai", + "multi_turn": true, + "captured_at": "2026-05-28T14:29:35.777Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "9d726d33-cb93-487c-92e5-dab5c026d796", + "sha256": "861af361c4224bc0abc8377b27d93a3aa97d4ea617162abb73db2c1dae4da7d2", + "bytes": 30701, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 14, + "duration_ms": 130284, + "skills": [ + "ad-creative" + ], + "session_id": "b54e56d6-b1e0-4e24-b713-c063de5e1a14", + "user_input_preview": "I need an a4? and the images are also not there???", + "multi_turn": true, + "captured_at": "2026-05-28T15:36:15.248Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "9e3d1b49-5e9d-4133-ae2e-9f42806c0164", + "sha256": "58eecf2c6c8d800a4b2ef31078436d085942dcd3d73239479566ff89dc1de979", + "bytes": 1245, + "agent": "claude", + "model": "sonnet", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 20942, + "skills": [ + "default" + ], + "session_id": "bc17a7ca-299f-4c4f-b8c4-f6ff8663a50c", + "user_input_preview": "the question \"why did you create two\" is still unanswered. why did you do it?", + "multi_turn": true, + "captured_at": "2026-05-28T15:45:14.277Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "9f08d810-562a-4b06-ace0-7c94b8cfa20e", + "sha256": "83ae59c19aa1a0dc200bc971e4167af5b4c609b43af206ce3c485d0daecec087", + "bytes": 64235, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 24, + "duration_ms": 427792, + "skills": [ + "default" + ], + "session_id": "c64c0d19-9e6c-465c-ad34-94e17827c04f", + "user_input_preview": "看下所有页面的按钮的icon 是否显示正常 尤其是 hover 的时候", + "multi_turn": true, + "captured_at": "2026-05-28T16:29:19.493Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "a1175805-8b79-4991-a669-3444d991883f", + "sha256": "1e8f9e1226e20e5b08f7073990f73de892ce941e8f251be44f1a08bd9289abb1", + "bytes": 79323, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 25, + "duration_ms": 190371, + "skills": [ + "default" + ], + "session_id": "976333ac-966e-49bc-a5e7-d6c903bd1a01", + "user_input_preview": "继续", + "multi_turn": true, + "captured_at": "2026-05-28T16:33:07.209Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "a1218c0a-d660-427c-b77b-7cc175eb959e", + "sha256": "bdabd26b8868ca10a30cb4adf0cf146d19cfff4c1b3454793727f7965aab26e9", + "bytes": 3295, + "agent": "cursor-agent", + "model": "composer-2-fast", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 144059, + "skills": [ + "algorithmic-art" + ], + "session_id": "8c252113-3802-4020-bb00-171c1576ebd8", + "user_input_preview": "We need clear visible borders around the US - otherwise nobody will realize that this is actually a map of the US.", + "multi_turn": true, + "captured_at": "2026-05-28T15:41:26.917Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "a132d324-32d0-4c85-baff-474c33c4bc2d", + "sha256": "66810815983c60db0d4d38fd73e6d04de302c5c0adaa80d535556bb400183f64", + "bytes": 43846, + "agent": "claude", + "model": "default", + "outcome": "failed", + "tool_count": 20, + "duration_ms": 881748, + "skills": [ + "ad-creative" + ], + "session_id": "8fbb6ad4-4c3d-4745-956c-502b09af5e2f", + "user_input_preview": "hay problemas en el sentido de. primero quiero que haya flujo en la pagina web, y en esto lo digo porque si agrego algo al carrito este se tiene que ser a traves del boton del producto no en la imagen", + "multi_turn": true, + "captured_at": "2026-05-28T02:04:21.831Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "a2134bb6-8c2d-4484-87d6-7bd8826f690c", + "sha256": "211a553bc526271a8c669436b0f058922f5d03c7b1f3b45d45eb791d7d2cc6ca", + "bytes": 112807, + "agent": "opencode", + "model": "opencode/deepseek-v4-flash-free", + "outcome": "succeeded", + "tool_count": 32, + "duration_ms": 322604, + "skills": [ + "data-report" + ], + "session_id": "354c836a-be1b-4bdf-9588-cb4d83beb2eb", + "user_input_preview": "后端需要加入超级管理员与用户独立的管理页面", + "multi_turn": false, + "captured_at": "2026-05-28T16:25:49.899Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "a5475efd-5f5d-4706-a7b6-5f34bd1329c8", + "sha256": "549155aa85e2cebe15bdbb2fca8507af8ac098b042a6e9ed0d2feaf417c3500f", + "bytes": 731, + "agent": "cursor-agent", + "model": "composer-2.5-fast", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 161796, + "skills": [ + "ad-creative" + ], + "session_id": "routine-conv-d34f70ac-9cd8-41fb-9183-2b6263862d7c", + "user_input_preview": "https://nfsauto.by/design-system нужно переделать эту дизайн систему на всех вкладках", + "multi_turn": false, + "captured_at": "2026-05-26T09:07:05.083Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "a70bf270-bb40-4e06-8734-9f05aef72d3b", + "sha256": "6d8d58cf5f252e24d46d6e08a1653b04ed070f4813fe7759d1a059beb577d32d", + "bytes": 593, + "agent": "claude", + "model": null, + "outcome": "failed", + "tool_count": 0, + "duration_ms": 3141, + "skills": [ + "default" + ], + "session_id": "75ab5caa-6b7d-4d01-ac03-b2ece54e9398", + "user_input_preview": "صمملي ديزاين موقع موسيقي وبي اياه بيسك مع زخارف عربيه", + "multi_turn": true, + "captured_at": "2026-05-28T16:34:34.517Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "a88405ce-7adf-4847-9f5b-936818a2314c", + "sha256": "576495aa106d0afc5db538ea056bae7e341acbba60ffc45334858b6bf9cd9493", + "bytes": 5469, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 6, + "duration_ms": 85968, + "skills": [ + "ad-creative" + ], + "session_id": "b54e56d6-b1e0-4e24-b713-c063de5e1a14", + "user_input_preview": "did you exported it?", + "multi_turn": true, + "captured_at": "2026-05-28T15:32:46.574Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "a89f2c3b-a94a-45b3-9a51-3e8efa0936b2", + "sha256": "30b3d89ab8b5a71f7608eb7f0d485222056d3972ada45f2948370468b62ff973", + "bytes": 30430, + "agent": "codex", + "model": null, + "outcome": "failed", + "tool_count": 12, + "duration_ms": 328153, + "skills": [ + "default" + ], + "session_id": "2b58dcbf-fca8-4bd8-8fd6-70a74b5819e5", + "user_input_preview": "[form answers — task-type] - What should I build?: Image - Who is this for?: 喜欢diy,喜欢送礼 - Brand context: Pick a direction for me [value: pick_direction] - Roughly how much?: 3000*3000 - Any important", + "multi_turn": false, + "captured_at": "2026-05-28T16:26:50.200Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "a8e51f72-982d-4b39-b8c9-86c9457fe61b", + "sha256": "b7c89d4a0f127d65fe12755ec27014ff6a0bff7528bfe5c5888f3a2718dc2160", + "bytes": 2234, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 23273, + "skills": [ + "default" + ], + "session_id": "5db542cb-eaae-46cc-a255-c5bbb0c75f37", + "user_input_preview": null, + "multi_turn": false, + "captured_at": "2026-05-28T15:45:14.061Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "a964326c-49dd-4aae-bbb8-fab3e9b16599", + "sha256": "67a76a9421ef62367a3c8b3e4ce42823221172de7d1a9a527707de91aef79ff5", + "bytes": 64131, + "agent": "codex", + "model": "gpt-5.5", + "outcome": "succeeded", + "tool_count": 31, + "duration_ms": 278476, + "skills": [ + "default" + ], + "session_id": "f67e8485-f269-41e2-87f6-0172a9bba9f9", + "user_input_preview": "continue", + "multi_turn": false, + "captured_at": "2026-05-28T16:28:54.878Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "a97cc987-dfb6-4e3d-956e-f7c3d82d4951", + "sha256": "254e197af6c06d9c28d6f1e45e7f45d6b5928715c8cd17bfd468463c283f9823", + "bytes": 1156, + "agent": "gemini", + "model": "gemini-3.1-pro-preview", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 176836, + "skills": [ + "ad-creative" + ], + "session_id": "3ea05316-0c9d-47d2-9ab0-f4b2cb2b15da", + "user_input_preview": "the logo is to small the logo is to small", + "multi_turn": true, + "captured_at": "2026-05-26T13:23:24.552Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "ad17d1ee-cc5d-4cee-9738-ea93fafa27ee", + "sha256": "04485bb328812272a166ec01368324226f89b2d66cdca02913f72d1fe4292312", + "bytes": 74147, + "agent": "opencode", + "model": "opencode/big-pickle", + "outcome": "succeeded", + "tool_count": 30, + "duration_ms": 256366, + "skills": [ + "video-hyperframes" + ], + "session_id": "c9a3e11b-ea5a-45ae-bcbd-5aab9ae204a3", + "user_input_preview": "Continue the remaining unfinished tasks from the previous run. Do not redo completed work. Focus only on these unfinished todos: 1. [in_progress] Init HyperFrames scaffold via npx hyperframes init 2.", + "multi_turn": true, + "captured_at": "2026-05-28T16:31:17.472Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "ae5f17a4-dee1-4e30-9a5b-e9457e76743a", + "sha256": "a8afa505a924cbf2b0b24830c2f7b65cf30c84205f774af36350ded9aa598c6c", + "bytes": 90685, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 31, + "duration_ms": 382694, + "skills": [ + "agent-browser" + ], + "session_id": "899d9e9f-0cb6-4429-b975-450f61400d7e", + "user_input_preview": "首页风格你在优化下,读取图片", + "multi_turn": true, + "captured_at": "2026-05-28T16:22:56.001Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "b11a039d-5d8c-4035-a6c2-f49e94ed27d2", + "sha256": "39993e13cbd648b7424636956e5a53f624903d9af28afc114d2667611c71dbbb", + "bytes": 525, + "agent": "deepseek", + "model": null, + "outcome": "failed", + "tool_count": 0, + "duration_ms": 182, + "skills": [ + "agent-browser" + ], + "session_id": "7a1dc886-2445-4dee-aa8b-88f91b8430e3", + "user_input_preview": "rediseña este cuadro de precios", + "multi_turn": true, + "captured_at": "2026-05-28T16:32:56.479Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "b2ae079f-ed93-4dbf-8489-b13d50ac4278", + "sha256": "4345a20d92b6c28fd3047623725c0dedebf3c238c7a1faca83561d1fe73f2738", + "bytes": 59155, + "agent": "codex", + "model": "gpt-5.5", + "outcome": "succeeded", + "tool_count": 18, + "duration_ms": 325515, + "skills": [ + "default" + ], + "session_id": "72e59a1e-6354-4acb-88f8-e4eabae3dadb", + "user_input_preview": "工单里还是要显示已分配和未分配工单", + "multi_turn": true, + "captured_at": "2026-05-28T16:22:04.372Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "b2ff83be-3d63-4b9b-b845-98e8163eda43", + "sha256": "3a50f6100734d1695b3540e370c4ee935c9ab482787e260ee9193bfad645f7ae", + "bytes": 2191, + "agent": "gemini", + "model": null, + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 110613, + "skills": [ + "algorithmic-art" + ], + "session_id": "73dd5a51-118c-45e6-aa7a-680ab6795510", + "user_input_preview": "Please create an A5 Flyer to be digitally distributed, the Topic: Invite to professionals and companies to showcase their knowledge in an exhibition panel guided by a host. Audience is: Entrepreneurs", + "multi_turn": true, + "captured_at": "2026-05-28T15:17:07.162Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "b5f436ad-6928-4170-a960-90cab38e0b5e", + "sha256": "d5caa89af8bee6b1e438713ba96263b343608ff37839087de65f8ac15b14ea52", + "bytes": 29407, + "agent": "opencode", + "model": "ollama-proxy/kimi-k2.6", + "outcome": "succeeded", + "tool_count": 11, + "duration_ms": 452709, + "skills": [ + "agent-browser" + ], + "session_id": "81526dba-0a4c-4805-a731-ed8f9b4f0639", + "user_input_preview": "continue. adding logo black-sm - for light bg white-sm - for dark bg", + "multi_turn": true, + "captured_at": "2026-05-28T16:27:47.660Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "b70edeb2-47eb-4dc2-bbc9-628062672ba5", + "sha256": "081a135ebad953a7dd7bf198b43a974327e49349f5aba949218a9a9fbafb2b30", + "bytes": 148171, + "agent": "codex", + "model": "default", + "outcome": "succeeded", + "tool_count": 42, + "duration_ms": 605291, + "skills": [ + "default" + ], + "session_id": "1a316879-148c-49cf-87fd-c2893f0c9bbd", + "user_input_preview": "或者唔洗分開顯示語言同翻譯語言。直接前後5個語言,然後所有版面統一語言就可以。 新增一餐嗰到未有AI生成功能。 食譜個AI生成食譜內容唔需要顯示屋企情況。 同埋我要一鍵生成一週餐單。", + "multi_turn": false, + "captured_at": "2026-05-28T16:25:44.964Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "b84bfafb-f17b-4d32-ae4e-dfaa11a1422b", + "sha256": "4dec4453ddc8597dc1e197e3d04f4605f820b4905dddca7172823b9e64a753f0", + "bytes": 64394, + "agent": "codex", + "model": "gpt-5.5", + "outcome": "succeeded", + "tool_count": 26, + "duration_ms": 279992, + "skills": [ + "default" + ], + "session_id": "106b1ee4-0f94-4e32-831d-9b14b720ceb3", + "user_input_preview": "i wanted mobile scrren designs you designed webaite plz check", + "multi_turn": true, + "captured_at": "2026-05-28T16:27:19.574Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "b9506214-9b49-4a52-be09-b61f7e694236", + "sha256": "648c6ad831cf53ca01850ce1e517b35b00f762984a15053318e59b9daa50f112", + "bytes": 16637, + "agent": "gemini", + "model": "gemini-3.1-pro-preview", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 187269, + "skills": [ + "ad-creative" + ], + "session_id": "3ea05316-0c9d-47d2-9ab0-f4b2cb2b15da", + "user_input_preview": "Create meta ads creative: ### SMM-S2 — \"3 Jam Sehari Bukan Tugasmu\" **Angle:** Pain — Time Drain | **Format:** Static 1080×1350px and 1080x1920px #### Visual Direction - **Layout:** Bold typograph", + "multi_turn": true, + "captured_at": "2026-05-26T13:08:26.553Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "ba224f2c-8d1f-4fbb-8ac7-56780fdcce39", + "sha256": "dba8efcc1f02b149c228b4e2d88ca066d3d76ef433aa3844283be6a31c611f16", + "bytes": 12421, + "agent": "opencode", + "model": null, + "outcome": "succeeded", + "tool_count": 6, + "duration_ms": 44421, + "skills": [ + "default" + ], + "session_id": "df080b61-929d-43ee-9cbe-47b51215c953", + "user_input_preview": "untuk foto logo mohon gunakan ini", + "multi_turn": true, + "captured_at": "2026-05-28T16:22:49.159Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "bb8a7a42-6e09-4684-bb14-71e3d2c52eed", + "sha256": "710aa0a916fc1f56866305c28958cfdbf33d4745f7cf367ce2c76b5adafaca2a", + "bytes": 17729, + "agent": "gemini", + "model": "default", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 176651, + "skills": [ + "ad-creative" + ], + "session_id": "3ea05316-0c9d-47d2-9ab0-f4b2cb2b15da", + "user_input_preview": "for the whatsapp icon, please make it better", + "multi_turn": true, + "captured_at": "2026-05-26T13:51:54.149Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "bc6fcb3b-b700-45f6-aafb-bce68b9f0176", + "sha256": "5b3b8ca8a8eac1ad71726a5a574859bafd36ede160c28fc3d826d040116cec25", + "bytes": 1756, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 2, + "duration_ms": 27243, + "skills": [ + "agent-browser" + ], + "session_id": "d89144a7-acfe-4d59-a732-b7737e73f165", + "user_input_preview": "i didnt notice a change. should it have updated the preview?", + "multi_turn": true, + "captured_at": "2026-05-28T15:49:16.635Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "bccb5763-363b-4749-be1a-b51b17fe9b0c", + "sha256": "aed99293df4929f5405df56b0b46afcb224c1cd260f6234b9d7cc9aa92629f43", + "bytes": 477, + "agent": "claude", + "model": null, + "outcome": "failed", + "tool_count": 0, + "duration_ms": 795, + "skills": [ + "default" + ], + "session_id": "97d627b9-c997-48c4-b976-1be8c982c2f4", + "user_input_preview": "你能设计App页面么", + "multi_turn": true, + "captured_at": "2026-05-28T15:45:48.628Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "bd2db863-956d-4f8d-87bb-36a864b1d61c", + "sha256": "7b0ae36e7d939149f9f39e67238d3dbe18d8e84b145e6e6e24155831f731ca3e", + "bytes": 1185, + "agent": "gemini", + "model": "gemini-3.1-pro-preview", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 108076, + "skills": [ + "ad-creative" + ], + "session_id": "3ea05316-0c9d-47d2-9ab0-f4b2cb2b15da", + "user_input_preview": "use logo files in design files folder", + "multi_turn": true, + "captured_at": "2026-05-26T13:12:44.475Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "bddc3c35-d642-484b-aee8-3c131f11dea9", + "sha256": "36d6da3064958c8b99752a5c203d2b1f67918329c5e64336092392b2a5fee471", + "bytes": 60202, + "agent": "opencode", + "model": "azure/gpt-5", + "outcome": "succeeded", + "tool_count": 8, + "duration_ms": 385510, + "skills": [ + "agent-browser" + ], + "session_id": "000cb696-1bd1-454d-abd7-ced41f710ed4", + "user_input_preview": "当前设计过于简单。请严格按照如下需求进行设计,不要遗漏细节:1. 地图制作 地图编辑页面是对二维地图进行编辑与管理。页面功能主要包括:地图管理、图层管理、地图编辑、量算工具、地图发布、地图预览、系统设置。 1.1. UI整体布局 地图窗口全屏显示,功能菜单显示到顶栏区域。点击菜单在地图区域左侧切换显示对应的管理面板。菜单同时为管理面板的显隐开关,面板隐藏时点击菜单则显示,面板显示时点击菜单则隐藏。", + "multi_turn": true, + "captured_at": "2026-05-28T16:26:10.402Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "bf4678a2-5510-48fd-b20d-999687c54b12", + "sha256": "638af861c828008950d05bee3b33419a4e20f289b90dba9946f227e64fc9044c", + "bytes": 546, + "agent": "antigravity", + "model": "gemini-3.5-flash", + "outcome": "failed", + "tool_count": 0, + "duration_ms": 952, + "skills": [ + "default" + ], + "session_id": "4131bd71-386d-4c2d-b8a4-45c5014c5a58", + "user_input_preview": "你能做什么?教教我。", + "multi_turn": false, + "captured_at": "2026-05-28T16:35:02.214Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "c1153633-3f74-4524-bfd8-15d8a58f2fac", + "sha256": "ca65d06c5cc54acf22eb2acc1c4a7e742b8f4897a25f9e925bec9408e705ea23", + "bytes": 12146, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 5, + "duration_ms": 42705, + "skills": [ + "ad-creative" + ], + "session_id": "4baaa2cb-68cb-4897-941f-39db5f3f835b", + "user_input_preview": null, + "multi_turn": true, + "captured_at": "2026-05-26T04:28:24.708Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "c3bf9cb1-03fa-4fb7-ba25-090cd4c2f1a2", + "sha256": "a7ea696841b631ed3d40a6c040287f5f2d284fb08b715187c726edfdfbff4046", + "bytes": 22084, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 16, + "duration_ms": 155788, + "skills": [ + "default" + ], + "session_id": "1a669d6e-5cfd-4a77-af4c-7aae550d48d5", + "user_input_preview": "顶上的,日期切换,改成课程预约。老师板块去掉。", + "multi_turn": false, + "captured_at": "2026-05-28T15:47:50.307Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "c4e6c7d0-a162-4c97-a083-2be853e9e028", + "sha256": "b9fbb5d9eaedd6f9692a7d660cf93cf9fc3b450c3e16a9fc8cb9747426b60352", + "bytes": 43586, + "agent": "opencode", + "model": "opencode/deepseek-v4-flash-free", + "outcome": "succeeded", + "tool_count": 7, + "duration_ms": 367707, + "skills": [ + "ad-creative" + ], + "session_id": "5a122929-f7a9-4a4e-bad5-5eb9f8a52d51", + "user_input_preview": "re design the whole page , as i am not able to see any cards , keep all in a same page without it being reveled by clicking some button", + "multi_turn": false, + "captured_at": "2026-05-26T06:05:28.121Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "c531816e-95cc-4fb2-ba03-174e8f1dbe3b", + "sha256": "3a2e8cddce4f1015da01201cea29c94ea405f126f7715b5cb915a4145abde7bc", + "bytes": 18515, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 14, + "duration_ms": 58371, + "skills": [ + "ad-creative" + ], + "session_id": "b54e56d6-b1e0-4e24-b713-c063de5e1a14", + "user_input_preview": "Still fucking empty", + "multi_turn": true, + "captured_at": "2026-05-28T15:39:08.782Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "c7266eeb-fa45-46af-a700-6392dde65702", + "sha256": "da6d796f35bcedc61e6479247e6285766df731f2974608e9e214853affa5dbd7", + "bytes": 6831, + "agent": "opencode", + "model": "opencode/big-pickle", + "outcome": "succeeded", + "tool_count": 2, + "duration_ms": 13391, + "skills": [ + "algorithmic-art" + ], + "session_id": "125ac378-0e9e-463e-bc90-91ee5f6a4e1e", + "user_input_preview": "обводку вип карточек менее яркую", + "multi_turn": true, + "captured_at": "2026-05-28T14:55:31.294Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "c7a33c84-ef9f-49c4-96e3-84ac3232d3db", + "sha256": "de9590fc30590b54283179bb967d45a123f18484aa0c5f5b4528fd27a583e2f7", + "bytes": 1329, + "agent": "claude", + "model": "sonnet", + "outcome": "failed", + "tool_count": 0, + "duration_ms": 5746, + "skills": [ + "agent-browser" + ], + "session_id": "3a3aeb61-2638-4971-8181-75a40b800eb9", + "user_input_preview": "我的界面,你把太多东西都给舍弃掉了,这不行。我们这个辩论胜场得票文章获赞,还有关注和AI,这几个肯定是要有的。那个AI里面隐藏了一个很重要的功能,就是AI助手,相当于一个Deepseek放在了里面。这个AI使用量,这个做的好。还有上面这些东西都可以,头像、姓名什么的都可以。然后按照我说的调整一下。然后你现在可以看一下,我们做了一个设置页面。设置页面里面的东西,就是你这个页面很多都已经放在设置页面了", + "multi_turn": true, + "captured_at": "2026-05-28T16:31:57.479Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "c901d77b-c98a-45d4-bb81-82502eac5920", + "sha256": "555257c703e3fd1ebd16a2c1b5bac9207648a5beb6a1f8ad73f9e8facb44ffe6", + "bytes": 4893, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 2, + "duration_ms": 130713, + "skills": [ + "algorithmic-art" + ], + "session_id": "73bdc051-ad43-4bbd-8db2-9fa10f41953d", + "user_input_preview": "continue", + "multi_turn": true, + "captured_at": "2026-05-28T15:15:30.035Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "c9ccdb7f-30d0-4b84-aa75-711d860a8aa2", + "sha256": "547a118d952f725cf17fd6c5c66999b95c04ae9f5087fcc28ba6b57713c49a6f", + "bytes": 586, + "agent": "claude", + "model": null, + "outcome": "failed", + "tool_count": 0, + "duration_ms": 3911, + "skills": [ + "ad-creative" + ], + "session_id": "8fbb6ad4-4c3d-4745-956c-502b09af5e2f", + "user_input_preview": "no puedes seguir?", + "multi_turn": true, + "captured_at": "2026-05-27T22:04:58.281Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "cdd021c6-9068-42df-a750-9cb7c435342b", + "sha256": "9f7afef1b4c82d8057f8f34e86670c39a6e11ce32b446c8dd7dc555def3b260e", + "bytes": 16796, + "agent": "opencode", + "model": "opencode/big-pickle", + "outcome": "succeeded", + "tool_count": 3, + "duration_ms": 33758, + "skills": [ + "ad-creative" + ], + "session_id": "b282d8d9-7052-4955-998f-ae5c0c945491", + "user_input_preview": "revisa la identidad", + "multi_turn": true, + "captured_at": "2026-05-28T01:46:30.963Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "d15c25b0-5ac6-420e-9a63-adaf0677560e", + "sha256": "acec3fa91991d369a579a6caaaa9f18fc650977c1f85bd60d5721587ba562669", + "bytes": 2440, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 18476, + "skills": [ + "ad-creative" + ], + "session_id": "4baaa2cb-68cb-4897-941f-39db5f3f835b", + "user_input_preview": "CRE un arte para hlg cycling academy donde diga una frase llamativa para ivnitar a las perosnas a sincribirse", + "multi_turn": true, + "captured_at": "2026-05-26T04:20:12.539Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "d15c3158-3d4a-40ce-8c89-ebc7d1ff877d", + "sha256": "938d44eb4052e2032386c8d0a4732e19606f82d63b436d3776bf1c27bcad1c9f", + "bytes": 539, + "agent": "gemini", + "model": null, + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 42653, + "skills": [ + "algorithmic-art" + ], + "session_id": "73dd5a51-118c-45e6-aa7a-680ab6795510", + "user_input_preview": "Yes, do I need to install anything?", + "multi_turn": true, + "captured_at": "2026-05-28T15:32:08.675Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "d273af29-4477-4549-b7ac-f6ec7682efdd", + "sha256": "fb85bbf786abdb2d7cff9236008d9cc730bb5ff8aba2e5d7def1bb7097e1a7a7", + "bytes": 8922, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 6, + "duration_ms": 47368, + "skills": [ + "ad-creative" + ], + "session_id": "b54e56d6-b1e0-4e24-b713-c063de5e1a14", + "user_input_preview": "use the market-psychology skill to redisgn please", + "multi_turn": true, + "captured_at": "2026-05-28T15:19:33.644Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "d2c66997-5962-4f3f-8f24-4c1434f30467", + "sha256": "6393174f7b219627981b97680b5b985d6b0acdd3b4490f11bb5e0fc9bdc4524a", + "bytes": 41664, + "agent": "opencode", + "model": null, + "outcome": "succeeded", + "tool_count": 21, + "duration_ms": 158269, + "skills": [ + "default" + ], + "session_id": "2e16f4fb-024d-45c2-917a-b61c61c67850", + "user_input_preview": "\"Agente, vamos a crear nuestra primera pieza de conexión humana. Hazlo en dos pasos exactos: PASO 1: Usa la API de Leonardo AI (o tu motor de generación de imágenes activo) para generar una fotografí", + "multi_turn": false, + "captured_at": "2026-05-28T16:31:45.453Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "d82d885c-a067-450c-a17b-f3442ad6005b", + "sha256": "699783b5d4bf8f1dc2b20c33d9924110da9e61c677977c31f5b36085b74f19db", + "bytes": 544, + "agent": "claude", + "model": "sonnet", + "outcome": "failed", + "tool_count": 0, + "duration_ms": 4607, + "skills": [ + "default" + ], + "session_id": "fd3e2909-2b97-4014-a58b-4a6a331a5c13", + "user_input_preview": "Est-ce que c'est bon ?", + "multi_turn": false, + "captured_at": "2026-05-28T16:34:07.220Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "d996dfdd-3ba6-4167-8d07-b4a7052266eb", + "sha256": "610a53c112d04d72fe817490af52b896b6212878461db29f0bbd6cac38fefefb", + "bytes": 490, + "agent": "gemini", + "model": null, + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 101307, + "skills": [ + "algorithmic-art" + ], + "session_id": "73dd5a51-118c-45e6-aa7a-680ab6795510", + "user_input_preview": null, + "multi_turn": true, + "captured_at": "2026-05-28T15:25:44.311Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "db0f3065-d9fd-407b-9a3d-32301a08520a", + "sha256": "90103897eb4a1458f036c765cd14f29db0f1ff8098794a0b78091479fd5efba3", + "bytes": 1153, + "agent": "claude", + "model": null, + "outcome": "failed", + "tool_count": 0, + "duration_ms": 2733, + "skills": [ + "algorithmic-art" + ], + "session_id": "9adb48c8-0744-47ee-a408-1f037c56d5aa", + "user_input_preview": "Please create an A5 Flyer to be digitally distributed, the Topic: Invite to professionals and companies to showcase their knowledge in an exhibition panel guided by a host. Audience is: Entrepreneurs", + "multi_turn": true, + "captured_at": "2026-05-28T14:52:28.756Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "dcdff3b3-cd39-4dcd-be83-372830a29639", + "sha256": "7a1faa6874d5569672e64ab60f09458892d0d3a36d3e363b58a02efa204f5f6d", + "bytes": 37600, + "agent": "codex", + "model": "gpt-5.5", + "outcome": "succeeded", + "tool_count": 14, + "duration_ms": 648955, + "skills": [ + "default" + ], + "session_id": "106b1ee4-0f94-4e32-831d-9b14b720ceb3", + "user_input_preview": "[form answers — task-type] - What should I build?: Prototype - Who is this for?: patient booking clinic queues - Brand context: Pick a direction for me [value: pick_direction] - Roughly how much?: (sk", + "multi_turn": true, + "captured_at": "2026-05-28T16:11:25.840Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "df6ad21c-ffa7-4475-8b9b-2440bd4c77bd", + "sha256": "50e6277afa4af22cb67c37630b98b82f5607600030d70e890e8a01a2a278ab9a", + "bytes": 38385, + "agent": "opencode", + "model": null, + "outcome": "succeeded", + "tool_count": 17, + "duration_ms": 95505, + "skills": [ + "default" + ], + "session_id": "5bbdbb28-b89c-4eac-b31d-12b367e640bc", + "user_input_preview": "cuando estamos en la red social x (twitter), como su color es negro, no se ve bien en los botone, la barra de progreso y todo lo que debe acentuar esta red social, debemos manejar grices claros para e", + "multi_turn": false, + "captured_at": "2026-05-28T16:23:39.608Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "e1db6716-f86f-4600-aec0-71c043430f1a", + "sha256": "2ec9a9409e965eb4bd098de1b391b93b21d46c35b70ebb7fd00799fd091469f5", + "bytes": 60314, + "agent": "codex", + "model": "gpt-5.5", + "outcome": "succeeded", + "tool_count": 18, + "duration_ms": 330915, + "skills": [ + "default" + ], + "session_id": "72e59a1e-6354-4acb-88f8-e4eabae3dadb", + "user_input_preview": "工单列表的分大类 不是贴标签", + "multi_turn": true, + "captured_at": "2026-05-28T16:31:25.923Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "e25c5699-8123-40d6-8580-495ecc72aed9", + "sha256": "7c2fad74b73c94499652c8fbe1d1cc6a5a9221a9b391e55f95266b6ef9d8bbff", + "bytes": 4182, + "agent": "claude", + "model": "sonnet", + "outcome": "failed", + "tool_count": 2, + "duration_ms": 6257, + "skills": [ + "default" + ], + "session_id": "85ac9b77-bd5c-4767-ae1f-7085c96b6baf", + "user_input_preview": "Fix the design-system package audit findings below. Treat every error and warning as blocking. Do not suppress the audit, delete evidence, or satisfy findings by only rewriting prose; update the real", + "multi_turn": true, + "captured_at": "2026-05-28T15:45:11.003Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "e2992461-51df-477a-ab97-7e0ac35f4984", + "sha256": "5530848408e4a11b0c43600574efc8f8586b6ce791f2a503f18b70e4df8b9c3c", + "bytes": 2605, + "agent": "gemini", + "model": "gemini-3.1-pro-preview", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 456314, + "skills": [ + "ad-creative" + ], + "session_id": "3ea05316-0c9d-47d2-9ab0-f4b2cb2b15da", + "user_input_preview": "I want you to UI-Frontend Audit for meta-ads-smm-s2.html; Because i think The text, SVG icon, it's not cosistent", + "multi_turn": true, + "captured_at": "2026-05-28T14:35:58.096Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "e452d373-535e-4e33-970e-1a99799e73af", + "sha256": "5ad965db2b680199a751c781b0d102580b8c8829dfe9b40d3a9096bb71c6df1a", + "bytes": 29899, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 6, + "duration_ms": 151253, + "skills": [ + "agent-browser" + ], + "session_id": "65585191-6be2-40f4-ade7-bedc12657be6", + "user_input_preview": "fix the jumping between the vertical scroll state on 100% and its state when zoomed in", + "multi_turn": false, + "captured_at": "2026-05-28T16:27:17.194Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "e54404b5-3260-464a-a062-5edc6ac79762", + "sha256": "3aba00a591599f22d3eb579112c48ad7cb22d8c57fb2ffb7aa798e606b077702", + "bytes": 10213, + "agent": "codex", + "model": null, + "outcome": "failed", + "tool_count": 5, + "duration_ms": 31233, + "skills": [ + "default" + ], + "session_id": "976333ac-966e-49bc-a5e7-d6c903bd1a01", + "user_input_preview": "[form answers — task-type] - What should I build?: Prototype - Who is this for?: (skipped) - Brand context: Pick a direction for me [value: pick_direction] - Roughly how much?: (skipped) - Target plat", + "multi_turn": true, + "captured_at": "2026-05-28T16:25:10.275Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "e5678ca3-d40a-4915-a7e4-b9c000e396cc", + "sha256": "be4ab42eb4cff134288e5527537547f16e9e06936647cc3c2833285b056a94c1", + "bytes": 11103, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 6, + "duration_ms": 29373, + "skills": [ + "default" + ], + "session_id": "364e2ac1-815c-4dcb-9412-24d260631481", + "user_input_preview": "去掉最后一页的核心平台、执行平台 实现企业知识资产化、专家经验数字化、研发能力平台化修改为:知识数字化、经验技能化、能力资产化", + "multi_turn": true, + "captured_at": "2026-05-28T15:48:25.111Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "e74397e3-03f5-4d36-9ae1-e21c80f53b11", + "sha256": "f4fb438a9e56bec53be252943bde229e7595cca54a654f7aadf5ed89e900eabc", + "bytes": 588, + "agent": "gemini", + "model": "default", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 631658, + "skills": [ + "ad-creative" + ], + "session_id": "3ea05316-0c9d-47d2-9ab0-f4b2cb2b15da", + "user_input_preview": "There is text behind the button, fix it", + "multi_turn": true, + "captured_at": "2026-05-26T13:57:04.076Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "e7b16771-af47-41d8-9521-209d1e915c41", + "sha256": "f17a6e9a4c87b68f01d741ab2fb50381f5e02cf62015a3c2a51386a000eb88a5", + "bytes": 31223, + "agent": "opencode", + "model": "opencode/deepseek-v4-flash-free", + "outcome": "succeeded", + "tool_count": 8, + "duration_ms": 96506, + "skills": [ + "default" + ], + "session_id": "efd35882-da45-49d0-b730-db507f7bdca6", + "user_input_preview": "Arregla que funcione bien el deck.html ya que si le doy a la siguiente slid no funciona y se queda en la primera", + "multi_turn": true, + "captured_at": "2026-05-28T16:32:24.169Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "e9f67500-718e-4e45-a56f-217239bb45a7", + "sha256": "b705dc3d81dabecd9e30c865ffe19f0a3bbf2ab6ed8ff886aa8be4109be012fd", + "bytes": 41706, + "agent": "opencode", + "model": null, + "outcome": "succeeded", + "tool_count": 17, + "duration_ms": 194367, + "skills": [ + "default" + ], + "session_id": "5a68949a-e449-47ce-8e39-49ce1e8f7342", + "user_input_preview": "quiero una imagen de fondo de un sujeto pensativo, fotorealistica real.. Usa el modelo Leonardo para generar esa imagen. En lugar de \"Frase Espejo\" coloca \"Reflexiona\"", + "multi_turn": true, + "captured_at": "2026-05-28T16:26:54.772Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "eacd9639-086e-44e5-b3ad-f62b45d55460", + "sha256": "70e99a0e810b2bf54b1f38b3f13c97dd8210422fb76143eeec3f37bb5e7308e2", + "bytes": 51367, + "agent": "opencode", + "model": "deepseek/deepseek-v4-pro", + "outcome": "succeeded", + "tool_count": 16, + "duration_ms": 130617, + "skills": [ + "agent-browser" + ], + "session_id": "50d03b79-796f-41b4-a729-df42d8de18b5", + "user_input_preview": "CORRIGIR NO MOBILE E TABLET AS INFORMAÇÕES ABAIXO: > Sessão \"Isso faz sentido\" - os retângulos onde tem \"rotina corrida\" e etc, estão com alguns cantos quadrados e outros arredondados. Gostaria que f", + "multi_turn": false, + "captured_at": "2026-05-28T16:23:12.460Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "ec4b14f2-a96a-4646-be05-fd955120db8f", + "sha256": "220bafd4f669972779a94a7100c36593a17f64e802f7fe781f36803fce64e2de", + "bytes": 34902, + "agent": "codex", + "model": null, + "outcome": "succeeded", + "tool_count": 9, + "duration_ms": 62763, + "skills": [ + "agent-browser" + ], + "session_id": "899d9e9f-0cb6-4429-b975-450f61400d7e", + "user_input_preview": "这里字体太大了", + "multi_turn": true, + "captured_at": "2026-05-28T16:31:27.016Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "eda6f6d4-4c21-4e6d-9caf-84b0c37dc027", + "sha256": "0bf4098aeb9111f723250f394ca4d688b6dc3628d0d35ea35bbe6b5ef6aee8d9", + "bytes": 3008, + "agent": "claude", + "model": null, + "outcome": "failed", + "tool_count": 0, + "duration_ms": 2080, + "skills": [ + "default" + ], + "session_id": "6157971e-bc71-44e3-be76-8287fdcc3c3d", + "user_input_preview": "# 📱 TASKORA — COMPLETE OPEN DESIGN UI MASTER DOCUMENT # 🎯 MAIN DESIGN GOAL Design style must feel like: ✅ Smart productivity workspace ✅ Interactive timeline dashboard ✅ Floating modern utility s", + "multi_turn": false, + "captured_at": "2026-05-28T18:26:15.786Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "eff7d1c7-278a-4e9c-b360-e12a33872bc8", + "sha256": "e092342c0ef29dfe1c254afab8f2c663a383ea9b30d956d912ba5274f3236c38", + "bytes": 14447, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 12, + "duration_ms": 142314, + "skills": [ + "ad-creative" + ], + "session_id": "b54e56d6-b1e0-4e24-b713-c063de5e1a14", + "user_input_preview": "Export index.html as a pdf", + "multi_turn": true, + "captured_at": "2026-05-28T15:27:41.692Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "f154b511-9dcf-4d7c-8e21-1da25e50ea23", + "sha256": "1a1916e6e99cabe3ee78acd01c45349b1c7ea7556ad1f44e68516969c65e76bf", + "bytes": 3360, + "agent": "cursor-agent", + "model": "composer-2-fast", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 137789, + "skills": [ + "algorithmic-art" + ], + "session_id": "8c252113-3802-4020-bb00-171c1576ebd8", + "user_input_preview": "Can you also make it possible for us to move the 3D map around, so we can manually navigate to different places, zoom, etc?", + "multi_turn": true, + "captured_at": "2026-05-28T16:01:14.642Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "f315889c-9a6b-4cd2-b986-ec41ad038466", + "sha256": "4d48cd4ba7bfaea6e309704e12feecebac8fe5d71c359eac33f64b3bdc3659ff", + "bytes": 98246, + "agent": "codex", + "model": "gpt-5.5", + "outcome": "succeeded", + "tool_count": 45, + "duration_ms": 783079, + "skills": [ + "default" + ], + "session_id": "167f41ae-5d12-4107-a6a7-0a09f394927b", + "user_input_preview": "[form answers — discovery] - Worksite visuals: Use structured placeholders only - Speaker notes depth: Concise executive notes - Slide density: Executive concise", + "multi_turn": false, + "captured_at": "2026-05-28T16:12:00.281Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "f545ebf8-9ceb-4ca1-990d-c1c88b59d2b7", + "sha256": "eeca917cfb45c2ef159467a5b84d6356990fd0f1a89e123e043a7504e4167503", + "bytes": 15748, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 4, + "duration_ms": 53938, + "skills": [ + "ad-creative" + ], + "session_id": "b54e56d6-b1e0-4e24-b713-c063de5e1a14", + "user_input_preview": "go for one 9s looping motion variant of Stories 9:16-A", + "multi_turn": true, + "captured_at": "2026-05-28T15:53:48.507Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "f8d32699-a348-4ffe-af73-11f59cb42b2a", + "sha256": "994c45eaf19d854ade9dd273ae041951e642eebcf6fe476afd26d0cfcbe7fd06", + "bytes": 58789, + "agent": "opencode", + "model": null, + "outcome": "succeeded", + "tool_count": 24, + "duration_ms": 239460, + "skills": [ + "default" + ], + "session_id": "5a68949a-e449-47ce-8e39-49ce1e8f7342", + "user_input_preview": "contenido para columna 3. **5. Post estático Frase espejo con imagen de sujeto de fondo pensativo** Texto sobre fondo: \"Ya viste a otros vender cursos. Tú llevas meses pensándolo.\" Pie: La diferencia", + "multi_turn": true, + "captured_at": "2026-05-28T16:21:19.096Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "f957c27c-0d66-468c-8236-bb6a2c96af12", + "sha256": "6482a9854eed845da61649ee5a948624f76d70385b6e9452d862bec04d34f8b7", + "bytes": 2583, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 56161, + "skills": [ + "ad-creative" + ], + "session_id": "8fbb6ad4-4c3d-4745-956c-502b09af5e2f", + "user_input_preview": "Quiero que revises la página web de referencia https://www.seikosushi.cl/pedir y diseñes una nueva página web completa para una marca de sushi, usando los archivos e imágenes que te voy a entregar en", + "multi_turn": true, + "captured_at": "2026-05-27T21:50:25.629Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "fa8d8b16-802e-4006-b19f-59b028996356", + "sha256": "37b0ba643791918b5f7c38c0c12303bcb3d01f59c7055d85c2e7d069da7b8507", + "bytes": 4410, + "agent": "claude", + "model": null, + "outcome": "succeeded", + "tool_count": 4, + "duration_ms": 14566, + "skills": [ + "default" + ], + "session_id": "a302a440-632b-4abb-8140-258ceff58ac0", + "user_input_preview": "Красота. Коммить", + "multi_turn": false, + "captured_at": "2026-05-28T15:45:21.149Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + }, + { + "trace_id": "ff0f0c68-763c-4dfb-9b3d-eae3235f4acd", + "sha256": "ead40a44eceaa9ab28346c7a3f2152aff23ec1178d2c0561da1a88da039c212f", + "bytes": 2438, + "agent": "opencode", + "model": "opencode-go/qwen3.7-max", + "outcome": "succeeded", + "tool_count": 0, + "duration_ms": 15272, + "skills": [ + "algorithmic-art" + ], + "session_id": "6f4bc605-2293-47f4-babe-3248a051fab1", + "user_input_preview": "Create a rubics cube @algorithmic-art", + "multi_turn": true, + "captured_at": "2026-05-28T15:57:43.230Z", + "cli_version": null, + "protocol_version": null, + "anonymization_version": null + } + ], + "total": 179, + "total_bytes": 4200634, + "histograms": { + "by_agent": { + "codex": 38, + "claude": 57, + "gemini": 25, + "opencode": 41, + "cursor-agent": 11, + "qwen": 2, + "copilot": 2, + "deepseek": 2, + "antigravity": 1 + }, + "by_outcome": { + "succeeded": 144, + "failed": 35 + }, + "by_skill": { + "default": 71, + "agent-browser": 22, + "ad-creative": 50, + "algorithmic-art": 30, + "magazine-web-ppt": 1, + "brainstorming": 1, + "penpot-flutter-design-source": 1, + "video-hyperframes": 2, + "data-report": 1 + } + }, + "sessions_with_multi_turn": 35 +} \ No newline at end of file diff --git a/mocks/mock-agent.mjs b/mocks/mock-agent.mjs new file mode 100755 index 000000000..cd6a3800d --- /dev/null +++ b/mocks/mock-agent.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +/** + * mock-agent.mjs — pretends to be one of OD's supported agent CLIs + * (claude / opencode / codex / deepseek / qwen / grok) by streaming a + * pre-recorded session in that CLI's native stdout protocol. Zero LLM + * tokens. + * + * Usage (driven by the wrappers in bin/, not directly): + * ./mock-agent.mjs --as opencode [--no-delay] [--report-file ] + * + * Recording selection — see lib/recording-picker.mjs. The wrappers + * announce the picked trace id on stderr. + * + * Trace data: ./recordings/.jsonl (anonymized exports from + * Langfuse). Index: ./recordings/index.json. + */ + +import { pickRecording, readRecording } from './lib/recording-picker.mjs'; +import { renderAsOpencode } from './lib/format-opencode.mjs'; +import { renderAsCodex } from './lib/format-codex.mjs'; +import { renderAsClaude } from './lib/format-claude.mjs'; +import { renderAsGemini } from './lib/format-gemini.mjs'; +import { renderAsCursorAgent } from './lib/format-cursor-agent.mjs'; +import { renderAsPlain } from './lib/format-plain.mjs'; +import { runAcpServer } from './lib/format-acp.mjs'; +import { runVelaAcpServer } from './lib/format-vela.mjs'; +import { runVelaLogin, runVelaModels } from './lib/vela-subcommands.mjs'; + +function parseArgs(argv) { + const opts = { as: null, noDelay: false, reportFile: null, positionals: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--as' || a === '--agent') { opts.as = argv[++i]; continue; } + if (a === '--no-delay') { opts.noDelay = true; continue; } + if (a === '--report-file') { opts.reportFile = argv[++i]; continue; } + if (a.startsWith('-')) continue; // Unknown flag — silently skip (model/permission flags etc.) + // Anything left is a positional — used by vela subcommand dispatch. + opts.positionals.push(a); + } + if (process.env.OD_MOCKS_NO_DELAY === '1') opts.noDelay = true; + // Fall through to REPORT_FILE env when --report-file wasn't supplied. + // Some harnesses (e.g. the agent-pr-explore orchestrator) set + // REPORT_FILE as env but expect the agent to write there + // autonomously — real opencode/claude do via their Write tool, but + // the mock needs to project the recording's final assistant text to + // that path so the harness sees a report. + if (!opts.reportFile && process.env.REPORT_FILE) { + opts.reportFile = process.env.REPORT_FILE; + } + return opts; +} + +async function readStdinIfPiped() { + if (process.stdin.isTTY) return ''; + return new Promise(resolve => { + let acc = ''; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', c => { acc += c; }); + process.stdin.on('end', () => resolve(acc)); + process.stdin.on('error', () => resolve(acc)); + // Safety timeout in case the parent never closes stdin (PTY). + setTimeout(() => resolve(acc), 1500); + }); +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + if (!opts.as) { + process.stderr.write( + 'mock-agent: --as required\n' + + ' supported: opencode | claude | codex | gemini | cursor-agent |\n' + + ' deepseek | qwen | grok | plain |\n' + + ' devin | hermes | kilo | kimi | kiro | vibe (ACP)\n' + + ' vela (AMR — vela CLI)\n', + ); + process.exit(2); + } + + // `vela` dispatches by the first positional arg passed by OD (login / + // models / agent). Subcommands run BEFORE recording selection because + // they don't use trace data at all. + if (opts.as === 'vela') { + const cmd = (opts.positionals[0] || '').trim(); + if (cmd === 'login') return runVelaLogin(); + if (cmd === 'models') return runVelaModels(); + // Default: `agent run --runtime opencode` — fall through to the ACP + // server below with the vela-flavored protocol. + } + + // ACP agents read JSON-RPC messages off stdin one line at a time, so the + // bulk-prompt buffering logic below doesn't apply — pickRecording sees no + // prompt for hash-mode (use OD_MOCKS_TRACE or _POOL instead). + const ACP_AGENTS = new Set(['devin', 'hermes', 'kilo', 'kimi', 'kiro', 'vibe', 'vela']); + const isAcp = ACP_AGENTS.has(opts.as); + const prompt = isAcp ? '' : await readStdinIfPiped(); + const picked = await pickRecording({ prompt }); + if (!picked) { + process.stderr.write( + 'mock-agent: no recordings on disk yet.\n' + + 'The recording corpus is hosted on Cloudflare R2 (see mocks/manifest.json)\n' + + 'and is fetched on demand. Run:\n' + + '\n' + + ' bash mocks/scripts/fetch-recordings.sh # all 179 (~30s, 4.5MB)\n' + + ' bash mocks/scripts/fetch-recordings.sh --agent claude # subset\n' + + '\n' + + 'Or set OD_MOCKS_RECORDINGS_DIR if you stashed them elsewhere.\n', + ); + process.exit(3); + } + + process.stderr.write( + `[mock-${opts.as}] picked ${picked.traceId.slice(0, 8)}… via ${picked.method}` + + (picked.pool ? ` (pool="${picked.pool}")` : '') + + '\n', + ); + + const events = await readRecording(picked.path); + const renderOpts = { noDelay: opts.noDelay, reportFile: opts.reportFile }; + + switch (opts.as) { + case 'opencode': await renderAsOpencode(events, renderOpts); break; + case 'codex': await renderAsCodex(events, renderOpts); break; + case 'claude': await renderAsClaude(events, renderOpts); break; + case 'gemini': await renderAsGemini(events, renderOpts); break; + case 'cursor-agent': await renderAsCursorAgent(events, renderOpts); break; + case 'deepseek': + case 'qwen': + case 'grok': + case 'plain': await renderAsPlain(events, renderOpts); break; + // ACP family — JSON-RPC server over stdio. + case 'devin': + case 'hermes': + case 'kilo': + case 'kimi': + case 'kiro': + case 'vibe': await runAcpServer(events, renderOpts); break; + // AMR (vela CLI) — ACP with vela-specific protocol extensions + // (agentCapabilities + models block + strict set_model gate). + case 'vela': await runVelaAcpServer(events, renderOpts); break; + default: + process.stderr.write(`mock-agent: unknown agent "${opts.as}"\n`); + process.exit(2); + } +} + +main().catch(err => { + process.stderr.write(`mock-agent: ${err.message}\n`); + process.exit(1); +}); diff --git a/mocks/recordings/.gitignore b/mocks/recordings/.gitignore new file mode 100644 index 000000000..bcfbf5443 --- /dev/null +++ b/mocks/recordings/.gitignore @@ -0,0 +1,6 @@ +# Recording corpus is hosted on Cloudflare R2 (see ../manifest.json). +# Pull it with: bash mocks/scripts/fetch-recordings.sh +# The .jsonl files land here at runtime; the manifest is the source of +# truth committed in the parent dir. +*.jsonl +index.json diff --git a/mocks/scripts/contract-check.sh b/mocks/scripts/contract-check.sh new file mode 100755 index 000000000..05fb93554 --- /dev/null +++ b/mocks/scripts/contract-check.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Real-CLI contract check — spot-check that the actual agent CLI's +# stream protocol still matches what the mock formatters emit. Catches +# drift like: +# - real CLI adds a new event `type` +# - real CLI changes a field name (e.g. `sessionID` → `sessionId`) +# - real CLI's usage object gains/loses a sub-key +# +# When mocks drift toward "satisfy the current OD parser" instead of +# "mimic the actual CLI protocol", the OD-level smoke tests stay green +# but real-world behavior diverges. Periodic runs of this script (manual +# or scheduled in CI on a real-CLI-available runner) surface that drift +# before it becomes a real-PR debugging session. +# +# bash mocks/scripts/contract-check.sh +# bash mocks/scripts/contract-check.sh claude +# bash mocks/scripts/contract-check.sh opencode +# +# Prereqs: the real agent CLI must be installed AND authenticated. This +# script DOES make a live LLM call (couple of cents of tokens) so it's +# not free. +# +# Output: side-by-side top-level event `type` distribution from real +# vs mock for the same prompt. A maintainer eyeballs the diff. Future +# work (see docs/MOCKS-CONTRACT-CHECK.md) is to lift this into a +# stricter structural compare. + +set -euo pipefail + +AGENT="${1:-}" +if [ -z "$AGENT" ]; then + echo "usage: $0 " >&2 + echo " supported: claude | opencode | codex" >&2 + exit 2 +fi + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +MOCKS_DIR="$(cd "$HERE/.." && pwd -P)" + +# Fixed deterministic prompt — small, no creative variability, costs +# pennies. Picked to be a realistic OD-shaped task: 1-2 tool calls. +PROMPT='List the entries of the current working directory and tell me how many JSON files are present. Reply with just the count, like "N JSON files".' + +real_out="$(mktemp -t contract-real.XXXX).jsonl" +mock_out="$(mktemp -t contract-mock.XXXX).jsonl" +trap 'rm -f "$real_out" "$mock_out"' EXIT + +# Resolve the real CLI binary. We deliberately ignore mocks/bin/ PATH +# overlays. +real_bin="" +case "$AGENT" in + claude) real_bin="$(PATH=$(echo "$PATH" | tr ':' '\n' | grep -v "$MOCKS_DIR/bin" | paste -sd: -) command -v claude || true)" ;; + codex) real_bin="$(PATH=$(echo "$PATH" | tr ':' '\n' | grep -v "$MOCKS_DIR/bin" | paste -sd: -) command -v codex || true)" ;; + opencode) real_bin="$(PATH=$(echo "$PATH" | tr ':' '\n' | grep -v "$MOCKS_DIR/bin" | paste -sd: -) command -v opencode || true)" ;; + *) echo "✗ unsupported agent for contract check: $AGENT" >&2; exit 2 ;; +esac +if [ -z "$real_bin" ]; then + echo "✗ real '$AGENT' CLI not on PATH. Install + login, then re-run." >&2 + exit 1 +fi +echo "real CLI: $real_bin" +echo "prompt: $PROMPT" +echo + +# 1. Real CLI +echo "→ invoking real $AGENT…" +case "$AGENT" in + claude) + printf '%s' "$PROMPT" | "$real_bin" -p --output-format=stream-json --verbose >"$real_out" 2>&1 || true ;; + codex) + printf '%s' "$PROMPT" | "$real_bin" exec >"$real_out" 2>&1 || true ;; + opencode) + printf '%s' "$PROMPT" | "$real_bin" run >"$real_out" 2>&1 || true ;; +esac + +# 2. Mock CLI — same prompt, PATH-overlayed to the mock bin. +# `bash -c` here would lose $PROMPT (parent shell var, not exported) +# and silently send an empty string to the mock — defeating the +# "same input on both sides" property the rest of the script relies on. +# A subshell scopes the PATH override locally, no var-passing dance. +echo "→ invoking mock $AGENT…" +( + export PATH="$MOCKS_DIR/bin:$PATH" + export OD_MOCKS_NO_DELAY=1 + case "$AGENT" in + claude) + printf '%s' "$PROMPT" | claude -p --output-format=stream-json --verbose >"$mock_out" 2>&1 ;; + codex) + printf '%s' "$PROMPT" | codex exec >"$mock_out" 2>&1 ;; + opencode) + printf '%s' "$PROMPT" | opencode run >"$mock_out" 2>&1 ;; + esac +) || true + +# 3. Compare top-level event `type` distributions (skip content) +summarize() { + jq -r 'try .type catch empty' "$1" 2>/dev/null | sort | uniq -c | sort -rn || true +} +real_summary=$(summarize "$real_out") +mock_summary=$(summarize "$mock_out") + +echo +echo "real:" +printf '%s\n' "${real_summary:- (no parseable JSON events)}" | sed 's/^/ /' +echo +echo "mock:" +printf '%s\n' "${mock_summary:- (no parseable JSON events)}" | sed 's/^/ /' +echo +echo "raw outputs kept at:" +echo " real: $real_out" +echo " mock: $mock_out" +echo "(diff manually — `diff <(jq -r .type $real_out|sort -u) <(jq -r .type $mock_out|sort -u)`)" +trap - EXIT # leave the tmpfiles for the maintainer to inspect diff --git a/mocks/scripts/fetch-recordings.sh b/mocks/scripts/fetch-recordings.sh new file mode 100755 index 000000000..277021ee0 --- /dev/null +++ b/mocks/scripts/fetch-recordings.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# Fetch the recording corpus referenced by mocks/manifest.json from +# Cloudflare R2 into mocks/recordings/. Skips files already on disk +# whose sha256 matches the manifest. Verifies every download. +# +# Usage: +# bash mocks/scripts/fetch-recordings.sh # fetch all +# bash mocks/scripts/fetch-recordings.sh --agent claude # fetch claude only +# bash mocks/scripts/fetch-recordings.sh --outcome failed # fetch failed only +# bash mocks/scripts/fetch-recordings.sh --skill agent-browser +# bash mocks/scripts/fetch-recordings.sh --concurrency 16 +# bash mocks/scripts/fetch-recordings.sh --force # re-download all +# bash mocks/scripts/fetch-recordings.sh --cache-dir

# override cache location +# +# Default cache: mocks/recordings/. Override with OD_MOCKS_CACHE_DIR env +# or --cache-dir flag — useful for sharing across multiple OD checkouts. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +MOCKS_DIR="$(cd "$HERE/.." && pwd -P)" +MANIFEST="$MOCKS_DIR/manifest.json" + +FILTER_AGENT="" +FILTER_OUTCOME="" +FILTER_SKILL="" +CONCURRENCY=8 +FORCE=0 +CACHE_DIR="${OD_MOCKS_CACHE_DIR:-$MOCKS_DIR/recordings}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --agent) FILTER_AGENT="$2"; shift 2 ;; + --outcome) FILTER_OUTCOME="$2"; shift 2 ;; + --skill) FILTER_SKILL="$2"; shift 2 ;; + --concurrency) CONCURRENCY="$2"; shift 2 ;; + --cache-dir) CACHE_DIR="$2"; shift 2 ;; + --force) FORCE=1; shift ;; + -h|--help) + sed -n '2,17p' "$0" | sed 's/^# //; s/^#//'; exit 0 ;; + *) echo "unknown flag: $1" >&2; exit 2 ;; + esac +done + +if [ ! -f "$MANIFEST" ]; then + echo "✗ manifest not found at $MANIFEST" >&2 + exit 1 +fi + +mkdir -p "$CACHE_DIR" + +# Use node to walk the manifest — sturdier than shell JSON parsing. +PUBLIC_URL=$(node -e ' +const m = JSON.parse(require("fs").readFileSync(process.argv[1],"utf-8")); +process.stdout.write(m.storage.public_url_base + "/" + m.storage.object_prefix); +' "$MANIFEST") + +# Select entries matching filters, write one TSV row per entry: +# \t\t +ENTRIES_TSV=$(node -e ' +const m = JSON.parse(require("fs").readFileSync(process.argv[1],"utf-8")); +const fa = process.argv[2], fo = process.argv[3], fs = process.argv[4]; +for (const e of m.entries) { + if (fa && e.agent !== fa) continue; + if (fo && e.outcome !== fo) continue; + if (fs && !(e.skills || []).includes(fs)) continue; + process.stdout.write(`${e.trace_id}\t${e.sha256}\t${e.bytes}\n`); +} +' "$MANIFEST" "$FILTER_AGENT" "$FILTER_OUTCOME" "$FILTER_SKILL") + +# Empty-string check has to come BEFORE any line-counting — `printf '%s\n' ""` +# emits a single empty line, which `grep -c ""` / `wc -l` would count as 1 +# and let a typo'd `--agent xyz` quietly succeed with zero downloads. +if [ -z "$ENTRIES_TSV" ]; then + echo "no entries matched filter" >&2 + exit 0 +fi +TOTAL=$(printf '%s\n' "$ENTRIES_TSV" | wc -l | tr -d ' ') + +echo "Fetching up to $TOTAL recordings → $CACHE_DIR" +echo " manifest: $MANIFEST" +echo " R2 prefix: $PUBLIC_URL" +[ -n "$FILTER_AGENT" ] && echo " filter: agent=$FILTER_AGENT" +[ -n "$FILTER_OUTCOME" ] && echo " filter: outcome=$FILTER_OUTCOME" +[ -n "$FILTER_SKILL" ] && echo " filter: skill=$FILTER_SKILL" +[ "$FORCE" -eq 1 ] && echo " --force: re-downloading all matched" +echo + +# Function called by xargs — must be exported. Writes one of: +# ✓ (newly fetched) +# • (skipped — sha256 already matches) +# ✗ (failed — sha256 mismatch or download error) +fetch_one() { + local id="$1" sha="$2" bytes="$3" + local dest="$CACHE_DIR/$id.jsonl" + if [ "$FORCE" -ne 1 ] && [ -f "$dest" ]; then + local existing + existing=$(shasum -a 256 "$dest" 2>/dev/null | awk '{print $1}') + if [ "$existing" = "$sha" ]; then + echo "• $id" + return 0 + fi + fi + local url="${PUBLIC_URL}${id}.jsonl" + if ! curl -sf -o "$dest.tmp" "$url"; then + echo "✗ $id (download failed)" + rm -f "$dest.tmp" + return 1 + fi + local got + got=$(shasum -a 256 "$dest.tmp" | awk '{print $1}') + if [ "$got" != "$sha" ]; then + echo "✗ $id (sha256 mismatch: got $got expected $sha)" + rm -f "$dest.tmp" + return 1 + fi + mv "$dest.tmp" "$dest" + echo "✓ $id" +} + +export PUBLIC_URL CACHE_DIR FORCE +export -f fetch_one + +printf '%s\n' "$ENTRIES_TSV" \ + | xargs -P "$CONCURRENCY" -L 1 bash -c 'fetch_one "$1" "$2" "$3"' _ \ + > /tmp/od-mocks-fetch-progress.txt 2>&1 + +new=$(grep -c "^✓" /tmp/od-mocks-fetch-progress.txt || true) +skip=$(grep -c "^•" /tmp/od-mocks-fetch-progress.txt || true) +fail=$(grep -c "^✗" /tmp/od-mocks-fetch-progress.txt || true) + +echo " ✓ fetched: $new" +echo " • cached: $skip" +if [ "$fail" -gt 0 ]; then + echo " ✗ failed: $fail" + echo + grep "^✗" /tmp/od-mocks-fetch-progress.txt | head -5 + echo " …(full log /tmp/od-mocks-fetch-progress.txt)" + exit 1 +fi + +# Symlink (or copy) into mocks/recordings/ when cache lives elsewhere so +# the mock-agent recording-picker keeps working without env overrides. +if [ "$CACHE_DIR" != "$MOCKS_DIR/recordings" ]; then + mkdir -p "$MOCKS_DIR/recordings" + for f in "$CACHE_DIR"/*.jsonl; do + [ -e "$f" ] || continue + bn=$(basename "$f") + if [ ! -e "$MOCKS_DIR/recordings/$bn" ]; then + ln -sf "$f" "$MOCKS_DIR/recordings/$bn" + fi + done + # Also link the manifest so picker/index-aware tooling sees it. + ln -sf "$MANIFEST" "$MOCKS_DIR/recordings/index.json" 2>/dev/null || true +fi + +echo +echo "✅ ready: $MOCKS_DIR/recordings/" diff --git a/mocks/scripts/lib/manifest-utils.mjs b/mocks/scripts/lib/manifest-utils.mjs new file mode 100644 index 000000000..59e989275 --- /dev/null +++ b/mocks/scripts/lib/manifest-utils.mjs @@ -0,0 +1,103 @@ +// Manifest math — shared between the local `add-recording.sh` preview +// step and the GitHub Action that actually does R2 uploads. +// +// Concentrated here so a maintainer who eyeballs add-recording's +// preview output sees the SAME entry shape the workflow will commit +// post-merge. No drift. + +import { readFileSync as readSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { basename } from 'node:path'; + +/** + * Parse a recording's first JSONL line (the `meta` event) and return + * the shape we store in the manifest. + */ +export function inspectRecording(jsonlPath) { + const buf = readSync(jsonlPath); + const sha256 = createHash('sha256').update(buf).digest('hex'); + const firstLine = buf.toString('utf-8').split('\n', 1)[0]; + let meta; + try { + meta = JSON.parse(firstLine); + } catch (err) { + throw new Error(`first line of ${jsonlPath} is not valid JSON: ${err.message}`); + } + if (meta.type !== 'meta') { + throw new Error(`first line of ${jsonlPath} has type="${meta.type}" — expected "meta"`); + } + const traceId = basename(jsonlPath, '.jsonl'); + const skills = (meta.tags ?? []) + .filter(t => typeof t === 'string' && t.startsWith('skill:')) + .map(t => t.slice('skill:'.length)); + return { + trace_id: traceId, + sha256, + bytes: buf.byteLength, + agent: meta.agent ?? 'unknown', + model: meta.model ?? null, + outcome: meta.outcome ?? 'unknown', + tool_count: meta.tool_call_count ?? 0, + duration_ms: meta.duration_ms ?? 0, + skills: skills.length > 0 ? skills : ['default'], + session_id: meta.session_id ?? null, + user_input_preview: meta.user_input + ? String(meta.user_input).slice(0, 200).replace(/\s+/g, ' ').trim() + : null, + multi_turn: false, // filled in after histograms + // ──── Provenance (fixture-trust signals; mrcfps review #3241) ──── + // Capture-time fields the harvester writes into the meta event. + // Older recordings may not have them all — null is the explicit "we + // don't know" so consumers can decide whether the fixture is still + // trustworthy as the real CLIs evolve. + captured_at: meta.timestamp ?? null, // ISO 8601 of the original session + cli_version: meta.cli_version ?? null, // e.g. "claude-code 1.0.65" / "codex 0.40" + protocol_version: meta.protocol_version ?? null, + anonymization_version: meta.anonymization_version ?? null, + }; +} + +/** + * Insert / replace a manifest entry and rebuild all derived fields + * (histograms, multi_turn flags, total_bytes, total). Returns the + * mutated manifest object — caller writes it back if it wants to. + */ +export function upsertEntry(manifest, newEntry) { + const idx = manifest.entries.findIndex(e => e.trace_id === newEntry.trace_id); + if (idx >= 0) { + manifest.entries[idx] = newEntry; + } else { + manifest.entries.push(newEntry); + } + // Rebuild derived fields from scratch. + const byAgent = {}; + const byOutcome = {}; + const bySkill = {}; + const sessions = {}; + let totalBytes = 0; + for (const e of manifest.entries) { + byAgent[e.agent] = (byAgent[e.agent] ?? 0) + 1; + byOutcome[e.outcome] = (byOutcome[e.outcome] ?? 0) + 1; + for (const s of e.skills) bySkill[s] = (bySkill[s] ?? 0) + 1; + if (e.session_id) (sessions[e.session_id] ??= []).push(e.trace_id); + totalBytes += e.bytes; + } + for (const e of manifest.entries) { + e.multi_turn = !!(e.session_id && (sessions[e.session_id]?.length ?? 0) >= 2); + } + manifest.entries.sort((a, b) => a.trace_id.localeCompare(b.trace_id)); + manifest.generated_at = new Date().toISOString(); + manifest.total = manifest.entries.length; + manifest.total_bytes = totalBytes; + manifest.histograms = { by_agent: byAgent, by_outcome: byOutcome, by_skill: bySkill }; + manifest.sessions_with_multi_turn = Object.values(sessions).filter(s => s.length >= 2).length; + return manifest; +} + +export function readManifest(path) { + return JSON.parse(readSync(path, 'utf-8')); +} + +export function writeManifest(path, manifest) { + writeFileSync(path, JSON.stringify(manifest, null, 2)); +} diff --git a/mocks/scripts/smoke-test.sh b/mocks/scripts/smoke-test.sh new file mode 100755 index 000000000..4b5ce0156 --- /dev/null +++ b/mocks/scripts/smoke-test.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# Quick smoke test for the mock CLIs. +# Runs each agent's wrapper against a known recording and asserts that: +# 1. The mock binary exits 0 +# 2. Stdout produces a sensible number of lines (>= 5 for JSON formats, +# >= 1 for plain) +# 3. The first JSON line for each JSON agent has the expected shape +# +# Usage: +# bash mocks/scripts/smoke-test.sh + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +MOCKS="$(cd "$HERE/.." && pwd -P)" +TRACE_ID="${OD_MOCKS_SMOKE_TRACE:-04097377}" # the 17-tool claude session + +# Ensure recordings are on disk — the corpus is hosted on R2 and fetched +# on demand. If nothing's been pulled yet (or only a few are), run the +# fetcher so smoke covers the full agent matrix. +if ! ls "$MOCKS/recordings"/*.jsonl >/dev/null 2>&1; then + echo "no recordings found — fetching from R2 first..." + bash "$HERE/fetch-recordings.sh" >/dev/null + echo +fi + +export PATH="$MOCKS/bin:$PATH" +export OD_MOCKS_TRACE="$TRACE_ID" +export OD_MOCKS_NO_DELAY=1 + +failed=0 +pass() { printf ' \033[32m✓\033[0m %s\n' "$1"; } +fail() { printf ' \033[31m✗\033[0m %s\n' "$1"; failed=$((failed + 1)); } + +check_json_first_event() { + local agent="$1" expected_type="$2" + local first + first=$(echo "smoke" | "$agent" run 2>/dev/null | head -1 || true) + if [ -z "$first" ]; then fail "$agent: empty stdout"; return; fi + local got + got=$(printf '%s' "$first" | node -e 'let buf="";process.stdin.on("data",d=>buf+=d);process.stdin.on("end",()=>{try{console.log(JSON.parse(buf).type||"")}catch{console.log("INVALID")}})') + if [ "$got" = "$expected_type" ]; then + pass "$agent first event = $expected_type" + else + fail "$agent first event = $got (wanted $expected_type)" + fi +} + +echo "Smoke testing mock CLIs against trace $TRACE_ID" +echo + +# opencode / opencode-cli (primary OD-facing bin) → step_start +check_json_first_event opencode step_start +check_json_first_event opencode-cli step_start + +# codex → thread.started +check_json_first_event codex thread.started + +# claude → system / init +# (codex/claude have a different entry verb; using a uniform "first line type" check) +first=$(echo smoke | claude -p 2>/dev/null | head -1 || true) +if printf '%s' "$first" | grep -q '"type":"system"'; then + pass "claude first event = system" +else + fail "claude first event missing system shape: ${first:0:80}" +fi + +# gemini → init +check_json_first_event gemini init + +# cursor-agent → system + subtype:init +first=$(echo smoke | cursor-agent 2>/dev/null | head -1 || true) +if printf '%s' "$first" | grep -q '"type":"system"' && printf '%s' "$first" | grep -q '"subtype":"init"'; then + pass "cursor-agent first event = system+init" +else + fail "cursor-agent first event missing system/init shape: ${first:0:80}" +fi + +# Plain agents — first non-empty line should be from the report content. +for agent in deepseek qwen grok; do + out=$(echo smoke | "$agent" 2>/dev/null | head -1 || true) + if [ -n "$out" ]; then + pass "$agent emitted plain text (${#out} chars on first line)" + else + fail "$agent emitted nothing" + fi +done + +# vela subcommands — `vela models` (prints catalog) and `vela login` (writes +# ~/.amr/config.json). These exit immediately, no recording involved. +vela_models_out=$(vela models 2>/dev/null | wc -l | tr -d ' ') +if [ "$vela_models_out" -ge 10 ]; then + pass "vela models printed $vela_models_out catalog lines" +else + fail "vela models printed only $vela_models_out lines (expected ≥10)" +fi + +# Sandbox vela login into a temp HOME so we never touch the caller's real +# ~/.amr config (which holds the production vela login state for anyone +# using the real CLI). vela's login subcommand resolves ~/.amr from $HOME, +# so override just for this one invocation. +amr_sandbox="$(mktemp -d -t od-mocks-amr.XXXXXX)" +trap 'rm -rf "$amr_sandbox"' EXIT +if HOME="$amr_sandbox" FAKE_VELA_LOGIN_USER_EMAIL=smoke@od.local vela login >/dev/null 2>&1 \ + && [ -f "$amr_sandbox/.amr/config.json" ]; then + email=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$amr_sandbox/.amr/config.json','utf-8')).profiles.prod.user.email)" 2>/dev/null || echo "") + if [ "$email" = "smoke@od.local" ]; then + pass "vela login wrote ~/.amr/config.json with profile.prod.user.email" + else + fail "vela login config.json missing expected email (got: $email)" + fi +else + fail "vela login did not produce ~/.amr/config.json" +fi + +# vela ACP roundtrip (strict set_model gate enforced). +vela_acp_out=$(cat </dev/null +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}} +{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/tmp"}} +{"jsonrpc":"2.0","id":3,"method":"session/set_model","params":{"sessionId":"fake-vela-session-1","modelId":"deepseek-v3.2"}} +{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"fake-vela-session-1","prompt":[{"type":"text","text":"hi"}]}} +EOF +) +if printf '%s' "$vela_acp_out" | grep -q '"agentCapabilities"' \ + && printf '%s' "$vela_acp_out" | grep -q '"availableModels"' \ + && printf '%s' "$vela_acp_out" | grep -q '"id":3,"result":{}' \ + && printf '%s' "$vela_acp_out" | grep -q '"sessionUpdate":"agent_message_chunk"' \ + && printf '%s' "$vela_acp_out" | grep -q '"id":4,"result":{"stopReason":'; then + pass "vela agent run ACP roundtrip (initialize+models, set_model accepted, prompt streamed)" +else + fail "vela agent run ACP roundtrip incomplete" +fi + +# vela strict set_model gate — skipping set_model must reject prompt. +vela_gate_out=$(cat </dev/null +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}} +{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/tmp"}} +{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"fake-vela-session-1","prompt":[{"type":"text","text":"hi"}]}} +EOF +) +if printf '%s' "$vela_gate_out" | grep -q 'session/set_model must be called before session/prompt'; then + pass "vela strict set_model gate rejects session/prompt without prior set_model" +else + fail "vela strict set_model gate did not reject (negative-path regression)" +fi + +# ACP agents — JSON-RPC server. Send initialize+session/new+prompt and +# verify the protocol responses come back in order. +# kiro-cli and vibe-acp are the primary OD-facing bin names; test them +# alongside the fallback names (kiro, vibe). +for agent in hermes kimi kilo kiro kiro-cli vibe vibe-acp devin; do + out=$(cat </dev/null +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}} +{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/tmp"}} +{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"X","prompt":[{"type":"text","text":"hi"}]}} +EOF +) + # Expect: id=1 initialize result, id=2 session/new result, ≥1 session/update, id=3 prompt result + if printf '%s' "$out" | grep -q '"id":1,"result":{"protocolVersion":1' \ + && printf '%s' "$out" | grep -q '"id":2,"result":{"sessionId":' \ + && printf '%s' "$out" | grep -q '"sessionUpdate":"agent_message_chunk"' \ + && printf '%s' "$out" | grep -q '"id":3,"result":{"stopReason":'; then + pass "$agent ACP roundtrip complete (init → session/new → update → prompt result)" + else + fail "$agent ACP roundtrip incomplete" + fi +done + +echo +if [ "$failed" -eq 0 ]; then + echo "All mock CLIs working. ✅" +else + echo "$failed check(s) failed. ❌" + exit 1 +fi diff --git a/mocks/scripts/upload-recording.sh b/mocks/scripts/upload-recording.sh new file mode 100755 index 000000000..f6c834301 --- /dev/null +++ b/mocks/scripts/upload-recording.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Upload a new anonymized recording to Cloudflare R2 and update the +# local manifest. Local-maintainer flow — no GitHub Action involved, +# nothing about the recording ever lands in git. +# +# bash mocks/scripts/upload-recording.sh +# +# Prereqs +# ------- +# - `wrangler login` once (OAuth, no token to manage). The logged-in +# account must have access to the powerformer R2 namespace where the +# `open-design-mocks` bucket lives. +# - That's it. Bucket is public-read, manifest is in repo; consumers +# pull via `fetch-recordings.sh`. +# +# What it does +# ------------ +# 1. Validate the .jsonl (first line = meta event, UUID filename) +# 2. Compute sha256, parse meta, build manifest entry +# 3. `wrangler r2 object put` the recording → recordings/v1/.jsonl +# 4. Update mocks/manifest.json with the new entry (rebuilt histograms) +# 5. `wrangler r2 object put` the manifest too → recordings/v1/manifest.json +# 6. Tell you to commit + push the manifest change +# +# The .jsonl is never copied into the repo. Only mocks/manifest.json +# (≈200B added per entry) gets git-tracked. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +MOCKS_DIR="$(cd "$HERE/.." && pwd -P)" +MANIFEST="$MOCKS_DIR/manifest.json" +LIB="$HERE/lib/manifest-utils.mjs" +BUCKET='open-design-mocks' +KEY_PREFIX='recordings/v1/' +# powerformer hosts the bucket; pin so wrangler doesn't ask which +# account in non-interactive mode when the OAuth login spans several. +export CLOUDFLARE_ACCOUNT_ID='64ad4569ffd912432d6b86d5656484c4' + +if [ $# -lt 1 ] || [ "$1" = "-h" ] || [ "$1" = "--help" ]; then + sed -n '2,28p' "$0" | sed 's/^# \?//' + exit 0 +fi + +INPUT="$1" +if [ ! -f "$INPUT" ]; then echo "✗ no such file: $INPUT" >&2; exit 1; fi + +INPUT_ABS="$(cd "$(dirname "$INPUT")" && pwd -P)/$(basename "$INPUT")" +TRACE_ID="$(basename "$INPUT" .jsonl)" + +if ! printf '%s' "$TRACE_ID" | grep -qE '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; then + echo "✗ trace id '$TRACE_ID' is not a UUID. Rename the file." >&2 + exit 1 +fi + +if ! command -v wrangler >/dev/null 2>&1; then + echo "✗ wrangler not installed. \`npm i -g wrangler\` (or pnpm/yarn) and \`wrangler login\` first." >&2 + exit 1 +fi + +# Step 1+2: validate + build entry preview via shared lib (Node — same code +# the consumers use, so no shape drift) +ENTRY_JSON=$(node --input-type=module -e " +import { inspectRecording } from '$LIB'; +process.stdout.write(JSON.stringify(inspectRecording('$INPUT_ABS'), null, 2)); +" 2>&1) || { + echo "✗ validation failed:" >&2 + echo "$ENTRY_JSON" | sed 's/^/ /' >&2 + exit 1 +} + +echo "manifest entry to add:" +echo "$ENTRY_JSON" | sed 's/^/ /' +echo + +# Step 3: upload the recording itself +echo "→ uploading recording to R2…" +wrangler r2 object put "${BUCKET}/${KEY_PREFIX}${TRACE_ID}.jsonl" \ + --file "$INPUT_ABS" --remote >/dev/null +echo " ✓ $TRACE_ID.jsonl" + +# Step 4: update local manifest.json +echo "→ updating local mocks/manifest.json…" +node --input-type=module -e " +import { inspectRecording, upsertEntry, readManifest, writeManifest } from '$LIB'; +const m = readManifest('$MANIFEST'); +upsertEntry(m, inspectRecording('$INPUT_ABS')); +writeManifest('$MANIFEST', m); +console.log(' ✓ now ' + m.total + ' entries (' + (m.total_bytes/1024).toFixed(0) + ' KB total)'); +" + +# Step 5: upload the updated manifest to R2 so consumers see the new entry +# without waiting for the next git push. +echo "→ uploading manifest to R2…" +wrangler r2 object put "${BUCKET}/${KEY_PREFIX}manifest.json" \ + --file "$MANIFEST" --remote >/dev/null +echo " ✓ manifest.json" + +echo +echo "✅ done. Next:" +echo " git add mocks/manifest.json" +echo " git commit -m 'mocks: add recording $TRACE_ID'" +echo " git push" diff --git a/scripts/guard.ts b/scripts/guard.ts index f200b8311..6d7d1af88 100644 --- a/scripts/guard.ts +++ b/scripts/guard.ts @@ -117,6 +117,17 @@ const residualAllowedPathPrefixes = [ "design-templates/last30days/scripts/lib/vendor/", // Vendored upstream html-ppt runtime assets (lewislulu/html-ppt-skill, design template). "design-templates/html-ppt/assets/", + // Replay-based mock CLIs that impersonate the agent CLIs OD spawns + // (opencode/claude/codex/gemini/cursor-agent + ACP family). Need to + // be directly executable via Node so `child_process.spawn` from test + // harnesses and PATH-overlay shells work without any transform step. + // `mocks/scripts/` holds the maintainer-facing helpers (manifest math, + // fetch from R2) which are also pure-node single-file modules — same + // precedent as `apps/daemon/tests/fixtures/fake-vela.mjs` (an ACP + // stdio stub, allowlisted individually above). See `mocks/README.md`. + "mocks/lib/", + "mocks/mock-agent.mjs", + "mocks/scripts/", "test-results/", "vendor/", ]; From d6d42c3600878d8e53f055947c743202a214ad24 Mon Sep 17 00:00:00 2001 From: youcef zr <93142224+youcefzemmar@users.noreply.github.com> Date: Fri, 29 May 2026 08:25:03 +0100 Subject: [PATCH 20/56] fix(pack): bundle download and host packages in Linux AppImage assembly (#2845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux AppImage path assembles INTERNAL_PACKAGES as `file:` tarballs and runs `npm install --omit=dev` in an isolated app directory. `pnpm pack` rewrites each tarball's `workspace:*` refs to a concrete version, so any runtime @open-design/* dependency missing from INTERNAL_PACKAGES is resolved from the public npm registry and 404s. Linux ships webOutputMode "server" and tarball-installs every INTERNAL_PACKAGES entry, including @open-design/desktop and @open-design/web. @open-design/host (dep of web + desktop, added in #2246) and @open-design/download (dep of desktop, added in #2677) landed after the Linux package list was written and were never added to it, so `pnpm exec tools-pack linux build --to appimage` fails with: npm error 404 Not Found - GET .../@open-design%2fdownload mac/win default to "standalone", where desktop/web/packaged/daemon are prebundled with esbuild and excluded from the tarball install (shouldInstallInternalPackageFor{Mac,Win}Prebundle). The packages they do install have no download/host dependency, so those lanes correctly omit them and need no change — this fix stays scoped to linux.ts and touches no mac/win or workspace-build code. Add both packages to the Linux INTERNAL_PACKAGES and build them in buildWorkspaceArtifacts (download depends on platform). Add a cross-lane regression test that, for each lane, derives the set it actually installs (honoring the standalone prebundle exclusion) and asserts that set is closed under its runtime @open-design/* dependencies. The test is red on the linux lane without this fix and green with it, while mac/win pass either way — encoding why only Linux needs these packages. --- tools/pack/src/linux.ts | 6 +- .../tests/internal-packages-closure.test.ts | 83 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tools/pack/tests/internal-packages-closure.test.ts diff --git a/tools/pack/src/linux.ts b/tools/pack/src/linux.ts index 5508c6c6c..adb677626 100644 --- a/tools/pack/src/linux.ts +++ b/tools/pack/src/linux.ts @@ -46,12 +46,14 @@ const CONTAINER_PNPM_HOME = "/tmp/pnpm-home"; const CONTAINER_NODE_VERSION = "24.14.1"; const CONTAINER_TOOLS_PACK_CLI_PATH = "tools/pack/bin/tools-pack.mjs"; -const INTERNAL_PACKAGES = [ +export const INTERNAL_PACKAGES = [ { directory: "packages/contracts", name: "@open-design/contracts" }, { directory: "packages/registry-protocol", name: "@open-design/registry-protocol" }, { directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" }, { directory: "packages/sidecar", name: "@open-design/sidecar" }, { directory: "packages/platform", name: "@open-design/platform" }, + { directory: "packages/download", name: "@open-design/download" }, + { directory: "packages/host", name: "@open-design/host" }, { directory: "packages/agui-adapter", name: "@open-design/agui-adapter" }, { directory: "packages/plugin-runtime", name: "@open-design/plugin-runtime" }, { directory: "packages/diagnostics", name: "@open-design/diagnostics" }, @@ -392,6 +394,8 @@ async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise { await runPnpm(config, ["--filter", "@open-design/sidecar-proto", "build"]); await runPnpm(config, ["--filter", "@open-design/sidecar", "build"]); await runPnpm(config, ["--filter", "@open-design/platform", "build"]); + await runPnpm(config, ["--filter", "@open-design/host", "build"]); + await runPnpm(config, ["--filter", "@open-design/download", "build"]); await runPnpm(config, ["--filter", "@open-design/agui-adapter", "build"]); await runPnpm(config, ["--filter", "@open-design/plugin-runtime", "build"]); await runPnpm(config, ["--filter", "@open-design/diagnostics", "build"]); diff --git a/tools/pack/tests/internal-packages-closure.test.ts b/tools/pack/tests/internal-packages-closure.test.ts new file mode 100644 index 000000000..2f398266e --- /dev/null +++ b/tools/pack/tests/internal-packages-closure.test.ts @@ -0,0 +1,83 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { INTERNAL_PACKAGES as LINUX_INTERNAL_PACKAGES } from "../src/linux.js"; +import { INTERNAL_PACKAGES as MAC_INTERNAL_PACKAGES } from "../src/mac/constants.js"; +import { shouldInstallInternalPackageForMacPrebundle } from "../src/mac-prebundle.js"; +import { INTERNAL_PACKAGES as WIN_INTERNAL_PACKAGES } from "../src/win/constants.js"; +import { shouldInstallInternalPackageForWinPrebundle } from "../src/win-prebundle.js"; + +const workspaceRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); + +type PackageEntry = { readonly directory: string; readonly name: string }; + +function runtimeWorkspaceDeps(directory: string): string[] { + const manifest = JSON.parse( + readFileSync(join(workspaceRoot, directory, "package.json"), "utf8"), + ) as { dependencies?: Record }; + return Object.keys(manifest.dependencies ?? {}).filter((dep) => dep.startsWith("@open-design/")); +} + +// Each pack lane assembles its packaged app by `pnpm pack`-ing a subset of +// INTERNAL_PACKAGES into tarballs, wiring them as `file:` dependencies, and +// running an npm/pnpm install in the isolated app directory. `pnpm pack` +// rewrites every `workspace:*` ref to a concrete version, so the install +// resolves each tarball's runtime `@open-design/*` dependencies. Any such +// dependency that is NOT also installed as a local tarball is fetched from the +// public npm registry and 404s — these packages are workspace-only and never +// published. +// +// The invariant: the set a lane actually installs must be closed under its +// runtime `@open-design/*` dependencies. +// +// The lanes diverge by web output mode: +// - linux ships "server" mode and tarball-installs every INTERNAL_PACKAGES +// entry, including @open-design/desktop and @open-design/web — so it must +// also install their runtime deps (@open-design/download, @open-design/host). +// - mac/win default to "standalone", where desktop/web/packaged/daemon are +// prebundled with esbuild and excluded from the tarball install. The +// packages they do install have no download/host dependency, so those +// lanes correctly omit them. Adding download/host there would be dead +// weight and would drag in the shared workspace-build cache. +const LANES: { name: string; packages: readonly PackageEntry[]; isInstalled: (pkg: PackageEntry) => boolean }[] = [ + { + name: "linux", + packages: LINUX_INTERNAL_PACKAGES, + isInstalled: () => true, + }, + { + name: "mac", + packages: MAC_INTERNAL_PACKAGES, + isInstalled: (pkg) => + shouldInstallInternalPackageForMacPrebundle({ packageName: pkg.name, webOutputMode: "standalone" }), + }, + { + name: "win", + packages: WIN_INTERNAL_PACKAGES, + isInstalled: (pkg) => + shouldInstallInternalPackageForWinPrebundle({ packageName: pkg.name, webOutputMode: "standalone" }), + }, +]; + +describe("pack lane INTERNAL_PACKAGES dependency closure", () => { + for (const lane of LANES) { + it(`${lane.name}: every installed package's runtime @open-design deps are installed`, () => { + const installed = lane.packages.filter((pkg) => lane.isInstalled(pkg)); + const installedNames = new Set(installed.map((pkg) => pkg.name)); + const missing: { dependency: string; dependent: string }[] = []; + + for (const pkg of installed) { + for (const dependency of runtimeWorkspaceDeps(pkg.directory)) { + if (!installedNames.has(dependency)) { + missing.push({ dependency, dependent: pkg.name }); + } + } + } + + expect(missing).toEqual([]); + }); + } +}); From be09fe92da5d204fa687bc8217b0fc711a7d32b2 Mon Sep 17 00:00:00 2001 From: elihahah666 Date: Fri, 29 May 2026 15:33:57 +0800 Subject: [PATCH 21/56] fix: keep settings/handoff/avatar buttons fixed to the right in project header (#3279) Move the three buttons (settings, handoff, avatar) from fileActionsBefore to the actions slot so they always stay pinned to the right edge of the header, regardless of how many extra controls (Share, Present, etc.) are injected via portal during HTML preview. Co-authored-by: qiongyu1999 <2694684348@qq.com> Co-authored-by: Claude Opus 4 --- apps/web/src/components/ProjectView.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index e5c317a3c..02aaa4e62 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -4272,6 +4272,12 @@ export function ProjectView({ onBack={onBack} backLabel={t('project.backToProjects')} fileActionsBefore={( +

+ )} + actions={( <>