diff --git a/docs/frontend-ui-audit-2026-08-06/CanvasDesignContextualComposer.md b/docs/frontend-ui-audit-2026-08-06/CanvasDesignContextualComposer.md new file mode 100644 index 000000000..bd8836c61 --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-06/CanvasDesignContextualComposer.md @@ -0,0 +1,53 @@ +# Frontend UI Audit — CanvasDesignContextualComposer + +**Files:** `src/engines/Simulator/apps/canvas/design/CanvasDesignSurface.tsx`, `src/engines/ChatPanel/InputArea/index.tsx`, `src/engines/ChatPanel/InputArea/inputAreaPresentation.ts`, `src/engines/ChatPanel/InputArea/components/InputComposerBars.tsx`, `src/engines/ChatPanel/InputArea/components/InputEditor.tsx`, `src/components/ComposerInput/index.scss` +**Date:** 2026-08-06 +**Auditor:** Codex + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| ----------------------------- | ---------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | +| `InputArea/index.tsx:657` | hidden file `` | keep with reason | Native file-input behavior and its imperative ref are required by the shared upload flow; the control is hidden and activated through the design-system composer action. | — | +| `CanvasDesignSurface.tsx:93` | selected-element pill | keep with reason | Uses the shared `BasePill` editor variant and existing pill size token rather than introducing a Canvas-specific chip. | — | +| `CanvasDesignSurface.tsx:397` | selection close action | keep with reason | Uses the shared `IconButton` rather than introducing a raw interactive element. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| ---- | ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- | ---------------- | +| — | No new arbitrary color values | keep with reason | The contextual composer uses existing surface, text, border, fill, and primary tokens. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| ------------------------------- | ------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `CanvasDesignSurface.tsx:419` | 15px close icon | keep with reason | Sub-16px optical size matches compact toolbar icon proportions and does not represent layout spacing. | — | +| `CanvasDesignSurface.tsx:62-68` | prompt geometry constants | keep with reason | These values are viewport collision and Replay-control clearance bounds calculated in CSS pixels, not reusable visual spacing tokens. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| ----------------------------- | -------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------- | ---------------- | +| `CanvasDesignSurface.tsx:273` | contextual composer dialog | keep with reason | The portal has `role="dialog"` and a translated accessible name. | — | +| `CanvasDesignSurface.tsx:93` | selected-element pill | keep with reason | The dismiss action has a translated accessible name, native focus participation, and Enter/Space handling. | — | +| `CanvasDesignSurface.tsx:397` | close `IconButton` | keep with reason | The icon-only control has a translated `aria-label`; the nested icon is hidden from assistive technology. | — | + +## D5 — Visual Patterns Observed + +| Line | Element | Verdict | Reason | Suggested change | +| --------------------------------- | ------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `InputComposerBars.tsx:383-415` | contextual selected-element reference | fix | The reference previously sat beside the full-width `InputEditor` inside `ComposerBar`, so the two independent layout boxes produced a tall, offset first row. | Route the existing `BasePill` through `InputEditor.leadingContent`, keeping it on the editor's first line without adding it to the serialized document. Implemented. | +| `InputArea/index.tsx:360-379` | contextual composer geometry | fix | The Design prompt previously forced the stacked shared-composer presentation even for a single-line draft, leaving unnecessary vertical space. | Route eligible contextual prompts through the existing compact `ComposerShell`/`ComposerBar` state and retain the existing multiline expansion gate. Implemented. | +| `CanvasDesignSurface.tsx:266-305` | contextual composer visual shell | fix | A Canvas-only rounded background wrapper painted behind the shared compact shell, creating a second surface with a mismatched radius at the right edge. | Remove the duplicate painted wrapper, keep the portal as a non-painting drop-shadow container, and let the shared `ComposerShell` own background, border, and radius. Implemented. | +| `CanvasDesignSurface.tsx:93-123` | selected-element pill shell | keep with reason | It reuses `BasePill`, `PILL_SIZE`, and the editor-pill pointer-to-close interaction, so icon, type, color, and baseline stay aligned with editable `@pill` references. | — | + +- The feature extends the shared `InputArea`, `ComposerBar`, `ComposerShell`, `BasePill`, and `IconButton` paths. No parallel Canvas-only input or button pattern was introduced. +- The contextual layout is an explicit shared `InputArea` presentation and is covered alongside the existing compact presentation. +- `InputEditor.leadingContent` is deliberately a visual adornment rather than a `ComposerInput` document node: Canvas selection metadata already has a dedicated submit payload, so serializing the same reference would duplicate context and make an otherwise empty input appear sendable. + +## Summary + +- 3 fixes implemented +- 10 kept with documented reason +- 0 abstract candidates diff --git a/docs/frontend-ui-audit-2026-08-06/CanvasRevisionActivity.md b/docs/frontend-ui-audit-2026-08-06/CanvasRevisionActivity.md new file mode 100644 index 000000000..ee0e2f984 --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-06/CanvasRevisionActivity.md @@ -0,0 +1,46 @@ +# Frontend UI Audit — CanvasRevisionActivity + +**Files:** `src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionActivity.tsx`, `src/engines/ChatPanel/rendering/adapters/CanvasInlineAdapter.tsx` +**Date:** 2026-08-06 +**Auditor:** Codex + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| -------------------------------- | ------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `CanvasRevisionActivity.tsx:101` | navigable activity header | keep with reason | Reuses the shared `EventBlockHeader` and its tokenized `EventNavigateIcon`; no Canvas-only button or clickable shell was added. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| ---- | ----------------------------- | ---------------- | --------------------------------------------------------------------------------- | ---------------- | +| — | No new arbitrary color values | keep with reason | Navigation inherits the existing event-header fill, text, hover, and icon tokens. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| -------------------------------- | -------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `CanvasRevisionActivity.tsx:130` | 14px timeline offset | keep with reason | Existing optical alignment centers the progress rail beneath the shared event icon; the navigation change does not alter it. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| -------------------------------- | ---------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `CanvasRevisionActivity.tsx:101` | shared navigation affordance | keep with reason | Matches the established chat activity contract: the visible shared arrow is a native button, while the full header remains an additional pointer hit area. | — | + +## D5 — Visual Patterns Observed + +| Line | Element | Verdict | Reason | Suggested change | +| --------------------------------- | ----------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `CanvasRevisionActivity.tsx:51` | event-to-Canvas locate | keep with reason | Reuses `useBlockHeader` and the session replay pointer used by existing tool activities instead of introducing a second Canvas navigation path. | — | +| `CanvasRevisionActivity.tsx:115` | variable activity title | keep with reason | Uses the shared title slot's truncation contract and native hover text, so long Canvas names stay inside narrow chat columns. | — | +| `CanvasInlineAdapter.tsx:110-114` | revision event identity | keep with reason | The adapter forwards the persisted revision event ID, allowing replay and Canvas projection to resolve the corresponding latest logical Canvas. | — | + +- The activity deliberately uses the revision event as the replay anchor. The existing Canvas projection follows `target_event_id` / revision ancestry and materializes the latest valid state of that logical Canvas. +- Records without a stable event ID remain readable but inert, avoiding an ambiguous jump target. + +## Summary + +- 0 fixes required +- 7 kept with documented reason +- 0 abstract candidates diff --git a/docs/frontend-ui-audit-2026-08-06/CanvasRevisionProgress.md b/docs/frontend-ui-audit-2026-08-06/CanvasRevisionProgress.md new file mode 100644 index 000000000..d68f77027 --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-06/CanvasRevisionProgress.md @@ -0,0 +1,50 @@ +# Frontend UI Audit — CanvasRevisionActivityAndProgress + +**Files:** `src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionActivity.tsx`, `src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionProgress.tsx`, `src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionSteps.tsx`, `src/engines/ChatPanel/events/stream/agent-message/index.tsx`, `src/engines/Simulator/apps/canvas/CanvasApp.tsx`, `src/config/toolIcons.tsx` +**Date:** 2026-08-06 +**Auditor:** Codex + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| ------------------------------- | ------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `CanvasRevisionProgress.tsx:33` | progress status container | keep with reason | This is non-interactive status content, so a semantic `div` with `role="status"` is appropriate and does not duplicate a design-system control. | — | +| `CanvasApp.tsx:783` | Canvas overlay wrapper | keep with reason | The wrapper only positions a shared progress component and deliberately disables pointer events; it is not an interactive control. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| ------------------------------- | ------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `CanvasRevisionProgress.tsx:41` | `max-w-[min(28rem,calc(100vw-2rem))]` | keep with reason | The expression combines the desired compact maximum with a viewport collision bound; no single design token captures both constraints. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| ---------------------------------- | ----------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| `CanvasRevisionProgress.tsx:46-48` | 13px pen / 27px activity ring | keep with reason | These are optical icon sizes inside the token-sized `h-7 w-7` status mark, not reusable layout spacing. | — | +| `CanvasRevisionProgress.tsx:57` | 11px secondary status text | keep with reason | The compact secondary line follows the existing event-metadata hierarchy and remains supplementary to the 12px title. | — | +| `CanvasRevisionSteps.tsx:19-35` | step icon size | keep with reason | The icons use the shared `SESSION_UI_TOKENS.ICON.SIZE_XS` value rather than introducing a Canvas-local size. | — | +| `CanvasRevisionActivity.tsx:120` | `ml-[14px]` timeline inset | abstract | The same 14px icon-column inset appears in Thinking, ContextCompacted, and StackedBlock; it is an established pattern with four consumers. | Promote the full timeline inset/border class to a shared event-block primitive in a dedicated cleanup sweep. | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| ---------------------------------- | ------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------- | +| `CanvasRevisionProgress.tsx:36-37` | streamed revision status | keep with reason | `role="status"` with polite live announcements exposes phase changes without interrupting the user. | — | +| `CanvasRevisionProgress.tsx:46-50` | decorative progress icons | keep with reason | Both icons are hidden from assistive technology, and reduced-motion users receive a static indicator. | — | +| `CanvasRevisionSteps.tsx:62-76` | ordered work-step list | keep with reason | A translated list label names the process; icon state is reinforced by text and DOM state rather than color alone. | — | + +## D5 — Visual Patterns Observed + +- Chat and Canvas reuse one `CanvasRevisionProgress` component; only the placement variant changes. +- Running and historical surfaces reuse one `CanvasRevisionSteps` component and one pure phase-state mapping. +- The persistent record reuses `EventBlockHeader`, its icon/title/subtitle slots, and `getEventBlockContainerClasses` instead of creating a Canvas-specific card shell. +- Canvas resolves its icon through the shared Rust-to-Lucide registry; the missing `layout` mapping was fixed in `toolIcons.tsx` rather than hardcoding an icon in the activity component. +- The component uses existing background, border, text, and primary tokens. No Canvas-only button, input, or color system was introduced. +- The Canvas overlay is pointer-transparent, so it cannot steal hover, selection, or Design-mode input from the preview beneath it. + +## Summary + +- 0 fixes recommended +- 9 kept with documented reason +- 1 abstract candidate diff --git a/src-tauri/crates/agent-core/src/core/definitions/builtin/prompts/sde.md b/src-tauri/crates/agent-core/src/core/definitions/builtin/prompts/sde.md index 48bbae68b..82ca48b8c 100644 --- a/src-tauri/crates/agent-core/src/core/definitions/builtin/prompts/sde.md +++ b/src-tauri/crates/agent-core/src/core/definitions/builtin/prompts/sde.md @@ -16,7 +16,8 @@ A request for a sketch, wireframe, mockup, prototype, interaction concept, or "s - Keep the sketch self-contained. Define an `App` component with JSX and use `React.useState` or `React.useReducer` for interactions. Do not add imports, install packages, edit project files, call network APIs, access app globals, or write to browser storage. - Use plausible sample data and make the primary path actually clickable. Include useful empty, disabled, validation, or completion states when they are material to the idea. - Use `mode: "a2ui"` for structured reports, tables, charts, or simple forms that do not need a custom interaction flow. Use `mode: "html"` only for static bespoke layouts. -- Treat follow-up feedback as a revision of the sketch. Render the revised canvas instead of implementing the product unless the user explicitly asks to build it. +- Treat follow-up feedback as a revision of the sketch instead of implementing the product unless the user explicitly asks to build it. For a Canvas Design request, call `revise_inline_canvas` with the exact supplied `target_event_id`; never call `render_inline_canvas` for that revision. Include `agent_steps` before `edits` or `content`: generate 1–6 short factual user-visible operation labels in the user's language and specific to this request, never a fixed template or private reasoning. Use compact exact `edits` for localized copy, value, or style changes, and return complete replacement content only for structural changes. Preserve unrelated behavior and styling from the current Canvas source included in the request. +- Before calling `revise_inline_canvas`, stream one short factual user-visible update that names the concrete change being made. Do not expose private chain-of-thought. After the tool is accepted, give a concise result summary. - A successful tool result only means the payload was accepted. Do not say the sketch was visually verified unless you inspected the rendered result. ## Code quality diff --git a/src-tauri/crates/agent-core/src/core/definitions/builtin/sde.rs b/src-tauri/crates/agent-core/src/core/definitions/builtin/sde.rs index 72405b479..524cba807 100644 --- a/src-tauri/crates/agent-core/src/core/definitions/builtin/sde.rs +++ b/src-tauri/crates/agent-core/src/core/definitions/builtin/sde.rs @@ -151,6 +151,12 @@ mod tests { .any(|tool| tool == tool_names::RENDER_INLINE_CANVAS), "SDE Agent must keep render_inline_canvas available for interactive sketches" ); + assert!( + !excluded + .iter() + .any(|tool| tool == tool_names::REVISE_INLINE_CANVAS), + "SDE Agent must keep revise_inline_canvas available for Canvas revisions" + ); } #[test] @@ -199,6 +205,14 @@ mod tests { assert!(prompt.contains("## Interactive sketches")); assert!(prompt.contains("not authorization to implement")); assert!(prompt.contains("render_inline_canvas")); + assert!(prompt.contains("revise_inline_canvas")); + assert!(prompt.contains("target_event_id")); + assert!(prompt.contains("`agent_steps`")); + assert!(prompt.contains("never a fixed template")); + assert!(prompt.contains("user's language")); + assert!(prompt.contains("compact exact `edits`")); + assert!(prompt.contains("user-visible update")); + assert!(prompt.contains("Do not expose private chain-of-thought")); assert!(prompt.contains("mode: \"react\"")); assert!(prompt.contains("React.useState")); assert!(prompt.contains("Do not add imports")); diff --git a/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/coding.rs b/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/coding.rs index cc650b9f5..cd7bd182a 100644 --- a/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/coding.rs +++ b/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/coding.rs @@ -480,6 +480,26 @@ pub(super) static TOOLS: &[ToolEntry] = &[ ], ..DEFAULT_TOOL_ENTRY }, + ToolEntry { + name: tool_names::REVISE_INLINE_CANVAS, + description: "Revise an existing inline Canvas without creating a new one.", + description_detail: "Replaces the content of a previously rendered inline Canvas while preserving its logical identity and sidebar position. Requires the exact target Canvas event id and validates that the target belongs to the same session. Supports the same html, url, react, and a2ui payload modes as render_inline_canvas.", + category: tool_categories::CODING, + icon_id: "layout", + simulator_app: AppCanvas, + app_subtool: OtherTool, + chat_block: CbCanvasInline, + label_running: "tools.renderInlineCanvasRunning", + label_done: "tools.renderInlineCanvasDone", + label_failed: "tools.renderInlineCanvasFailed", + actions: &[ + action_sub!("html", "Revise with a self-contained HTML/SVG/CSS snippet", OtherTool, chat: CbCanvasInline, labels: "tools.renderInlineCanvasHtmlRunning", "tools.renderInlineCanvasHtmlDone", "tools.renderInlineCanvasHtmlFailed"), + action_sub!("url", "Revise with an HTTPS URL external-open action", OtherTool, chat: CbCanvasInline, labels: "tools.renderInlineCanvasUrlRunning", "tools.renderInlineCanvasUrlDone", "tools.renderInlineCanvasUrlFailed"), + action_sub!("react", "Revise with a stateful JSX App component", OtherTool, chat: CbCanvasInline, labels: "tools.renderInlineCanvasHtmlRunning", "tools.renderInlineCanvasHtmlDone", "tools.renderInlineCanvasHtmlFailed"), + action_sub!("a2ui", "Revise with typed UI elements", OtherTool, chat: CbCanvasInline, labels: "tools.renderInlineCanvasA2uiRunning", "tools.renderInlineCanvasA2uiDone", "tools.renderInlineCanvasA2uiFailed"), + ], + ..DEFAULT_TOOL_ENTRY + }, ToolEntry { name: tool_names::MANAGE_FILE_HISTORY, description: "Inspect and rewind file-history snapshots for this session.", diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/coding/render_inline_canvas.rs b/src-tauri/crates/agent-core/src/core/tools/impls/coding/render_inline_canvas.rs index 3ba725b37..803a2b246 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/coding/render_inline_canvas.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/coding/render_inline_canvas.rs @@ -21,22 +21,430 @@ //! `canvas-inline-event` window event pipeline — not via the tool result text. use async_trait::async_trait; +use rusqlite::{Connection, OptionalExtension}; use serde_json::Value; +use std::collections::HashSet; use crate::tools::names as tool_names; use crate::tools::traits::{Tool, ToolError}; pub struct RenderInlineCanvasTool; +pub struct ReviseInlineCanvasTool; -fn format_canvas_acceptance(mode: &str, content_len: usize, title: &str, url: &str) -> String { +const MAX_CANVAS_REVISION_EDITS: usize = 16; +const MAX_CANVAS_REVISION_EDIT_CHARS: usize = 32_768; +const MAX_CANVAS_REVISION_AGENT_STEPS: usize = 6; +const MAX_CANVAS_REVISION_AGENT_STEP_CHARS: usize = 80; +const MAX_CANVAS_REVISION_CHAIN_DEPTH: usize = 32; + +fn format_canvas_acceptance( + tool_name: &str, + mode: &str, + content_len: usize, + title: &str, + url: &str, +) -> String { match mode { "html" | "a2ui" | "react" => format!( - "render_inline_canvas: accepted {mode} content ({content_len} bytes), title=\"{title}\"; visual output not verified" + "{tool_name}: accepted {mode} content ({content_len} bytes), title=\"{title}\"; visual output not verified" ), "url" => format!( - "render_inline_canvas: accepted url=\"{url}\", title=\"{title}\"; visual output not verified" + "{tool_name}: accepted url=\"{url}\", title=\"{title}\"; visual output not verified" ), - _ => format!("render_inline_canvas: accepted mode={mode}, title=\"{title}\""), + _ => format!("{tool_name}: accepted mode={mode}, title=\"{title}\""), + } +} + +fn canvas_parameters(revision: bool) -> Value { + let mut schema = serde_json::json!({ + "type": "object", + "required": ["mode"], + "properties": { + "mode": { + "type": "string", + "enum": ["html", "url", "a2ui", "react"], + "description": "Rendering mode: \"html\" for inline HTML, \"url\" for URL embed, \"a2ui\" for streamed typed elements, \"react\" for a React App component sandbox." + }, + "content": { + "type": "string", + "description": "The complete HTML/SVG/CSS string for \"html\" mode, JavaScript React App component source for \"react\" mode, or JSONL payload for \"a2ui\" mode. Not used in \"url\" mode." + }, + "url": { + "type": "string", + "description": "The HTTPS URL to embed. Required for \"url\" mode; ignored for other modes." + }, + "title": { + "type": "string", + "description": "Optional human-readable title shown in the card header." + }, + "streaming": { + "type": "boolean", + "description": "Set to true when content will be appended in multiple calls (a2ui streaming). Defaults to false." + } + }, + "additionalProperties": false + }); + + if revision { + schema["required"] = serde_json::json!(["target_event_id", "mode", "agent_steps"]); + schema["properties"] + .as_object_mut() + .expect("canvas properties are an object") + .insert( + "agent_steps".to_string(), + serde_json::json!({ + "type": "array", + "minItems": 1, + "maxItems": MAX_CANVAS_REVISION_AGENT_STEPS, + "description": "Ordered short, factual, user-visible labels describing the concrete operations for this revision. Generate these labels for the current request in the user's language; do not use a fixed template and do not include private reasoning. Emit agent_steps before edits or content so progress can appear while the remaining arguments stream.", + "items": { + "type": "string", + "minLength": 1, + "maxLength": MAX_CANVAS_REVISION_AGENT_STEP_CHARS + } + }), + ); + schema["properties"] + .as_object_mut() + .expect("canvas properties are an object") + .insert( + "target_event_id".to_string(), + serde_json::json!({ + "type": "string", + "minLength": 1, + "description": "Exact event id of the existing Canvas version to replace. It must belong to the current session and identify a render_inline_canvas or revise_inline_canvas event." + }), + ); + schema["properties"] + .as_object_mut() + .expect("canvas properties are an object") + .insert( + "edits".to_string(), + serde_json::json!({ + "type": "array", + "minItems": 1, + "maxItems": MAX_CANVAS_REVISION_EDITS, + "description": "Preferred for localized copy, value, or style changes. Apply these exact literal replacements to the current materialized Canvas source instead of returning the complete content. Each edit must match exactly once unless all=true.", + "items": { + "type": "object", + "required": ["find", "replace"], + "properties": { + "find": { + "type": "string", + "minLength": 1, + "maxLength": MAX_CANVAS_REVISION_EDIT_CHARS, + "description": "Exact literal source text to find. Include enough surrounding text to make the match unique." + }, + "replace": { + "type": "string", + "maxLength": MAX_CANVAS_REVISION_EDIT_CHARS, + "description": "Literal replacement source text." + }, + "all": { + "type": "boolean", + "description": "Set true only when every occurrence should change. Defaults to false, which requires exactly one match." + } + }, + "additionalProperties": false + } + }), + ); + schema["properties"]["content"]["description"] = serde_json::json!( + "Complete replacement source for structural revisions. Omit this field when using edits for a localized change." + ); + } + + schema +} + +fn canvas_revision_edits(params: &Value) -> Option<&Vec> { + params.get("edits").and_then(Value::as_array) +} + +fn validate_canvas_revision_edits(edits: &[Value]) -> Result<(), ToolError> { + if edits.is_empty() || edits.len() > MAX_CANVAS_REVISION_EDITS { + return Err(ToolError::InvalidParams(format!( + "field \"edits\" must contain 1 to {MAX_CANVAS_REVISION_EDITS} operations" + ))); + } + + for (index, edit) in edits.iter().enumerate() { + let find = edit.get("find").and_then(Value::as_str).ok_or_else(|| { + ToolError::InvalidParams(format!("edits[{index}].find must be a non-empty string")) + })?; + let replace = edit.get("replace").and_then(Value::as_str).ok_or_else(|| { + ToolError::InvalidParams(format!("edits[{index}].replace must be a string")) + })?; + if find.is_empty() || find.len() > MAX_CANVAS_REVISION_EDIT_CHARS { + return Err(ToolError::InvalidParams(format!( + "edits[{index}].find must contain 1 to {MAX_CANVAS_REVISION_EDIT_CHARS} bytes" + ))); + } + if replace.len() > MAX_CANVAS_REVISION_EDIT_CHARS { + return Err(ToolError::InvalidParams(format!( + "edits[{index}].replace exceeds {MAX_CANVAS_REVISION_EDIT_CHARS} bytes" + ))); + } + if find == replace { + return Err(ToolError::InvalidParams(format!( + "edits[{index}] must change the matched source" + ))); + } + if edit.get("all").is_some_and(|value| !value.is_boolean()) { + return Err(ToolError::InvalidParams(format!( + "edits[{index}].all must be a boolean" + ))); + } + } + Ok(()) +} + +fn validate_canvas_revision_agent_steps(params: &Value) -> Result<(), ToolError> { + let steps = params + .get("agent_steps") + .and_then(Value::as_array) + .ok_or_else(|| ToolError::InvalidParams("missing required field: agent_steps".into()))?; + if steps.is_empty() || steps.len() > MAX_CANVAS_REVISION_AGENT_STEPS { + return Err(ToolError::InvalidParams(format!( + "field \"agent_steps\" must contain 1 to {MAX_CANVAS_REVISION_AGENT_STEPS} labels" + ))); + } + + for (index, step) in steps.iter().enumerate() { + let label = step.as_str().ok_or_else(|| { + ToolError::InvalidParams(format!("agent_steps[{index}] must be a string")) + })?; + let character_count = label.trim().chars().count(); + if character_count == 0 || character_count > MAX_CANVAS_REVISION_AGENT_STEP_CHARS { + return Err(ToolError::InvalidParams(format!( + "agent_steps[{index}] must contain 1 to {MAX_CANVAS_REVISION_AGENT_STEP_CHARS} characters" + ))); + } + } + Ok(()) +} + +fn validate_canvas_payload(params: &Value, allow_edits: bool) -> Result<(), ToolError> { + let mode = params + .get("mode") + .and_then(Value::as_str) + .ok_or_else(|| ToolError::InvalidParams("missing required field: mode".into()))?; + + let edits = canvas_revision_edits(params); + if allow_edits { + validate_canvas_revision_agent_steps(params)?; + } else if params.get("agent_steps").is_some() { + return Err(ToolError::InvalidParams( + "field \"agent_steps\" is only available on revise_inline_canvas".into(), + )); + } + if edits.is_some() && !allow_edits { + return Err(ToolError::InvalidParams( + "field \"edits\" is only available on revise_inline_canvas".into(), + )); + } + + if let Some(edits) = edits { + if params.get("content").is_some() || params.get("url").is_some() { + return Err(ToolError::InvalidParams( + "use either edits or a complete content/url replacement, not both".into(), + )); + } + if mode == "url" { + return Err(ToolError::InvalidParams( + "targeted edits require a source-backed html, a2ui, or react Canvas".into(), + )); + } + validate_canvas_revision_edits(edits)?; + return Ok(()); + } + + match mode { + "html" | "a2ui" | "react" => { + if params.get("content").and_then(Value::as_str).is_none() { + return Err(ToolError::InvalidParams( + "field \"content\" is required for html, a2ui, and react modes".into(), + )); + } + } + "url" => { + let url = params.get("url").and_then(Value::as_str).ok_or_else(|| { + ToolError::InvalidParams("field \"url\" is required for url mode".into()) + })?; + if !url.starts_with("https://") && !url.starts_with('/') { + return Err(ToolError::InvalidParams( + "url mode requires an HTTPS URL or a relative path".into(), + )); + } + } + other => { + return Err(ToolError::InvalidParams(format!( + "unknown mode \"{other}\"; expected one of: html, url, a2ui, react" + ))); + } + } + + Ok(()) +} + +fn canvas_acceptance(tool_name: &str, params: &Value) -> String { + let mode = params + .get("mode") + .and_then(Value::as_str) + .expect("validated canvas mode"); + let title = params + .get("title") + .and_then(Value::as_str) + .unwrap_or("(no title)"); + let content_len = params + .get("content") + .and_then(Value::as_str) + .map(str::len) + .unwrap_or(0); + let url = params.get("url").and_then(Value::as_str).unwrap_or(""); + + if let Some(edit_count) = canvas_revision_edits(params).map(Vec::len) { + return format!( + "{tool_name}: accepted {edit_count} targeted source edit(s), title=\"{title}\"; visual output not verified" + ); + } + + format_canvas_acceptance(tool_name, mode, content_len, title, url) +} + +fn apply_canvas_revision_edits(source: &str, edits: &[Value]) -> Result { + validate_canvas_revision_edits(edits)?; + let mut content = source.to_string(); + + for (index, edit) in edits.iter().enumerate() { + let find = edit + .get("find") + .and_then(Value::as_str) + .expect("validated edit find"); + let replace = edit + .get("replace") + .and_then(Value::as_str) + .expect("validated edit replace"); + let replace_all = edit.get("all").and_then(Value::as_bool).unwrap_or(false); + let matches = content.match_indices(find).count(); + if matches == 0 { + return Err(ToolError::InvalidParams(format!( + "edits[{index}].find no longer matches the current Canvas source" + ))); + } + if !replace_all && matches != 1 { + return Err(ToolError::InvalidParams(format!( + "edits[{index}].find matched {matches} times; make it unique or set all=true deliberately" + ))); + } + content = if replace_all { + content.replace(find, replace) + } else { + content.replacen(find, replace, 1) + }; + } + + Ok(content) +} + +fn load_materialized_canvas_args( + connection: &Connection, + session_id: &str, + event_id: &str, + visited: &mut HashSet, + depth: usize, +) -> Result { + if depth >= MAX_CANVAS_REVISION_CHAIN_DEPTH || !visited.insert(event_id.to_string()) { + return Err(ToolError::InvalidParams( + "Canvas revision chain is cyclic or exceeds the supported depth".into(), + )); + } + + let row = connection + .query_row( + "SELECT function_name, args_json FROM events WHERE session_id = ?1 AND id = ?2 LIMIT 1", + rusqlite::params![session_id, event_id], + |row| Ok((row.get::<_, Option>(0)?, row.get::<_, String>(1)?)), + ) + .optional() + .map_err(|error| { + ToolError::ExecutionFailed(format!("could not load Canvas revision target: {error}")) + })?; + + let Some((function_name, args_json)) = row else { + return Err(ToolError::InvalidParams(format!( + "target_event_id \"{event_id}\" does not identify an inline Canvas in the current session" + ))); + }; + if !matches!( + function_name.as_deref(), + Some(tool_names::RENDER_INLINE_CANVAS | tool_names::REVISE_INLINE_CANVAS) + ) { + return Err(ToolError::InvalidParams(format!( + "target_event_id \"{event_id}\" does not identify an inline Canvas" + ))); + } + + let args: Value = serde_json::from_str(&args_json).map_err(|error| { + ToolError::ExecutionFailed(format!("stored Canvas arguments are invalid JSON: {error}")) + })?; + let Some(edits) = canvas_revision_edits(&args) else { + return Ok(args); + }; + let parent_id = args + .get("target_event_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + ToolError::InvalidParams("stored compact Canvas revision has no target_event_id".into()) + })?; + let mut materialized = + load_materialized_canvas_args(connection, session_id, parent_id, visited, depth + 1)?; + let source = materialized + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| { + ToolError::InvalidParams("target Canvas has no patchable source content".into()) + })?; + let content = apply_canvas_revision_edits(source, edits)?; + let object = materialized.as_object_mut().ok_or_else(|| { + ToolError::ExecutionFailed("stored Canvas arguments must be an object".into()) + })?; + object.insert("content".into(), Value::String(content)); + if let Some(title) = args.get("title").and_then(Value::as_str) { + object.insert("title".into(), Value::String(title.to_string())); + } + Ok(materialized) +} + +fn validate_revision_target( + connection: &Connection, + session_id: &str, + target_event_id: &str, +) -> Result<(), ToolError> { + let function_name = connection + .query_row( + "SELECT function_name FROM events WHERE session_id = ?1 AND id = ?2 LIMIT 1", + rusqlite::params![session_id, target_event_id], + |row| row.get::<_, Option>(0), + ) + .optional() + .map_err(|error| { + ToolError::ExecutionFailed(format!( + "could not validate Canvas revision target: {error}" + )) + })? + .flatten(); + + match function_name.as_deref() { + Some(tool_names::RENDER_INLINE_CANVAS | tool_names::REVISE_INLINE_CANVAS) => Ok(()), + Some(other) => Err(ToolError::InvalidParams(format!( + "target_event_id \"{target_event_id}\" identifies {other}, not an inline Canvas" + ))), + None => Err(ToolError::InvalidParams(format!( + "target_event_id \"{target_event_id}\" does not identify an inline Canvas in the current session" + ))), } } @@ -52,6 +460,18 @@ impl Default for RenderInlineCanvasTool { } } +impl ReviseInlineCanvasTool { + pub fn new() -> Self { + Self + } +} + +impl Default for ReviseInlineCanvasTool { + fn default() -> Self { + Self::new() + } +} + #[async_trait] impl Tool for RenderInlineCanvasTool { fn name(&self) -> &str { @@ -103,6 +523,9 @@ impl Tool for RenderInlineCanvasTool { - Prefer \"html\" only for static bespoke layouts that none of the a2ui types can express.\n\ - Keep HTML payloads under 64 KB for smooth rendering.\n\ - Always set a descriptive \"title\" — it appears in the card header.\n\ + - This tool creates a new logical Canvas. For a Canvas Design request or any\n\ + update to an existing Canvas, call revise_inline_canvas instead; do not call\n\ + render_inline_canvas for the revised payload.\n\ - A successful tool result only confirms that the payload was accepted by the UI.\n\ It does not prove visual correctness. Do not claim the preview was visually\n\ verified unless you inspected it through a screenshot or browser snapshot." @@ -117,34 +540,7 @@ impl Tool for RenderInlineCanvasTool { } fn parameters(&self) -> Value { - serde_json::json!({ - "type": "object", - "required": ["mode"], - "properties": { - "mode": { - "type": "string", - "enum": ["html", "url", "a2ui", "react"], - "description": "Rendering mode: \"html\" for inline HTML, \"url\" for URL embed, \"a2ui\" for streamed typed elements, \"react\" for a React App component sandbox." - }, - "content": { - "type": "string", - "description": "The HTML/SVG/CSS string for \"html\" mode, JavaScript React App component source for \"react\" mode, or the JSONL payload for \"a2ui\" mode. Not used in \"url\" mode." - }, - "url": { - "type": "string", - "description": "The HTTPS URL to embed. Required for \"url\" mode; ignored for other modes." - }, - "title": { - "type": "string", - "description": "Optional human-readable title shown in the card header." - }, - "streaming": { - "type": "boolean", - "description": "Set to true when content will be appended in multiple calls (a2ui streaming). Defaults to false." - } - }, - "additionalProperties": false - }) + canvas_parameters(false) } async fn execute_text( @@ -152,35 +548,7 @@ impl Tool for RenderInlineCanvasTool { params: Value, _ctx: &crate::tools::traits::CallContext, ) -> Result { - let mode = params - .get("mode") - .and_then(Value::as_str) - .ok_or_else(|| ToolError::InvalidParams("missing required field: mode".into()))?; - - match mode { - "html" | "a2ui" | "react" => { - if params.get("content").and_then(Value::as_str).is_none() { - return Err(ToolError::InvalidParams( - "field \"content\" is required for html, a2ui, and react modes".into(), - )); - } - } - "url" => { - let url = params.get("url").and_then(Value::as_str).ok_or_else(|| { - ToolError::InvalidParams("field \"url\" is required for url mode".into()) - })?; - if !url.starts_with("https://") && !url.starts_with('/') { - return Err(ToolError::InvalidParams( - "url mode requires an HTTPS URL or a relative path".into(), - )); - } - } - other => { - return Err(ToolError::InvalidParams(format!( - "unknown mode \"{other}\"; expected one of: html, url, a2ui, react" - ))); - } - } + validate_canvas_payload(¶ms, false)?; // Return a concise confirmation — the actual content is not echoed back // to the LLM because it can be many KB of HTML/JSONL that would bloat @@ -188,30 +556,140 @@ impl Tool for RenderInlineCanvasTool { // `agent:tool_call` args (dispatched before this result arrives), so // the full content is already available without appearing in the LLM // tool_result message. - let title = params - .get("title") - .and_then(Value::as_str) - .unwrap_or("(no title)"); + Ok(canvas_acceptance(tool_names::RENDER_INLINE_CANVAS, ¶ms)) + } +} + +#[async_trait] +impl Tool for ReviseInlineCanvasTool { + fn name(&self) -> &str { + tool_names::REVISE_INLINE_CANVAS + } + + fn description(&self) -> &str { + "Revise an existing inline Canvas in place without creating a second logical Canvas.\n\ + Use this for every Canvas Design request and for any follow-up that changes an\n\ + existing sketch. Pass the exact target event id supplied by the request. The\n\ + target must be an earlier render_inline_canvas or revise_inline_canvas event in\n\ + the current session. For localized copy, value, or style changes, prefer edits:\n\ + exact literal find/replace operations applied to the current materialized source.\n\ + Include enough surrounding source in find to make it unique; set all=true only\n\ + when every occurrence should change. For structural revisions, return the complete\n\ + replacement content instead. Generate agent_steps for the current request: 1 to 6\n\ + short factual user-visible operation labels in the user's language, ordered and\n\ + specific to the requested change, never a fixed template or private reasoning. Emit agent_steps before edits\n\ + or content so they can appear while the remaining arguments stream. Before calling\n\ + this tool, send one short factual\n\ + user-visible update naming the concrete change; do not expose private chain-of-thought.\n\ + Preserve unrelated content, behavior, local state,\n\ + and styling. Supports html, url, a2ui, and react modes. A successful\n\ + result confirms acceptance only; do not claim visual verification without inspection." + } + + fn category(&self) -> &str { + crate::tools::categories::GENERAL + } - let content_len = params - .get("content") + fn is_read_only(&self) -> bool { + true + } + + fn parameters(&self) -> Value { + canvas_parameters(true) + } + + async fn execute_text( + &self, + params: Value, + ctx: &crate::tools::traits::CallContext, + ) -> Result { + validate_canvas_payload(¶ms, true)?; + + let target_event_id = params + .get("target_event_id") .and_then(Value::as_str) - .map(|s| s.len()) - .unwrap_or(0); + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + ToolError::InvalidParams("missing required field: target_event_id".into()) + })?; + + if ctx.session_id.trim().is_empty() { + return Err(ToolError::ExecutionFailed( + "Canvas revision requires a dispatching session id".into(), + )); + } + if !ctx.call_id.is_empty() && target_event_id == format!("tool-call-{}", ctx.call_id) { + return Err(ToolError::InvalidParams( + "target_event_id cannot identify the revision call itself".into(), + )); + } + + let connection = crate::foundation::db_bridge::get_connection().map_err(|error| { + ToolError::ExecutionFailed(format!( + "could not open session persistence to validate Canvas target: {error}" + )) + })?; + validate_revision_target(&connection, ctx.session_id.trim(), target_event_id)?; - let url = params.get("url").and_then(Value::as_str).unwrap_or(""); - Ok(format_canvas_acceptance(mode, content_len, title, url)) + if let Some(edits) = canvas_revision_edits(¶ms) { + let mut visited = HashSet::new(); + let target_args = load_materialized_canvas_args( + &connection, + ctx.session_id.trim(), + target_event_id, + &mut visited, + 0, + )?; + let target_mode = target_args + .get("mode") + .and_then(Value::as_str) + .ok_or_else(|| { + ToolError::InvalidParams("target Canvas has no valid rendering mode".into()) + })?; + let requested_mode = params + .get("mode") + .and_then(Value::as_str) + .expect("validated Canvas mode"); + if requested_mode != target_mode { + return Err(ToolError::InvalidParams(format!( + "targeted edits cannot change Canvas mode from {target_mode} to {requested_mode}; use a complete replacement" + ))); + } + let source = target_args + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| { + ToolError::InvalidParams("target Canvas has no patchable source content".into()) + })?; + apply_canvas_revision_edits(source, edits)?; + } + + Ok(canvas_acceptance(tool_names::REVISE_INLINE_CANVAS, ¶ms)) } } #[cfg(test)] mod tests { - use super::{format_canvas_acceptance, RenderInlineCanvasTool}; - use crate::tools::traits::Tool; + use super::{ + apply_canvas_revision_edits, canvas_acceptance, format_canvas_acceptance, + load_materialized_canvas_args, validate_canvas_payload, validate_revision_target, + RenderInlineCanvasTool, ReviseInlineCanvasTool, + }; + use crate::tools::names as tool_names; + use crate::tools::traits::{CallContext, Tool, ToolError}; + use rusqlite::{params, Connection}; + use serde_json::json; #[test] fn acceptance_does_not_claim_visual_success() { - let result = format_canvas_acceptance("html", 42, "Prototype", ""); + let result = format_canvas_acceptance( + tool_names::RENDER_INLINE_CANVAS, + "html", + 42, + "Prototype", + "", + ); assert!(result.contains("accepted html content")); assert!(result.contains("visual output not verified")); @@ -220,7 +698,13 @@ mod tests { #[test] fn url_acceptance_does_not_claim_embedding_succeeded() { - let result = format_canvas_acceptance("url", 0, "Docs", "https://example.com"); + let result = format_canvas_acceptance( + tool_names::RENDER_INLINE_CANVAS, + "url", + 0, + "Docs", + "https://example.com", + ); assert!(result.contains("accepted url=\"https://example.com\"")); assert!(result.contains("visual output not verified")); @@ -238,4 +722,225 @@ mod tests { assert!(!description.contains("JSX is not transformed")); assert!(!description.contains("Hooks and ReactDOM APIs are not available")); } + + #[test] + fn creation_and_revision_have_distinct_identity_contracts() { + let creation_schema = RenderInlineCanvasTool::new().parameters(); + let creation_properties = creation_schema["properties"] + .as_object() + .expect("render_inline_canvas properties"); + assert!(!creation_properties.contains_key("revises_event_id")); + assert!(!creation_properties.contains_key("target_event_id")); + + let revision_schema = ReviseInlineCanvasTool::new().parameters(); + let revision_properties = revision_schema["properties"] + .as_object() + .expect("revise_inline_canvas properties"); + assert!(revision_properties.contains_key("target_event_id")); + assert!(revision_properties.contains_key("edits")); + assert!(revision_properties.contains_key("agent_steps")); + assert_eq!( + revision_schema["required"], + json!(["target_event_id", "mode", "agent_steps"]) + ); + assert_eq!(revision_schema["additionalProperties"], false); + } + + #[test] + fn compact_revision_acceptance_reports_edits_without_echoing_source() { + let result = canvas_acceptance( + tool_names::REVISE_INLINE_CANVAS, + &json!({ + "target_event_id": "canvas-a", + "mode": "react", + "edits": [{"find": "Start", "replace": "Start setup"}] + }), + ); + + assert!(result.contains("accepted 1 targeted source edit")); + assert!(result.contains("visual output not verified")); + assert!(!result.contains("Start setup")); + } + + #[test] + fn revision_description_requests_a_factual_visible_progress_update() { + let tool = ReviseInlineCanvasTool::new(); + let description = tool.description(); + + assert!(description.contains("user-visible update")); + assert!(description.contains("do not expose private chain-of-thought")); + assert!(description.contains("prefer edits")); + assert!(description.contains("never a fixed template")); + assert!(description.contains("user's language")); + assert!(description.contains("Emit agent_steps before edits")); + } + + #[test] + fn revision_requires_bounded_agent_generated_steps() { + let missing = validate_canvas_payload( + &json!({ + "target_event_id": "canvas-a", + "mode": "react", + "content": "function App() { return null; }" + }), + true, + ); + assert!(matches!( + missing, + Err(ToolError::InvalidParams(message)) if message.contains("agent_steps") + )); + + let whitespace = validate_canvas_payload( + &json!({ + "target_event_id": "canvas-a", + "mode": "react", + "agent_steps": [" "], + "content": "function App() { return null; }" + }), + true, + ); + assert!(matches!( + whitespace, + Err(ToolError::InvalidParams(message)) if message.contains("agent_steps[0]") + )); + + assert!(validate_canvas_payload( + &json!({ + "target_event_id": "canvas-a", + "mode": "react", + "agent_steps": ["替换按钮文案", "核对原有交互"], + "content": "function App() { return null; }" + }), + true, + ) + .is_ok()); + } + + #[test] + fn compact_edits_require_a_unique_match_by_default() { + let ambiguous = apply_canvas_revision_edits( + "Same Same", + &[json!({"find": "Same", "replace": "Changed"})], + ); + assert!(matches!( + ambiguous, + Err(ToolError::InvalidParams(message)) if message.contains("matched 2 times") + )); + + let replaced = apply_canvas_revision_edits( + "Same Same", + &[json!({"find": "Same", "replace": "Changed", "all": true})], + ) + .expect("replace-all edit"); + assert_eq!(replaced, "Changed Changed"); + } + + #[test] + fn materializes_compact_revision_chains_from_persisted_events() { + let connection = Connection::open_in_memory().expect("in-memory database"); + connection + .execute( + "CREATE TABLE events ( + id TEXT NOT NULL, + session_id TEXT NOT NULL, + function_name TEXT, + args_json TEXT NOT NULL + )", + [], + ) + .expect("events table"); + connection + .execute( + "INSERT INTO events (id, session_id, function_name, args_json) VALUES (?1, ?2, ?3, ?4)", + params![ + "canvas-a", + "session-a", + tool_names::RENDER_INLINE_CANVAS, + json!({"mode": "react", "content": "Start Keep"}).to_string() + ], + ) + .expect("base canvas"); + connection + .execute( + "INSERT INTO events (id, session_id, function_name, args_json) VALUES (?1, ?2, ?3, ?4)", + params![ + "canvas-b", + "session-a", + tool_names::REVISE_INLINE_CANVAS, + json!({ + "target_event_id": "canvas-a", + "mode": "react", + "edits": [{"find": "Start", "replace": "Start setup"}] + }) + .to_string() + ], + ) + .expect("compact revision"); + + let args = load_materialized_canvas_args( + &connection, + "session-a", + "canvas-b", + &mut std::collections::HashSet::new(), + 0, + ) + .expect("materialized Canvas"); + assert_eq!(args["content"], "Start setup Keep"); + } + + #[tokio::test] + async fn revision_rejects_an_empty_target_before_persistence_lookup() { + let result = ReviseInlineCanvasTool::new() + .execute_text( + json!({ + "target_event_id": " ", + "mode": "react", + "agent_steps": ["定位目标"], + "content": "function App() { return null; }" + }), + &CallContext::new("call-revision", "session-a"), + ) + .await; + + assert!( + matches!(result, Err(ToolError::InvalidParams(message)) if message.contains("target_event_id")) + ); + } + + #[test] + fn revision_target_must_be_a_canvas_in_the_same_session() { + let connection = Connection::open_in_memory().expect("in-memory database"); + connection + .execute( + "CREATE TABLE events ( + id TEXT NOT NULL, + session_id TEXT NOT NULL, + function_name TEXT + )", + [], + ) + .expect("events table"); + connection + .execute( + "INSERT INTO events (id, session_id, function_name) VALUES (?1, ?2, ?3)", + params!["canvas-a", "session-a", tool_names::RENDER_INLINE_CANVAS], + ) + .expect("canvas event"); + connection + .execute( + "INSERT INTO events (id, session_id, function_name) VALUES (?1, ?2, ?3)", + params!["read-a", "session-a", tool_names::READ_FILE], + ) + .expect("non-canvas event"); + + assert!(validate_revision_target(&connection, "session-a", "canvas-a").is_ok()); + assert!(matches!( + validate_revision_target(&connection, "session-b", "canvas-a"), + Err(ToolError::InvalidParams(_)) + )); + assert!(matches!( + validate_revision_target(&connection, "session-a", "read-a"), + Err(ToolError::InvalidParams(_)) + )); + } } diff --git a/src-tauri/crates/agent-core/src/core/tools/registration/coding.rs b/src-tauri/crates/agent-core/src/core/tools/registration/coding.rs index c7661d01c..886206c49 100644 --- a/src-tauri/crates/agent-core/src/core/tools/registration/coding.rs +++ b/src-tauri/crates/agent-core/src/core/tools/registration/coding.rs @@ -16,7 +16,7 @@ use crate::tools::impls::coding::{ manage_todo::{TodoSessionContext, TodoTool}, manage_workspace::ManageWorkspaceTool, query_lsp::LspTool, - render_inline_canvas::RenderInlineCanvasTool, + render_inline_canvas::{RenderInlineCanvasTool, ReviseInlineCanvasTool}, setup_repo::RepoSetupTool, skill::SkillTool, worktree::WorktreeTool, @@ -221,4 +221,5 @@ pub fn register(registry: &mut ToolRegistry, deps: &ToolDeps, disabled: &HashSet // ── Inline canvas (SDE + OS) ── register_if_enabled(registry, Box::new(RenderInlineCanvasTool::new()), disabled); + register_if_enabled(registry, Box::new(ReviseInlineCanvasTool::new()), disabled); } diff --git a/src-tauri/crates/agent-core/src/core/tools/tests/ui_metadata_tests.rs b/src-tauri/crates/agent-core/src/core/tools/tests/ui_metadata_tests.rs index 08ec2c6c1..768d216bf 100644 --- a/src-tauri/crates/agent-core/src/core/tools/tests/ui_metadata_tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/tests/ui_metadata_tests.rs @@ -25,6 +25,7 @@ fn invokable_canonical_tool_names() -> BTreeSet<&'static str> { names::SETUP_REPO, names::WORKTREE, names::RENDER_INLINE_CANVAS, + names::REVISE_INLINE_CANVAS, names::INSPECT_TERMINALS, names::WEB_SEARCH, names::WEB_FETCH, @@ -163,6 +164,7 @@ fn every_renderable_tool_has_non_default_chat_block() { names::SETUP_REPO, names::WORKTREE, names::RENDER_INLINE_CANVAS, + names::REVISE_INLINE_CANVAS, names::INSPECT_TERMINALS, names::CONTROL_DESKTOP_WITH_PEEKABOO, names::CONTROL_BROWSER_WITH_AGENT_BROWSER, diff --git a/src-tauri/crates/types/src/tool_names.rs b/src-tauri/crates/types/src/tool_names.rs index c2a806f21..23bf90f17 100644 --- a/src-tauri/crates/types/src/tool_names.rs +++ b/src-tauri/crates/types/src/tool_names.rs @@ -159,6 +159,10 @@ pub const TOOL_SEARCH: &str = "tool_search"; /// inside the chat panel as a sandboxed inline canvas card. Supported by /// both OS Agent and SDE Agent. Mode values: "html" | "url" | "a2ui" | "react". pub const RENDER_INLINE_CANVAS: &str = "render_inline_canvas"; +/// Replaces an existing logical inline Canvas while preserving its identity. +/// The target event id is required and validated against the dispatching +/// session before the revision is accepted. +pub const REVISE_INLINE_CANVAS: &str = "revise_inline_canvas"; // ── Plan Mode ─────────────────────────────────────────────────────── /// Writes markdown plan content to the session's plan file AND submits it diff --git a/src-tauri/crates/types/src/tool_names_tests.rs b/src-tauri/crates/types/src/tool_names_tests.rs index f5e380ac8..715800468 100644 --- a/src-tauri/crates/types/src/tool_names_tests.rs +++ b/src-tauri/crates/types/src/tool_names_tests.rs @@ -28,6 +28,8 @@ fn tool_name_constants_are_stable_wire_strings() { assert_eq!(ASK_USER_PERMISSIONS, "ask_user_permissions"); assert_eq!(MANAGE_SECRETS, "manage_secrets"); assert_eq!(WRITE_ENV_FILE, "write_env_file"); + assert_eq!(RENDER_INLINE_CANVAS, "render_inline_canvas"); + assert_eq!(REVISE_INLINE_CANVAS, "revise_inline_canvas"); // ── Project ── assert_eq!(MANAGE_PROJECT, "manage_project"); diff --git a/src/components/ComposerInput/BasePill.tsx b/src/components/ComposerInput/BasePill.tsx index d2093510c..416d238f0 100644 --- a/src/components/ComposerInput/BasePill.tsx +++ b/src/components/ComposerInput/BasePill.tsx @@ -35,6 +35,7 @@ export interface BasePillProps { className?: string; style?: React.CSSProperties; title?: string; + "aria-label"?: string; role?: string; tabIndex?: number; /** Forwarded ref for position calculations (e.g. preview portal in ComposerPill) */ @@ -74,6 +75,7 @@ const BasePill = React.forwardRef( className, style, title, + "aria-label": ariaLabel, role, tabIndex, pillRef, @@ -97,6 +99,7 @@ const BasePill = React.forwardRef( className={className} style={{ ...baseStyle, ...style }} title={title} + aria-label={ariaLabel} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} diff --git a/src/components/ComposerInput/index.scss b/src/components/ComposerInput/index.scss index ae0f54bde..5be6c27fc 100644 --- a/src/components/ComposerInput/index.scss +++ b/src/components/ComposerInput/index.scss @@ -153,6 +153,20 @@ } } +// A contextual reference is owned by the surrounding surface rather than the +// editable document, but visually shares the first line with the editor. Drop +// the ordinary leading inset so the placeholder/caret follows the shared +// editor pill at the same spacing as a real inline ComposerPill. +.composer-input.chat-input-editor-leading:not(.chat-input-compact) { + .composer-input-content { + padding-left: 0; + + &.is-empty::before { + left: 0; + } + } +} + .composer-input.chat-input-editor.chat-input-compact { box-sizing: border-box; min-height: 0 !important; diff --git a/src/components/Voice/VoiceInputButton.tsx b/src/components/Voice/VoiceInputButton.tsx index 5ab6e6472..f9b5c3f05 100644 --- a/src/components/Voice/VoiceInputButton.tsx +++ b/src/components/Voice/VoiceInputButton.tsx @@ -24,10 +24,12 @@ interface VoiceInputButtonProps { * tooltip explains the unavailable recognizer state. */ disabled?: boolean; + /** Filled treatment for compact contextual composers. */ + appearance?: "default" | "solid"; } const VoiceInputButton: React.FC = memo( - ({ onPressStart, onPressEnd, disabled = false }) => { + ({ onPressStart, onPressEnd, disabled = false, appearance = "default" }) => { const { t } = useTranslation(); const activePointerIdRef = useRef(null); const isPressingRef = useRef(false); @@ -96,11 +98,12 @@ const VoiceInputButton: React.FC = memo( } }} className={[ - "flex items-center justify-center rounded-full bg-transparent text-text-1 transition-colors duration-200 focus:outline-none", + "flex items-center justify-center rounded-full transition-colors duration-200 focus:outline-none", INPUT_AREA_BUTTONS.iconButtonSizeClass, - disabled - ? "cursor-not-allowed opacity-50" - : "cursor-pointer hover:bg-fill-2", + appearance === "solid" + ? "bg-text-1 text-bg-1 hover:bg-text-2" + : "bg-transparent text-text-1 hover:bg-fill-2", + disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer", "leading-none", ].join(" ")} style={{ lineHeight: 0 }} diff --git a/src/config/toolIcons.tsx b/src/config/toolIcons.tsx index 6029b1915..5a48ccf24 100644 --- a/src/config/toolIcons.tsx +++ b/src/config/toolIcons.tsx @@ -52,6 +52,7 @@ import { Inbox, Keyboard, Layers, + Layout, LayoutList, List, ListChecks, @@ -145,6 +146,7 @@ export const LUCIDE_ICON_BY_ID: Record = { infinity: Infinity, keyboard: Keyboard, layers: Layers, + layout: Layout, "layout-list": LayoutList, list: List, "list-checks": ListChecks, diff --git a/src/engines/ChatPanel/ChatHistory/components/UserMessageContent.tsx b/src/engines/ChatPanel/ChatHistory/components/UserMessageContent.tsx index 8d0e853c9..c0cc7197a 100644 --- a/src/engines/ChatPanel/ChatHistory/components/UserMessageContent.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/UserMessageContent.tsx @@ -43,6 +43,8 @@ import { } from "@src/config/pillTokens"; import type { PillType } from "@src/config/pillTokens"; import { normalizeUserMessageText } from "@src/engines/ChatPanel/ChatItems/normalizeUserMessageText"; +import CanvasDomComponentPreview from "@src/features/DomSelection/CanvasDomComponentPreview"; +import { parseCanvasDomComponent } from "@src/features/DomSelection/domComponentPayload"; import { sessionByIdAtom } from "@src/store/session/sessionAtom"; import { openExternalLink } from "@src/util/platform/ipcRenderer"; import { resolveSessionRowIcon } from "@src/util/session/sessionSidebarRow"; @@ -613,6 +615,12 @@ const UserMessageContent: React.FC = memo( [normalizedText] ); const hasImages = images && images.length > 0; + const canvasSelectionJson = segments.find( + (segment): segment is PillSegment => + segment.kind === "pill" && + segment.pillType === "dom-component" && + parseCanvasDomComponent(segment.terminalText) !== null + )?.terminalText; // Fast path: no pills and no images, render plain text const hasPills = segments.some((s) => s.kind === "pill"); @@ -623,6 +631,9 @@ const UserMessageContent: React.FC = memo( return (
{hasImages && } + {canvasSelectionJson && ( + + )} {normalizedText && normalizedText !== "(image)" && ( { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("renders the captured preview above the existing dom-component pill", () => { + const jsonText = JSON.stringify({ + schemaVersion: 1, + origin: "canvas-design", + previewHtml: '
M
', + selection: { kind: "element", label: "Stat" }, + }); + const encoded = btoa(encodeURIComponent(jsonText)); + const text = `Stat [dom-component:paste://canvas-design/event-a/1::${encoded}]\n字体变大一些`; + + act(() => root.render(createElement(UserMessageContent, { text }))); + + expect( + container.querySelector("iframe[title='Canvas selection preview']") + ).not.toBeNull(); + expect(container.textContent).toContain("Stat"); + expect(container.textContent).toContain("字体变大一些"); + }); +}); diff --git a/src/engines/ChatPanel/ChatHistory/components/__tests__/UserMessageContent.test.ts b/src/engines/ChatPanel/ChatHistory/components/__tests__/UserMessageContent.test.ts index 333e4bf67..04fc06009 100644 --- a/src/engines/ChatPanel/ChatHistory/components/__tests__/UserMessageContent.test.ts +++ b/src/engines/ChatPanel/ChatHistory/components/__tests__/UserMessageContent.test.ts @@ -90,6 +90,32 @@ describe("external-history Markdown URL pills", () => { }); }); +describe("Canvas Design component pills", () => { + it("decodes the versioned preview context for sent-message rendering", () => { + const jsonText = JSON.stringify({ + schemaVersion: 1, + origin: "canvas-design", + previewHtml: "
Stat
", + }); + const encoded = btoa(encodeURIComponent(jsonText)); + + expect( + parseUserMessage( + `Stat [dom-component:paste://canvas-design/event-a/1::${encoded}]\n字体变大一些` + ) + ).toEqual([ + { + kind: "pill", + displayName: "Stat", + pillType: "dom-component", + path: "paste://canvas-design/event-a/1", + terminalText: jsonText, + }, + { kind: "text", text: "\n字体变大一些" }, + ]); + }); +}); + describe("message reference interactions", () => { it("underlines clickable references on hover, press, and keyboard focus", () => { const markup = renderToStaticMarkup( diff --git a/src/engines/ChatPanel/InputArea/__tests__/inputAreaPresentation.test.ts b/src/engines/ChatPanel/InputArea/__tests__/inputAreaPresentation.test.ts new file mode 100644 index 000000000..59f8ec842 --- /dev/null +++ b/src/engines/ChatPanel/InputArea/__tests__/inputAreaPresentation.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { shouldUseCompactComposerLayout } from "../inputAreaPresentation"; + +function compactLayout( + overrides: Partial[0]> = {} +): boolean { + return shouldUseCompactComposerLayout({ + presentation: "default", + isChatPanelMaximized: false, + isEditMode: false, + hasImages: false, + isCiteCode: false, + isReply: false, + editorMultiline: false, + ...overrides, + }); +} + +describe("shouldUseCompactComposerLayout", () => { + it("keeps the ordinary non-maximized composer expanded", () => { + expect(compactLayout()).toBe(false); + }); + + it("uses the shared compact row for a contextual Canvas prompt", () => { + expect(compactLayout({ presentation: "contextual" })).toBe(true); + }); + + it("expands the contextual prompt when its editor becomes multiline", () => { + expect( + compactLayout({ presentation: "contextual", editorMultiline: true }) + ).toBe(false); + }); + + it.each([ + ["edit mode", { isEditMode: true }], + ["image attachment", { hasImages: true }], + ["code citation", { isCiteCode: true }], + ["reply context", { isReply: true }], + ])("does not compact around %s", (_label, blockedState) => { + expect(compactLayout({ presentation: "contextual", ...blockedState })).toBe( + false + ); + }); +}); diff --git a/src/engines/ChatPanel/InputArea/components/InputComposerBars.test.ts b/src/engines/ChatPanel/InputArea/components/InputComposerBars.test.ts new file mode 100644 index 000000000..f520fcff0 --- /dev/null +++ b/src/engines/ChatPanel/InputArea/components/InputComposerBars.test.ts @@ -0,0 +1,268 @@ +// @vitest-environment jsdom +import React, { act, createElement, createRef } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type ComposerBar from "@src/components/ComposerBar"; + +import { NormalComposerContent } from "./InputComposerBars"; + +const testState = vi.hoisted(() => ({ + composerBarProps: null as React.ComponentProps | null, + inputEditorProps: null as Record | null, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@src/components/ComposerBar", async () => { + const ReactModule = await import("react"); + return { + default: (props: React.ComponentProps) => { + testState.composerBarProps = props; + return ReactModule.createElement( + "div", + { "data-testid": "composer-bar" }, + props.leftPrefix, + props.editorSlot, + props.pills, + props.submitButton + ); + }, + }; +}); + +vi.mock("@src/components/Voice", async () => { + const ReactModule = await import("react"); + return { + VoiceInputButton: (props: { appearance?: string }) => + ReactModule.createElement("span", { + "data-testid": "voice-button", + "data-appearance": props.appearance, + }), + VoiceRecordingBar: () => + ReactModule.createElement("span", { + "data-testid": "voice-recording-bar", + }), + }; +}); + +vi.mock("./InputEditor", async () => { + const ReactModule = await import("react"); + return { + default: (props: Record) => { + testState.inputEditorProps = props; + return ReactModule.createElement( + "span", + { "data-testid": "input-editor" }, + props.leadingContent as React.ReactNode + ); + }, + }; +}); + +vi.mock("./InputActions", async () => { + const ReactModule = await import("react"); + return { + default: () => + ReactModule.createElement("span", { "data-testid": "input-actions" }), + }; +}); + +vi.mock("./PromptPolishButton", async () => { + const ReactModule = await import("react"); + return { + default: () => + ReactModule.createElement("span", { "data-testid": "prompt-polish" }), + }; +}); + +vi.mock("./CiteCodePreview", () => ({ default: () => null })); +vi.mock("./ImageAttachmentPreview", () => ({ default: () => null })); +vi.mock("./ReplyInfoDisplay", () => ({ default: () => null })); + +describe("NormalComposerContent contextual presentations", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + testState.composerBarProps = null; + testState.inputEditorProps = null; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + const renderComposer = ( + currentInputEmpty: boolean, + overrides: Partial> = {} + ) => { + const props = { + composerInputRef: createRef(), + showContextMenu: false, + contextMenuKeyboardHandlerRef: { current: null }, + showSlashMenu: false, + slashCommandKeyboardHandlerRef: { current: null }, + showPlusSlashMenu: false, + plusSlashCommandKeyboardHandlerRef: { current: null }, + onSlashCommand: vi.fn(), + onSlashCommandClose: vi.fn(), + onPlusSlashClose: vi.fn(), + onContentChange: vi.fn(), + onAtMention: vi.fn(), + onAtMentionClose: vi.fn(), + onSubmit: vi.fn(), + onFocus: vi.fn(), + onBlur: vi.fn(), + onDragOver: vi.fn(), + onDragLeave: vi.fn(), + onDrop: vi.fn(), + onAddContent: vi.fn(), + onUpload: vi.fn(), + onOpenSkillsTools: vi.fn(), + isCiteCode: false, + selectedCiteRange: null, + citeFileName: "", + onClearCiteCode: vi.fn(), + replyInfo: { isReply: false }, + onClearReplyInfo: vi.fn(), + modePill: null, + modelPill: null, + isHosted: false, + canStopAgent: false, + canResume: false, + onInterrupt: vi.fn(async () => undefined), + onResume: vi.fn(async () => undefined), + isCursorIde: false, + showVoiceUi: false, + voice: { + elapsedSeconds: 0, + isRecording: false, + liveTranscript: "", + cancel: vi.fn(), + stop: vi.fn(), + start: vi.fn(), + toggle: vi.fn(), + isSupported: true, + }, + isCompactRow: true, + contextualCompact: true, + inlineLeadingContent: createElement("span", null, "Stat"), + suppressToolbarHover: false, + currentInputEmpty, + stopSuppressedForEmptyInput: false, + isWpGeneWorking: false, + isPendingCancel: false, + isSessionTerminal: false, + voiceFeatureEnabled: true, + dropTargetId: "canvas-design", + promptPolish: { + status: "idle", + isAvailable: false, + isPolishing: false, + isPolished: false, + toggle: vi.fn(async () => undefined), + reset: vi.fn(), + }, + promptPolishDisabled: true, + showAgentControls: true, + showImageAttachments: false, + ...overrides, + } as React.ComponentProps; + + act(() => root.render(createElement(NormalComposerContent, props))); + }; + + it("renders context, editor, and one solid voice action in the compact row", () => { + renderComposer(true); + + expect(testState.composerBarProps).toMatchObject({ + inlineLayout: true, + hideAddButton: true, + showContextInfo: false, + }); + expect(container.textContent).toContain("Stat"); + expect( + container.querySelector("[data-testid='input-editor']") + ).not.toBeNull(); + expect( + container + .querySelector("[data-testid='voice-button']") + ?.getAttribute("data-appearance") + ).toBe("solid"); + expect(container.querySelector("[data-testid='input-actions']")).toBeNull(); + expect(container.querySelector("[data-testid='prompt-polish']")).toBeNull(); + }); + + it("replaces the idle microphone with the shared send action after typing", () => { + renderComposer(false); + + expect(container.querySelector("[data-testid='voice-button']")).toBeNull(); + expect( + container.querySelector("[data-testid='input-actions']") + ).not.toBeNull(); + }); + + it("uses the compact shared toolbar without hiding contextual controls", () => { + renderComposer(true, { + isCompactRow: true, + contextualCompact: false, + contextualPanel: true, + inlineLeadingContent: createElement("span", null, "H1"), + modePill: createElement("span", null, "Auto"), + modelPill: createElement("span", null, "GPT 5.6 Sol · Extra High"), + }); + + expect(testState.composerBarProps).toMatchObject({ + inlineLayout: true, + hideAddButton: false, + showContextInfo: false, + }); + expect(testState.inputEditorProps).toMatchObject({ + compact: true, + leadingContent: expect.anything(), + }); + expect(container.textContent).toContain("H1"); + expect( + container.querySelector("[data-testid='input-editor']")?.textContent + ).toBe("H1"); + expect(container.textContent).toContain("Auto"); + expect(container.textContent).toContain("GPT 5.6 Sol · Extra High"); + expect( + container.querySelector("[data-testid='input-editor']") + ).not.toBeNull(); + expect( + container.querySelector("[data-testid='voice-button']") + ).not.toBeNull(); + expect( + container.querySelector("[data-testid='input-actions']") + ).not.toBeNull(); + expect(container.querySelector("[data-testid='prompt-polish']")).toBeNull(); + }); +}); diff --git a/src/engines/ChatPanel/InputArea/components/InputComposerBars.tsx b/src/engines/ChatPanel/InputArea/components/InputComposerBars.tsx index cb3ad98bc..25ca10ad7 100644 --- a/src/engines/ChatPanel/InputArea/components/InputComposerBars.tsx +++ b/src/engines/ChatPanel/InputArea/components/InputComposerBars.tsx @@ -265,7 +265,10 @@ interface NormalComposerContentProps extends SharedComposerBarProps { showVoiceUi: boolean; voice: UseVoiceInputResult; currentRepoPath?: string; - isCursorCompactRow: boolean; + isCompactRow: boolean; + contextualCompact?: boolean; + contextualPanel?: boolean; + inlineLeadingContent?: React.ReactNode; suppressToolbarHover: boolean; onContentChange: (text: string) => void; onBlur: () => void; @@ -285,6 +288,7 @@ interface NormalComposerContentProps extends SharedComposerBarProps { submitDisabled?: boolean; showAgentControls?: boolean; showImageAttachments?: boolean; + autoFocus?: boolean; } export const NormalComposerContent: React.FC = ({ @@ -328,7 +332,10 @@ export const NormalComposerContent: React.FC = ({ showVoiceUi, voice, currentRepoPath, - isCursorCompactRow, + isCompactRow, + contextualCompact = false, + contextualPanel = false, + inlineLeadingContent, suppressToolbarHover, placeholder, trailingHint, @@ -344,8 +351,10 @@ export const NormalComposerContent: React.FC = ({ submitDisabled, showAgentControls = true, showImageAttachments = true, + autoFocus = false, }) => { const { t } = useTranslation("sessions"); + const isContextual = contextualCompact || contextualPanel; return (
@@ -358,7 +367,7 @@ export const NormalComposerContent: React.FC = ({ onCancel={voice.cancel} onAccept={voice.stop} onAddContent={onAddContent} - compact={isCursorCompactRow} + compact={isCompactRow} /> ) : ( = ({ dropdownDirection="up" toolbarItemGap={false} repoPath={currentRepoPath} - inlineLayout={isCursorCompactRow} - showContextInfo={showAgentControls && !isCursorIde} + inlineLayout={isCompactRow} + hideAddButton={contextualCompact} + showContextInfo={showAgentControls && !isCursorIde && !isContextual} editorSlot={ = ({ placeholder={placeholder || t("input.defaultPlaceholder")} trailingHint={trailingHint} onImagePaste={onImagePaste} - compact={isCursorCompactRow} + compact={isCompactRow} + autoFocus={autoFocus} + leadingContent={ + contextualPanel ? inlineLeadingContent : undefined + } /> } leftPrefix={ - + <> + + {!contextualPanel && inlineLeadingContent} + } pills={
= ({ } submitButton={
- {showAgentControls && ( + {showAgentControls && !isContextual && ( )} - {showAgentControls && voiceFeatureEnabled && ( - + )} + {(!contextualCompact || + !currentInputEmpty || + isWpGeneWorking || + isPendingCancel || + isSessionTerminal || + !voiceFeatureEnabled) && ( + )} -
} /> diff --git a/src/engines/ChatPanel/InputArea/components/InputEditor.test.ts b/src/engines/ChatPanel/InputArea/components/InputEditor.test.ts new file mode 100644 index 000000000..f16588c02 --- /dev/null +++ b/src/engines/ChatPanel/InputArea/components/InputEditor.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +import React, { act, createElement, createRef } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { ComposerInputRef } from "@src/components/ComposerInput"; + +import InputEditor from "./InputEditor"; + +const testState = vi.hoisted(() => ({ + composerInputProps: null as Record | null, +})); + +vi.mock("jotai", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomValue: () => ({ sendOnEnter: true }), +})); + +vi.mock("@src/components/ComposerInput", async () => { + const ReactModule = await import("react"); + return { + default: ReactModule.forwardRef( + (props: Record, _ref: React.ForwardedRef) => { + testState.composerInputProps = props; + return ReactModule.createElement("div", { + "data-testid": "composer-input", + "data-class-name": props.className, + }); + } + ), + }; +}); + +describe("InputEditor leading content", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + testState.composerInputProps = null; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + function renderEditor( + leadingContent?: React.ReactNode, + compact: boolean = false + ) { + act(() => + root.render( + createElement(InputEditor, { + composerInputRef: createRef(), + showContextMenu: false, + contextMenuKeyboardHandlerRef: { current: null }, + placeholder: "Describe what to change…", + leadingContent, + compact, + }) + ) + ); + } + + it("keeps contextual references on the editor line but outside its document", () => { + renderEditor(createElement("span", null, "Button")); + + const leading = container.querySelector("[data-composer-leading-content]"); + const composer = container.querySelector("[data-testid='composer-input']"); + + expect(leading?.textContent).toBe("Button"); + expect(leading?.nextElementSibling).toBe(composer); + expect(testState.composerInputProps).toMatchObject({ + placeholder: "Describe what to change…", + className: expect.stringContaining("chat-input-editor-leading"), + }); + expect(testState.composerInputProps).not.toHaveProperty("leadingContent"); + expect(testState.composerInputProps).not.toHaveProperty("initialContent"); + }); + + it("keeps ordinary editors on the existing inset when no reference exists", () => { + renderEditor(); + + expect( + container.querySelector("[data-composer-leading-content]") + ).toBeNull(); + expect(testState.composerInputProps?.className).not.toContain( + "chat-input-editor-leading" + ); + }); + + it("keeps a contextual reference inside the shared single-row editor", () => { + renderEditor(createElement("span", null, "Button"), true); + + const leading = container.querySelector( + "[data-composer-leading-content]" + ); + expect(leading?.className).toContain("h-full"); + expect(testState.composerInputProps).toMatchObject({ + minHeight: 0, + maxHeight: 36, + overflowY: "visible", + className: expect.stringContaining("chat-input-compact"), + }); + }); +}); diff --git a/src/engines/ChatPanel/InputArea/components/InputEditor.tsx b/src/engines/ChatPanel/InputArea/components/InputEditor.tsx index 116f502d8..a340c86cb 100644 --- a/src/engines/ChatPanel/InputArea/components/InputEditor.tsx +++ b/src/engines/ChatPanel/InputArea/components/InputEditor.tsx @@ -71,6 +71,15 @@ export interface InputEditorProps { compact?: boolean; /** Called synchronously before a newline is inserted. */ onBeforeNewline?: () => void; + /** Focus the contenteditable host after mount. */ + autoFocus?: boolean; + /** + * Non-document context rendered on the editor's first line before the + * contenteditable surface. This intentionally stays outside the serialized + * composer value (for example, a Canvas element selection that is submitted + * through a dedicated override payload). + */ + leadingContent?: React.ReactNode; } // ============================================ @@ -104,6 +113,8 @@ const InputEditor: React.FC = memo( slashTriggerMode = "command", compact = false, onBeforeNewline, + autoFocus = false, + leadingContent, }) => { const wrapperRef = useRef(null); const { sendOnEnter } = useAtomValue(chatAppearanceAtom); @@ -164,7 +175,9 @@ const InputEditor: React.FC = memo(
= memo( onFocus={onFocus} onBlur={onBlur} > + {leadingContent && ( +
+ {leadingContent} +
+ )} = memo( onAtMentionClose={onAtMentionClose} onSubmit={onSubmit} requireCmdEnter={!sendOnEnter} - autoFocus={false} + autoFocus={autoFocus} className={ compact - ? "chat-input-editor chat-input-compact h-full max-h-9 min-h-0" - : "chat-input-editor max-h-[140px] min-h-[60px] overflow-y-auto" + ? "chat-input-editor chat-input-compact h-full max-h-9 min-h-0 min-w-0 flex-1" + : `chat-input-editor max-h-[140px] min-h-[60px] min-w-0 flex-1 overflow-y-auto ${ + leadingContent ? "chat-input-editor-leading" : "" + }`.trim() } minHeight={compact ? 0 : 60} maxHeight={compact ? 36 : 140} diff --git a/src/engines/ChatPanel/InputArea/index.tsx b/src/engines/ChatPanel/InputArea/index.tsx index cfafcf775..7f0918cfa 100644 --- a/src/engines/ChatPanel/InputArea/index.tsx +++ b/src/engines/ChatPanel/InputArea/index.tsx @@ -38,6 +38,11 @@ import { useEditorExpansion } from "./hooks/useEditorExpansion"; import { useInputAreaMenus } from "./hooks/useInputAreaMenus"; import { useInputAreaVoice } from "./hooks/useInputAreaVoice"; import { useStopOnDoubleEscape } from "./hooks/useStopOnDoubleEscape"; +import { + type InputAreaPresentation, + isContextualInputAreaPresentation, + shouldUseCompactComposerLayout, +} from "./inputAreaPresentation"; import { openedTabMentionOptionsAtom } from "./openedTabMentionOptionsAtom"; interface InputAreaProps { @@ -87,6 +92,8 @@ interface InputAreaProps { allowFileAttachments?: boolean; /** Enable agent-only submit interceptors such as /compact and MCP tools. */ enableAgentInterceptors?: boolean; + /** Focus the shared composer editor when this InputArea mounts. */ + autoFocus?: boolean; /** Limit the slash menu to the supplied item categories. */ slashItemCategories?: ReadonlyArray; /** @@ -94,6 +101,8 @@ interface InputAreaProps { * open upward even in queue-edit mode (there is no room beneath it). */ bottomAnchored?: boolean; + /** Contextual composers used by element-selection surfaces. */ + presentation?: InputAreaPresentation; } /** @@ -151,8 +160,10 @@ const InputAreaInteractive: React.FC = memo( showAgentControls = true, allowFileAttachments = true, enableAgentInterceptors = true, + autoFocus = false, slashItemCategories, bottomAnchored = false, + presentation = "default", }) => { const { t } = useTranslation("sessions"); @@ -244,6 +255,9 @@ const InputAreaInteractive: React.FC = memo( const mentionTreePosition = chatPanelPosition === "left" ? "right" : "left"; const voiceFeatureEnabled = useAtomValue(voiceInputEnabledAtom); const isChatPanelMaximized = useAtomValue(chatPanelMaximizedAtom); + const isContextualCompact = presentation === "contextual-compact"; + const isContextualPanel = presentation === "contextual"; + const isContextual = isContextualInputAreaPresentation(presentation); const { showPlusSlashMenu, @@ -346,13 +360,17 @@ const InputAreaInteractive: React.FC = memo( const isCursorCompactRow = useMemo( () => - isChatPanelMaximized && - !isEditMode && - !hasImages && - !isCiteCode && - !replyInfo.isReply && - !editorMultiline, + shouldUseCompactComposerLayout({ + presentation, + isChatPanelMaximized, + isEditMode, + hasImages, + isCiteCode, + isReply: replyInfo.isReply, + editorMultiline, + }), [ + presentation, isChatPanelMaximized, isEditMode, hasImages, @@ -383,9 +401,15 @@ const InputAreaInteractive: React.FC = memo( // Cursor IDE sessions are read-only; no interactive model/mode pill. const modelPill = - !showAgentControls || (isCursorIde && sessionId) ? null : ; + !showAgentControls || + isContextualCompact || + (isCursorIde && sessionId) ? null : ( + + ); const modePill = - !showAgentControls || (isCursorIde && sessionId) ? null : ( + !showAgentControls || + isContextualCompact || + (isCursorIde && sessionId) ? null : ( ); const clearReplyInfo = useCallback( @@ -414,15 +438,17 @@ const InputAreaInteractive: React.FC = memo( onDrop={handleContainerDrop} >
- + {!isContextual && ( + + )} = memo( showVoiceUi={showVoiceUi} voice={voice} currentRepoPath={currentRepoPath} - isCursorCompactRow={isCursorCompactRow} + isCompactRow={isCursorCompactRow} + contextualCompact={isContextualCompact} + contextualPanel={isContextualPanel} + inlineLeadingContent={isContextual ? topRowPills : undefined} suppressToolbarHover={suppressToolbarHover} placeholder={placeholder} trailingHint={ @@ -581,6 +610,7 @@ const InputAreaInteractive: React.FC = memo( submitDisabled={submitDisabled} showAgentControls={showAgentControls} showImageAttachments={allowFileAttachments} + autoFocus={autoFocus} /> )} @@ -610,8 +640,8 @@ const InputAreaInteractive: React.FC = memo( onModeSelect={handleModeSelect} slashCommandKeyboardHandlerRef={slashCommandKeyboardHandlerRef} onImageUpload={allowFileAttachments ? handleUploadClick : undefined} - showActionFlyouts={showAgentControls} - showModeRows={showAgentControls} + showActionFlyouts={showAgentControls && !isContextualCompact} + showModeRows={showAgentControls && !isContextualCompact} showPlusSlashMenu={showPlusSlashMenu} plusSlashQuery={plusSlashQuery} onPlusSlashClose={handlePlusSlashClose} diff --git a/src/engines/ChatPanel/InputArea/inputAreaPresentation.ts b/src/engines/ChatPanel/InputArea/inputAreaPresentation.ts new file mode 100644 index 000000000..b4c26866b --- /dev/null +++ b/src/engines/ChatPanel/InputArea/inputAreaPresentation.ts @@ -0,0 +1,45 @@ +export type InputAreaPresentation = + | "default" + | "contextual" + | "contextual-compact"; + +interface CompactComposerLayoutInput { + presentation: InputAreaPresentation; + isChatPanelMaximized: boolean; + isEditMode: boolean; + hasImages: boolean; + isCiteCode: boolean; + isReply: boolean; + editorMultiline: boolean; +} + +export function isContextualInputAreaPresentation( + presentation: InputAreaPresentation +): boolean { + return presentation !== "default"; +} + +/** + * Resolve whether InputArea can use the shared one-row capsule without + * hiding valid editor content. Contextual Canvas prompts opt into the capsule + * even when ChatPanel itself is not maximized, then expand through the same + * editor-multiline transition as the ordinary composer. + */ +export function shouldUseCompactComposerLayout({ + presentation, + isChatPanelMaximized, + isEditMode, + hasImages, + isCiteCode, + isReply, + editorMultiline, +}: CompactComposerLayoutInput): boolean { + return ( + (isChatPanelMaximized || isContextualInputAreaPresentation(presentation)) && + !isEditMode && + !hasImages && + !isCiteCode && + !isReply && + !editorMultiline + ); +} diff --git a/src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionActivity.tsx b/src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionActivity.tsx new file mode 100644 index 000000000..061d7c06a --- /dev/null +++ b/src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionActivity.tsx @@ -0,0 +1,141 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { getEventIcon } from "@src/config/toolIcons"; +import { + EventBlockHeader, + EventBlockHeaderIcon, + EventBlockHeaderSubtitle, + EventBlockHeaderTitle, + getEventBlockContainerClasses, +} from "@src/engines/ChatPanel/blocks/primitives"; +import { useBlockHeader } from "@src/engines/ChatPanel/blocks/useBlockLocate"; +import type { EventStatus } from "@src/engines/SessionCore/rendering/types/universalProps"; + +import CanvasRevisionSteps from "./CanvasRevisionSteps"; +import { CANVAS_REVISION_TOOL_NAME } from "./canvasRevision"; +import { + type CanvasRevisionActivityPhase, + summarizeCanvasRevisionActivity, +} from "./canvasRevisionActivityState"; +import { formatCanvasRevisionCharacterCount } from "./canvasRevisionProgressState"; + +interface CanvasRevisionActivityProps { + args: Record; + status: EventStatus; + eventId?: string; + errorDetail?: string; +} + +function phaseForStatus(status: EventStatus): CanvasRevisionActivityPhase { + switch (status) { + case "pending": + case "running": + return "applying"; + case "success": + return "completed"; + case "failed": + return "failed"; + case "cancelled": + return "cancelled"; + } +} + +const CanvasRevisionActivity: React.FC = ({ + args, + status, + eventId, + errorDetail, +}) => { + const { t } = useTranslation("sessions"); + const { handleLocate } = useBlockHeader({ eventId }); + const handleNavigate = eventId ? handleLocate : undefined; + const summary = summarizeCanvasRevisionActivity(args); + const canvasTitle = summary.title || t("canvasApp.revisionCanvas", "Canvas"); + const phase = phaseForStatus(status); + const isLoading = status === "pending" || status === "running"; + const isFailed = status === "failed" || status === "cancelled"; + const title = isLoading + ? t("canvasApp.revisionTitle", "Updating {{title}}", { title: canvasTitle }) + : isFailed + ? t("canvasApp.revisionFailedTitle", "Couldn’t update {{title}}", { + title: canvasTitle, + }) + : t("canvasApp.revisionDoneTitle", "Updated {{title}}", { + title: canvasTitle, + }); + const detail = errorDetail?.trim() + ? errorDetail.trim() + : summary.changeKind === "targeted" + ? t( + "canvasApp.revisionTargetedSummary", + "{{amount}} targeted changes · same Canvas", + { + amount: summary.editCount, + } + ) + : summary.changeKind === "replacement" + ? t( + "canvasApp.revisionReplacementSummary", + "Full replacement · {{amount}} characters · same Canvas", + { + amount: formatCanvasRevisionCharacterCount( + summary.payloadCharacters + ), + } + ) + : summary.changeKind === "url" + ? t("canvasApp.revisionUrlSummary", "URL updated · same Canvas") + : t( + "canvasApp.revisionGenericSummary", + "Existing Canvas updated in place" + ); + + return ( +
+ + + + {title} + + + {detail} + + + {summary.agentSteps.length > 0 && ( +
+ +
+ )} +
+ ); +}; + +CanvasRevisionActivity.displayName = "CanvasRevisionActivity"; + +export default CanvasRevisionActivity; diff --git a/src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionProgress.tsx b/src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionProgress.tsx new file mode 100644 index 000000000..5319cde4d --- /dev/null +++ b/src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionProgress.tsx @@ -0,0 +1,70 @@ +import { LoaderCircle, PenTool } from "lucide-react"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +import type { CanvasRevisionDraft } from "@src/store/session/canvasRevisionDraftAtom"; + +import CanvasRevisionSteps from "./CanvasRevisionSteps"; +import { formatCanvasRevisionCharacterCount } from "./canvasRevisionProgressState"; + +interface CanvasRevisionProgressProps { + draft: CanvasRevisionDraft; + variant?: "chat" | "overlay"; +} + +const CanvasRevisionProgress: React.FC = ({ + draft, + variant = "chat", +}) => { + const { t } = useTranslation("sessions"); + const title = draft.title?.trim() || t("canvasApp.revisionCanvas", "Canvas"); + const applying = draft.phase === "applying"; + const detail = applying + ? t("canvasApp.revisionApplying", "Applying the validated change…") + : t( + "canvasApp.revisionReceiving", + "Generating the change · {{amount}} characters", + { + amount: formatCanvasRevisionCharacterCount(draft.receivedCharacters), + } + ); + + return ( +
+ + + + + + + {t("canvasApp.revisionTitle", "Updating {{title}}", { title })} + + {detail} + + +
+ ); +}; + +CanvasRevisionProgress.displayName = "CanvasRevisionProgress"; + +export default CanvasRevisionProgress; diff --git a/src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionSteps.tsx b/src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionSteps.tsx new file mode 100644 index 000000000..f78ec43f4 --- /dev/null +++ b/src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionSteps.tsx @@ -0,0 +1,78 @@ +import { CheckCircle2, Circle, CircleX, LoaderCircle } from "lucide-react"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { SESSION_UI_TOKENS } from "@src/engines/ChatPanel/blocks/primitives"; + +import { + type CanvasRevisionActivityPhase, + type CanvasRevisionStepState, + getCanvasRevisionStepStates, +} from "./canvasRevisionActivityState"; + +interface CanvasRevisionStepsProps { + phase: CanvasRevisionActivityPhase; + steps: readonly string[]; + className?: string; +} + +const StepIcon: React.FC<{ state: CanvasRevisionStepState }> = ({ state }) => { + const size = SESSION_UI_TOKENS.ICON.SIZE_XS; + if (state === "complete") { + return ; + } + if (state === "active") { + return ( + + ); + } + if (state === "failed") { + return ; + } + return ; +}; + +const CanvasRevisionSteps: React.FC = ({ + phase, + steps, + className = "", +}) => { + const { t } = useTranslation("sessions"); + if (steps.length === 0) return null; + const states = getCanvasRevisionStepStates(phase, steps.length); + + return ( +
    + {steps.map((label, index) => { + const state = states[index] ?? "pending"; + return ( +
  1. + + + + + {label} + +
  2. + ); + })} +
+ ); +}; + +CanvasRevisionSteps.displayName = "CanvasRevisionSteps"; + +export default CanvasRevisionSteps; diff --git a/src/engines/ChatPanel/blocks/CanvasInlineCard/ReactArtifactRunner.runtime.test.ts b/src/engines/ChatPanel/blocks/CanvasInlineCard/ReactArtifactRunner.runtime.test.ts index b1d177488..2fd5a576d 100644 --- a/src/engines/ChatPanel/blocks/CanvasInlineCard/ReactArtifactRunner.runtime.test.ts +++ b/src/engines/ChatPanel/blocks/CanvasInlineCard/ReactArtifactRunner.runtime.test.ts @@ -44,11 +44,22 @@ describe("ReactArtifactRunner runtime", () => { } }); - it("provides a bounded native scroll container for tall sketches", async () => { + it("keeps tall and fixed-width sketches reachable inside the bounded scroll container", async () => { const root = createSmokeRoot(); const source = ` function App() { - return React.createElement("div", { style: { height: 1200 } }, "Tall sketch"); + return React.createElement( + "div", + { + style: { + display: "grid", + gridTemplateColumns: "220px 330px minmax(450px, 1fr)", + height: 1200, + overflow: "hidden" + } + }, + "Tall and wide sketch" + ); } `; @@ -58,7 +69,52 @@ describe("ReactArtifactRunner runtime", () => { const scrollContainer = root.container.querySelector( '[data-testid="react-artifact-scroll"]' ); + const preview = root.container.querySelector( + '[data-testid="react-artifact-preview"]' + ); expect(scrollContainer?.classList.contains("overflow-auto")).toBe(true); + expect(preview?.classList.contains("w-fit")).toBe(true); + expect(preview?.classList.contains("min-w-full")).toBe(true); + } finally { + await root.unmount(); + } + }); + + it("keeps the live preview DOM and its state across parent rerenders", async () => { + const root = createSmokeRoot(); + const source = ` + const { useState } = React; + function App() { + const [count, setCount] = useState(0); + return React.createElement( + "button", + { type: "button", onClick: () => setCount((value) => value + 1) }, + "Count " + count + ); + } + `; + + try { + await root.render( + React.createElement(ReactArtifactRunner, { + source, + onError: vi.fn(), + }) + ); + const originalButton = root.container.querySelector("button"); + await dispatch(() => originalButton?.click()); + expect(originalButton?.textContent).toBe("Count 1"); + + await root.render( + React.createElement(ReactArtifactRunner, { + source, + onError: vi.fn(), + }) + ); + + const buttonAfterParentRender = root.container.querySelector("button"); + expect(buttonAfterParentRender).toBe(originalButton); + expect(buttonAfterParentRender?.textContent).toBe("Count 1"); } finally { await root.unmount(); } diff --git a/src/engines/ChatPanel/blocks/CanvasInlineCard/ReactArtifactRunner.tsx b/src/engines/ChatPanel/blocks/CanvasInlineCard/ReactArtifactRunner.tsx index 882cf57ba..bfa6098b9 100644 --- a/src/engines/ChatPanel/blocks/CanvasInlineCard/ReactArtifactRunner.tsx +++ b/src/engines/ChatPanel/blocks/CanvasInlineCard/ReactArtifactRunner.tsx @@ -11,6 +11,11 @@ export interface ReactArtifactRunnerProps { onError?: (error: ReactArtifactError) => void; } +// react-live retranspiles whenever `scope` changes by reference. A module-level +// immutable scope keeps parent-only renders (such as Canvas hover overlays) +// from replacing the preview DOM and resetting the generated app's state. +const REACT_LIVE_SCOPE = Object.freeze({ React }); + export function normalizeReactLiveSource(source: string): string { let code = source.replace( /^\s*import\s+React(?:\s*,\s*\{[^}]*\})?\s+from\s+["']react["'];?\s*$/gm, @@ -73,13 +78,18 @@ const ReactArtifactRunner: React.FC = ({
- + {/* Let a fixed/min-width artifact establish scrollable overflow before + its own root-level overflow rules can clip the narrow viewport. */} + { diff --git a/src/engines/ChatPanel/blocks/CanvasInlineCard/TEST_CASES.md b/src/engines/ChatPanel/blocks/CanvasInlineCard/TEST_CASES.md index 6f401483a..710b50cd7 100644 --- a/src/engines/ChatPanel/blocks/CanvasInlineCard/TEST_CASES.md +++ b/src/engines/ChatPanel/blocks/CanvasInlineCard/TEST_CASES.md @@ -4,36 +4,48 @@ - A session is open in ChatPanel. - The WorkStation Canvas app is visible or can be opened for the same session. -- The agent can invoke `render_inline_canvas`. +- The agent can invoke `render_inline_canvas` and `revise_inline_canvas`. +- New `revise_inline_canvas` calls include 1–6 request-specific `agent_steps` before `edits` or `content`. ## Happy Path -| # | Steps | Expected Result | -|---|-------|-----------------| -| 1 | Ask the agent to generate an interactive canvas. | The Canvas card appears inline in ChatPanel without an intermediate generic gray loading bar. | -| 2 | Observe the WorkStation while the canvas tool call arrives. | WorkStation and ChatPanel show the same canvas payload for the same event. | -| 3 | Wait for the assistant's final prose message. | The inline Canvas card remains visible when the streaming message becomes historical. | +| # | Steps | Expected Result | +| --- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| 1 | Ask the agent to generate an interactive canvas. | The Canvas card appears inline in ChatPanel without an intermediate generic gray loading bar. | +| 2 | Observe the WorkStation while the canvas tool call arrives. | WorkStation and ChatPanel show the same canvas payload for the same event. | +| 3 | Wait for the assistant's final prose message. | The inline Canvas card remains visible when the streaming message becomes historical. | ## Edge Cases -| # | Scenario | Steps | Expected Result | -|---|----------|-------|-----------------| -| 1 | Cold renderer cache | Open a fresh app window and generate a canvas as the first tool call. | ChatPanel preloads the renderer while the agent works and renders the card through the synchronous cache path. | -| 2 | Rapid finalization | Make `render_inline_canvas` complete immediately before the final assistant message. | No visible empty handoff appears between the live preview and persisted event card. | -| 3 | Session switch | Generate a canvas in session A, switch to session B, then return. | Session B never shows session A's preview; session A hydrates its persisted card. | -| 4 | Multiple canvases | Generate two canvases in consecutive turns. | Each historical tool event owns one inline card and the latest WorkStation selection advances normally. | +| # | Scenario | Steps | Expected Result | +| --- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Cold renderer cache | Open a fresh app window and generate a canvas as the first tool call. | ChatPanel preloads the renderer while the agent works and renders the card through the synchronous cache path. | +| 2 | Rapid finalization | Make `render_inline_canvas` complete immediately before the final assistant message. | No visible empty handoff appears between the live preview and persisted event card. | +| 3 | Session switch | Generate a canvas in session A, switch to session B, then return. | Session B never shows session A's preview; session A hydrates its persisted card. | +| 4 | Multiple canvases | Generate two canvases in consecutive turns. | Each historical tool event owns one inline card and the latest WorkStation selection advances normally. | +| 5 | Design revision | Select an element in Canvas Design, request a copy/style change, and wait for `revise_inline_canvas` with the required `target_event_id`. | The immutable revision event updates the existing Canvas entry, selects the latest version, and does not render a second full Canvas card in ChatPanel. | +| 6 | Revision chain | Revise the same Canvas twice. | Both revision events remain in history while WorkStation shows one logical Canvas with the newest content. | +| 7 | Invalid revision target | Emit a revision with a missing, non-Canvas, future, or cross-session target id. | Backend validation fails the tool call; ChatPanel shows the error and WorkStation keeps the last valid Canvas. | +| 8 | Reload after revision | Revise a Canvas, close and reopen the session, then open Canvas. | Persisted create/revision events project back to one logical Canvas containing the newest successful content. | +| 9 | Dynamic agent steps | Request two materially different Canvas revisions and inspect their live and historical activity rows. | Each row uses the number and labels supplied by its own Agent tool call; neither row receives a fixed three-step template. | +| 10 | Legacy revision event | Replay a stored revision that has no `agent_steps` field. | The revision title and factual summary remain visible, and no fabricated progress list is rendered. | +| 11 | Narrow activity width | Resize ChatPanel until one valid 80-character Agent step cannot fit on one line. | The label truncates inside the activity row, exposes the complete label as its title, and never crosses the panel boundary. | +| 12 | Step-count boundaries | Render revisions with one and six valid Agent steps. | Every supplied step is shown in order; the single-step and maximum-step layouts remain contained. | ## Error / Degraded States -| # | Scenario | Steps | Expected Result | -|---|----------|-------|-----------------| -| 1 | Renderer chunk fails to load | Simulate a dynamic-import failure. | The existing activity error boundary reports the render failure; WorkStation data remains intact. | -| 2 | Invalid or empty payload | Invoke the tool without displayable content. | The Canvas card shows its existing empty or failed state and does not remove unrelated messages. | +| # | Scenario | Steps | Expected Result | +| --- | ---------------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Renderer chunk fails to load | Simulate a dynamic-import failure. | The existing activity error boundary reports the render failure; WorkStation data remains intact. | +| 2 | Invalid or empty payload | Invoke the tool without displayable content. | The Canvas card shows its existing empty or failed state and does not remove unrelated messages. | +| 3 | Failed revision | Make a valid Canvas revision tool call fail after its event is recorded. | The failed event remains diagnosable in ChatPanel and never replaces the last valid Canvas in WorkStation. | +| 4 | Invalid Agent steps | Invoke a new revision with missing, empty, non-string, overlong, or more than six `agent_steps`. | Backend validation rejects the call; replay defensively omits an invalid list instead of showing partial or fixed fallback steps. | ## Accessibility - [ ] Canvas header remains keyboard-operable for collapse and navigation. - [ ] Loading and error states retain their existing accessible labels. +- [ ] The dynamic progress list keeps its localized screen-reader label, and truncated items expose their complete label as a title. - [ ] Focus is not moved when the live preview becomes a historical card. ## Acceptance Criteria @@ -42,4 +54,8 @@ - [ ] WorkStation and ChatPanel continue to read the same persisted event payload. - [ ] Final assistant-message arrival does not create a visible empty handoff. - [ ] Canvas previews remain isolated by session. +- [ ] Canvas Design revisions preserve logical Canvas identity without rewriting event history. +- [ ] New revision progress labels and counts come from persisted Agent `agent_steps`; no fixed step labels are synthesized. +- [ ] Legacy or invalid step metadata does not hide the revision summary and does not create fallback steps. +- [ ] Dynamic step labels remain contained at narrow widths. - [ ] Non-canvas activity renderers are unchanged. diff --git a/src/engines/ChatPanel/blocks/CanvasInlineCard/__tests__/CanvasRevisionActivity.test.ts b/src/engines/ChatPanel/blocks/CanvasInlineCard/__tests__/CanvasRevisionActivity.test.ts new file mode 100644 index 000000000..6feace4c1 --- /dev/null +++ b/src/engines/ChatPanel/blocks/CanvasInlineCard/__tests__/CanvasRevisionActivity.test.ts @@ -0,0 +1,172 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import CanvasRevisionActivity from "../CanvasRevisionActivity"; + +const testState = vi.hoisted(() => ({ + locate: vi.fn(), +})); + +vi.mock("@src/engines/ChatPanel/blocks/useBlockLocate", () => ({ + useBlockHeader: ({ eventId }: { eventId?: string }) => ({ + handleLocate: eventId ? testState.locate : vi.fn(), + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: ( + _key: string, + fallback: string, + values?: Record + ) => + Object.entries(values ?? {}).reduce( + (text, [name, value]) => text.split(`{{${name}}}`).join(String(value)), + fallback + ), + }), +})); + +describe("CanvasRevisionActivity", () => { + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("keeps a completed targeted-edit process visible in chat history", () => { + const markup = renderToStaticMarkup( + createElement(CanvasRevisionActivity, { + eventId: "revision-a", + status: "success", + args: { + title: "Coffee sketch", + edits: [ + { find: "Start", replace: "Start setup" }, + { find: "13px", replace: "15px" }, + ], + agent_steps: ["替换按钮文案", "核对原有交互"], + }, + }) + ); + + expect(markup).toContain('data-testid="canvas-revision-activity"'); + expect(markup).toContain("Updated Coffee sketch"); + expect(markup).toContain('title="Updated Coffee sketch"'); + expect(markup).toContain("truncate"); + expect(markup).toContain( + 'Updated Coffee sketch' + ); + expect(markup).toContain("2 targeted changes"); + expect(markup).toContain( + '2 targeted changes · same Canvas' + ); + expect(markup).toContain("替换按钮文案"); + expect(markup).toContain("核对原有交互"); + expect(markup.match(/data-step-state="complete"/g)).toHaveLength(2); + }); + + it("shows a failed apply step and the validated failure detail", () => { + const markup = renderToStaticMarkup( + createElement(CanvasRevisionActivity, { + eventId: "revision-a", + status: "failed", + args: { + title: "Coffee sketch", + agent_steps: ["替换完整画布", "验证结果"], + content: "function App() {}", + }, + errorDetail: "Exact source no longer matches", + }) + ); + + expect(markup).toContain("Couldn’t update Coffee sketch"); + expect(markup).toContain("Exact source no longer matches"); + expect(markup).toContain('data-step-state="failed"'); + }); + + it("does not fabricate fixed steps for legacy revision events", () => { + const markup = renderToStaticMarkup( + createElement(CanvasRevisionActivity, { + eventId: "revision-legacy", + status: "success", + args: { title: "Legacy", content: "function App() {}" }, + }) + ); + + expect(markup).not.toContain("Canvas update progress"); + expect(markup).not.toContain("Locate existing Canvas"); + expect(markup).not.toContain("Generate change"); + expect(markup).not.toContain("Apply and validate"); + }); + + it("truncates an individual agent label instead of overflowing", () => { + const label = "一个需要在窄布局中被截断但仍能通过标题查看的动态步骤"; + const markup = renderToStaticMarkup( + createElement(CanvasRevisionActivity, { + eventId: "revision-a", + status: "success", + args: { agent_steps: [label], content: "function App() {}" }, + }) + ); + + expect(markup).toContain(`title="${label}"`); + expect(markup).toContain('class="min-w-0 truncate"'); + }); + + it("reuses event replay navigation to open the corresponding Canvas", () => { + testState.locate.mockReset(); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => + root.render( + createElement(CanvasRevisionActivity, { + eventId: "revision-a", + status: "success", + args: { + title: "Coffee sketch", + target_event_id: "canvas-a", + edits: [{ find: "Start", replace: "Start setup" }], + }, + }) + ) + ); + + const navigate = container.querySelector( + "[data-testid='event-navigate']" + ); + expect(navigate).not.toBeNull(); + act(() => navigate?.click()); + expect(testState.locate).toHaveBeenCalledTimes(1); + + const header = container.querySelector(".chat-block-header"); + act(() => header?.click()); + expect(testState.locate).toHaveBeenCalledTimes(2); + + act(() => root.unmount()); + container.remove(); + }); + + it("stays non-interactive when the revision event has no stable id", () => { + const markup = renderToStaticMarkup( + createElement(CanvasRevisionActivity, { + status: "success", + args: { title: "Coffee sketch" }, + }) + ); + + expect(markup).not.toContain('data-testid="event-navigate"'); + expect(markup).toContain("cursor-default"); + }); +}); diff --git a/src/engines/ChatPanel/blocks/CanvasInlineCard/__tests__/canvasRevisionActivityState.test.ts b/src/engines/ChatPanel/blocks/CanvasInlineCard/__tests__/canvasRevisionActivityState.test.ts new file mode 100644 index 000000000..d89d853d1 --- /dev/null +++ b/src/engines/ChatPanel/blocks/CanvasInlineCard/__tests__/canvasRevisionActivityState.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { + getCanvasRevisionStepStates, + summarizeCanvasRevisionActivity, +} from "../canvasRevisionActivityState"; + +describe("Canvas revision activity state", () => { + it("moves the factual work steps through receiving, applying, and completion", () => { + expect(getCanvasRevisionStepStates("receiving", 2)).toEqual([ + "active", + "pending", + ]); + expect(getCanvasRevisionStepStates("applying", 2)).toEqual([ + "active", + "pending", + ]); + expect(getCanvasRevisionStepStates("completed", 2)).toEqual([ + "complete", + "complete", + ]); + }); + + it("marks failure without claiming later agent steps ran", () => { + expect(getCanvasRevisionStepStates("failed", 3)).toEqual([ + "failed", + "pending", + "pending", + ]); + expect(getCanvasRevisionStepStates("completed", 0)).toEqual([]); + }); + + it("summarizes compact edits without exposing source contents", () => { + expect( + summarizeCanvasRevisionActivity({ + title: "Coffee sketch", + edits: [ + { find: "Start", replace: "Start setup" }, + { find: "13px", replace: "15px" }, + ], + agent_steps: ["替换按钮文案", "核对原有交互"], + }) + ).toEqual({ + title: "Coffee sketch", + changeKind: "targeted", + editCount: 2, + payloadCharacters: 0, + agentSteps: ["替换按钮文案", "核对原有交互"], + }); + }); + + it("distinguishes full replacements, URLs, and empty legacy payloads", () => { + expect( + summarizeCanvasRevisionActivity({ content: "function App() {}" }) + ).toMatchObject({ changeKind: "replacement", payloadCharacters: 17 }); + expect( + summarizeCanvasRevisionActivity({ url: "https://example.com" }) + ).toMatchObject({ changeKind: "url" }); + expect(summarizeCanvasRevisionActivity({})).toMatchObject({ + changeKind: "unknown", + }); + }); +}); diff --git a/src/engines/ChatPanel/blocks/CanvasInlineCard/__tests__/canvasRevisionProgress.test.ts b/src/engines/ChatPanel/blocks/CanvasInlineCard/__tests__/canvasRevisionProgress.test.ts new file mode 100644 index 000000000..755969a11 --- /dev/null +++ b/src/engines/ChatPanel/blocks/CanvasInlineCard/__tests__/canvasRevisionProgress.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import type { CanvasRevisionDraft } from "@src/store/session/canvasRevisionDraftAtom"; + +import { + formatCanvasRevisionCharacterCount, + isCanvasRevisionDraftRelevant, +} from "../canvasRevisionProgressState"; + +function draft( + overrides: Partial = {} +): CanvasRevisionDraft { + return { + sessionId: "session-a", + toolCallId: "revision-a", + targetEventId: "canvas-a", + receivedCharacters: 1_200, + phase: "receiving", + startedAt: 1, + ...overrides, + }; +} + +describe("Canvas revision progress", () => { + it("formats bounded progress without pretending it is a percentage", () => { + expect(formatCanvasRevisionCharacterCount(0)).toBe("0"); + expect(formatCanvasRevisionCharacterCount(999)).toBe("999"); + expect(formatCanvasRevisionCharacterCount(1_200)).toBe("1.2K"); + expect(formatCanvasRevisionCharacterCount(21_162)).toBe("21K"); + }); + + it("only paints a draft on its owning session and selected Canvas", () => { + expect( + isCanvasRevisionDraftRelevant(draft(), "session-a", "canvas-a") + ).toBe(true); + expect( + isCanvasRevisionDraftRelevant(draft(), "session-b", "canvas-a") + ).toBe(false); + expect( + isCanvasRevisionDraftRelevant(draft(), "session-a", "canvas-b") + ).toBe(false); + expect( + isCanvasRevisionDraftRelevant( + draft(), + "session-a", + "tool-call-revision-a" + ) + ).toBe(true); + }); + + it("shows an early draft before target metadata has finished streaming", () => { + expect( + isCanvasRevisionDraftRelevant( + draft({ targetEventId: undefined }), + "session-a", + "canvas-a" + ) + ).toBe(true); + }); +}); diff --git a/src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevision.test.ts b/src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevision.test.ts new file mode 100644 index 000000000..64503e0f4 --- /dev/null +++ b/src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevision.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { + getCanvasRevisionAgentSteps, + getCanvasRevisionTargetId, + isCanvasRevisionPayload, + isCanvasRevisionToolName, + isSameLogicalCanvas, +} from "./canvasRevision"; + +describe("Canvas revision identity", () => { + it("reads the dedicated target id and keeps the legacy field compatible", () => { + expect(getCanvasRevisionTargetId({ target_event_id: " event-new " })).toBe( + "event-new" + ); + expect(getCanvasRevisionTargetId({ revises_event_id: " event-a " })).toBe( + "event-a" + ); + expect(getCanvasRevisionTargetId({ revises_event_id: " " })).toBeNull(); + expect(getCanvasRevisionTargetId({ revises_event_id: 42 })).toBeNull(); + expect(isCanvasRevisionToolName("revise_inline_canvas")).toBe(true); + expect(isCanvasRevisionToolName("render_inline_canvas")).toBe(false); + }); + + it("recognizes a direct revision as the same logical Canvas", () => { + const previous = { mode: "react" as const, eventId: "event-a" }; + const revision = { + mode: "react" as const, + eventId: "event-b", + revisesEventId: "event-a", + }; + + expect(isCanvasRevisionPayload(revision)).toBe(true); + expect(isSameLogicalCanvas(previous, revision)).toBe(true); + expect( + isSameLogicalCanvas(previous, { + mode: "react", + eventId: "event-c", + }) + ).toBe(false); + }); + + it("accepts only bounded agent-generated progress labels", () => { + expect( + getCanvasRevisionAgentSteps({ + agent_steps: [" 替换按钮文案 ", "核对原有交互"], + }) + ).toEqual(["替换按钮文案", "核对原有交互"]); + expect(getCanvasRevisionAgentSteps({})).toBeNull(); + expect(getCanvasRevisionAgentSteps({ agent_steps: [" "] })).toBeNull(); + expect( + getCanvasRevisionAgentSteps({ agent_steps: ["x".repeat(81)] }) + ).toBeNull(); + expect( + getCanvasRevisionAgentSteps({ + agent_steps: ["1", "2", "3", "4", "5", "6", "7"], + }) + ).toBeNull(); + }); +}); diff --git a/src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevision.ts b/src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevision.ts new file mode 100644 index 000000000..e4eb5011b --- /dev/null +++ b/src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevision.ts @@ -0,0 +1,181 @@ +import type { CanvasInlinePayload } from "./types"; + +export const CANVAS_CREATE_TOOL_NAME = "render_inline_canvas"; +export const CANVAS_REVISION_TOOL_NAME = "revise_inline_canvas"; +export const CANVAS_REVISION_TARGET_EVENT_ID_ARG = "target_event_id"; +export const LEGACY_CANVAS_REVISION_EVENT_ID_ARG = "revises_event_id"; +export const CANVAS_REVISION_EDITS_ARG = "edits"; +export const CANVAS_REVISION_AGENT_STEPS_ARG = "agent_steps"; + +export interface CanvasRevisionTextEdit { + /** Exact literal text to find in the current materialized Canvas source. */ + find: string; + /** Literal replacement text. */ + replace: string; + /** Replace every occurrence instead of requiring exactly one match. */ + all?: boolean; +} + +const MAX_CANVAS_REVISION_EDITS = 16; +const MAX_CANVAS_REVISION_EDIT_CHARS = 32_768; +export const MAX_CANVAS_REVISION_AGENT_STEPS = 6; +export const MAX_CANVAS_REVISION_AGENT_STEP_CHARS = 80; + +export function isCanvasToolName(value: string | undefined): boolean { + return ( + value === CANVAS_CREATE_TOOL_NAME || value === CANVAS_REVISION_TOOL_NAME + ); +} + +export function isCanvasRevisionToolName(value: string | undefined): boolean { + return value === CANVAS_REVISION_TOOL_NAME; +} + +export function getCanvasRevisionTargetId( + args: Record | undefined +): string | null { + for (const key of [ + CANVAS_REVISION_TARGET_EVENT_ID_ARG, + LEGACY_CANVAS_REVISION_EVENT_ID_ARG, + ]) { + const value = args?.[key]; + if (typeof value !== "string") continue; + const targetId = value.trim(); + if (targetId.length > 0) return targetId; + } + return null; +} + +export function isCanvasRevisionPayload(payload: CanvasInlinePayload): boolean { + return Boolean(payload.revisesEventId?.trim()); +} + +export function getCanvasRevisionTextEdits( + args: Record | undefined +): CanvasRevisionTextEdit[] | null { + const raw = args?.[CANVAS_REVISION_EDITS_ARG]; + if (!Array.isArray(raw) || raw.length === 0) return null; + if (raw.length > MAX_CANVAS_REVISION_EDITS) return null; + + const edits: CanvasRevisionTextEdit[] = []; + for (const candidate of raw) { + if (!candidate || typeof candidate !== "object") return null; + const edit = candidate as Record; + if ( + typeof edit.find !== "string" || + edit.find.length === 0 || + edit.find.length > MAX_CANVAS_REVISION_EDIT_CHARS || + typeof edit.replace !== "string" || + edit.replace.length > MAX_CANVAS_REVISION_EDIT_CHARS || + edit.find === edit.replace || + (edit.all !== undefined && typeof edit.all !== "boolean") + ) { + return null; + } + edits.push({ + find: edit.find, + replace: edit.replace, + ...(edit.all === true ? { all: true } : {}), + }); + } + return edits; +} + +export function getCanvasRevisionAgentSteps( + args: Record | undefined +): string[] | null { + const raw = args?.[CANVAS_REVISION_AGENT_STEPS_ARG]; + if ( + !Array.isArray(raw) || + raw.length === 0 || + raw.length > MAX_CANVAS_REVISION_AGENT_STEPS + ) { + return null; + } + + const steps: string[] = []; + for (const candidate of raw) { + if (typeof candidate !== "string") return null; + const label = candidate.trim(); + if ( + label.length === 0 || + Array.from(label).length > MAX_CANVAS_REVISION_AGENT_STEP_CHARS + ) { + return null; + } + steps.push(label); + } + return steps; +} + +function countLiteralOccurrences(source: string, find: string): number { + let count = 0; + let offset = 0; + while (offset <= source.length - find.length) { + const index = source.indexOf(find, offset); + if (index < 0) break; + count += 1; + offset = index + find.length; + } + return count; +} + +/** + * Materialize a compact revision against the previous immutable Canvas args. + * + * The backend runs the same exact-match policy before accepting the tool call. + * Returning `null` keeps the last valid Canvas visible when a malformed or + * stale patch somehow reaches replay (for example from an older client). + */ +export function materializeCanvasRevisionArgs( + targetArgs: Record | undefined, + revisionArgs: Record | undefined +): Record | null { + if (!targetArgs || !revisionArgs) return null; + if (!(CANVAS_REVISION_EDITS_ARG in revisionArgs)) return revisionArgs; + + const edits = getCanvasRevisionTextEdits(revisionArgs); + const source = targetArgs.content; + if (!edits || typeof source !== "string") return null; + + const targetMode = targetArgs.mode; + const requestedMode = revisionArgs.mode; + if ( + typeof targetMode !== "string" || + (typeof requestedMode === "string" && requestedMode !== targetMode) + ) { + return null; + } + + let content = source; + for (const edit of edits) { + const matches = countLiteralOccurrences(content, edit.find); + if (matches === 0 || (!edit.all && matches !== 1)) return null; + content = edit.all + ? content.split(edit.find).join(edit.replace) + : content.replace(edit.find, edit.replace); + } + + return { + ...targetArgs, + ...revisionArgs, + mode: targetMode, + content, + title: + typeof revisionArgs.title === "string" + ? revisionArgs.title + : targetArgs.title, + }; +} + +export function isSameLogicalCanvas( + previous: CanvasInlinePayload, + next: CanvasInlinePayload +): boolean { + const previousEventId = previous.eventId; + if (!previousEventId) return false; + return ( + previousEventId === next.eventId || + previousEventId === next.revisesEventId?.trim() + ); +} diff --git a/src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevisionActivityState.ts b/src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevisionActivityState.ts new file mode 100644 index 000000000..838015aa5 --- /dev/null +++ b/src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevisionActivityState.ts @@ -0,0 +1,78 @@ +import { + getCanvasRevisionAgentSteps, + getCanvasRevisionTextEdits, +} from "./canvasRevision"; + +export type CanvasRevisionActivityPhase = + | "receiving" + | "applying" + | "completed" + | "failed" + | "cancelled"; + +export type CanvasRevisionStepState = + | "complete" + | "active" + | "pending" + | "failed"; + +export type CanvasRevisionChangeKind = + | "targeted" + | "replacement" + | "url" + | "unknown"; + +export interface CanvasRevisionActivitySummary { + title?: string; + changeKind: CanvasRevisionChangeKind; + editCount: number; + payloadCharacters: number; + agentSteps: string[]; +} + +export function getCanvasRevisionStepStates( + phase: CanvasRevisionActivityPhase, + stepCount: number +): CanvasRevisionStepState[] { + if (stepCount <= 0) return []; + + switch (phase) { + case "receiving": + case "applying": + return Array.from({ length: stepCount }, (_, index) => + index === 0 ? "active" : "pending" + ); + case "completed": + return Array.from({ length: stepCount }, () => "complete"); + case "failed": + case "cancelled": + return Array.from({ length: stepCount }, (_, index) => + index === 0 ? "failed" : "pending" + ); + } +} + +export function summarizeCanvasRevisionActivity( + args: Record +): CanvasRevisionActivitySummary { + const edits = getCanvasRevisionTextEdits(args); + const content = typeof args.content === "string" ? args.content : undefined; + const url = typeof args.url === "string" ? args.url : undefined; + + return { + title: + typeof args.title === "string" + ? args.title.trim() || undefined + : undefined, + changeKind: edits + ? "targeted" + : content !== undefined + ? "replacement" + : url + ? "url" + : "unknown", + editCount: edits?.length ?? 0, + payloadCharacters: content?.length ?? 0, + agentSteps: getCanvasRevisionAgentSteps(args) ?? [], + }; +} diff --git a/src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevisionProgressState.ts b/src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevisionProgressState.ts new file mode 100644 index 000000000..0f756680e --- /dev/null +++ b/src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevisionProgressState.ts @@ -0,0 +1,24 @@ +import type { CanvasRevisionDraft } from "@src/store/session/canvasRevisionDraftAtom"; + +export function formatCanvasRevisionCharacterCount(count: number): string { + const safeCount = Math.max(0, Math.floor(count)); + if (safeCount < 1_000) return String(safeCount); + if (safeCount < 10_000) { + return `${(safeCount / 1_000).toFixed(1).replace(/\.0$/, "")}K`; + } + return `${Math.round(safeCount / 1_000)}K`; +} + +export function isCanvasRevisionDraftRelevant( + draft: CanvasRevisionDraft | null, + sessionId: string | null | undefined, + selectedEventId?: string | null +): draft is CanvasRevisionDraft { + if (!draft || !sessionId || draft.sessionId !== sessionId) return false; + return ( + !draft.targetEventId || + !selectedEventId || + draft.targetEventId === selectedEventId || + `tool-call-${draft.toolCallId}` === selectedEventId + ); +} diff --git a/src/engines/ChatPanel/blocks/CanvasInlineCard/openInSimulatorCanvas.ts b/src/engines/ChatPanel/blocks/CanvasInlineCard/openInSimulatorCanvas.ts index 46383be8c..695f8bc5a 100644 --- a/src/engines/ChatPanel/blocks/CanvasInlineCard/openInSimulatorCanvas.ts +++ b/src/engines/ChatPanel/blocks/CanvasInlineCard/openInSimulatorCanvas.ts @@ -9,6 +9,7 @@ import { canvasPreviewAtom } from "@src/store/session/canvasPreviewAtom"; import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; +import { isSameLogicalCanvas } from "./canvasRevision"; import type { CanvasInlinePayload } from "./types"; export function openInSimulatorCanvas( @@ -20,8 +21,7 @@ export function openInSimulatorCanvas( const previous = store.get(canvasPreviewAtom); const sameCanvas = previous?.sessionId === sessionId && - previous.payload.eventId && - previous.payload.eventId === payload.eventId; + isSameLogicalCanvas(previous.payload, payload); store.set(canvasPreviewAtom, { sessionId, payload, diff --git a/src/engines/ChatPanel/blocks/CanvasInlineCard/types.ts b/src/engines/ChatPanel/blocks/CanvasInlineCard/types.ts index a3c4073a1..013fb189a 100644 --- a/src/engines/ChatPanel/blocks/CanvasInlineCard/types.ts +++ b/src/engines/ChatPanel/blocks/CanvasInlineCard/types.ts @@ -22,6 +22,8 @@ export interface CanvasInlinePayload { title?: string; streaming?: boolean; eventId?: string; + /** Event id of the prior Canvas version when this payload is a revision. */ + revisesEventId?: string; } export interface CanvasInlineCardProps { diff --git a/src/engines/ChatPanel/blocks/primitives/EventBlockHeaderTextSlots.tsx b/src/engines/ChatPanel/blocks/primitives/EventBlockHeaderTextSlots.tsx index b73d015b0..04c9724b6 100644 --- a/src/engines/ChatPanel/blocks/primitives/EventBlockHeaderTextSlots.tsx +++ b/src/engines/ChatPanel/blocks/primitives/EventBlockHeaderTextSlots.tsx @@ -39,6 +39,8 @@ export interface EventBlockHeaderTitleProps { * agent-provided description promoted into the title slot). */ truncate?: boolean; + /** Native hover text for truncated variable titles. */ + title?: string; className?: string; } @@ -60,10 +62,12 @@ export const EventBlockHeaderTitle: React.FC = ({ children, isLoading = false, truncate = false, + title, className = "", }) => ( {children} diff --git a/src/engines/ChatPanel/events/stream/agent-message/index.tsx b/src/engines/ChatPanel/events/stream/agent-message/index.tsx index a40d7b044..a66e0c0d6 100644 --- a/src/engines/ChatPanel/events/stream/agent-message/index.tsx +++ b/src/engines/ChatPanel/events/stream/agent-message/index.tsx @@ -19,6 +19,8 @@ import { getEventIcon } from "@src/config/toolIcons"; import AgentChatItemDefault from "@src/engines/ChatPanel/ChatItems/AgentChatItemDefault"; import { AgentMessageBlock } from "@src/engines/ChatPanel/blocks"; import CanvasInlineCard from "@src/engines/ChatPanel/blocks/CanvasInlineCard"; +import CanvasRevisionProgress from "@src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionProgress"; +import { isCanvasRevisionPayload } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevision"; import { useCanvasForTurn } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/useCanvasForTurn"; import MessageReferenceCards from "@src/engines/ChatPanel/blocks/MessageReferenceCards"; import LlmUsageBadge from "@src/engines/ChatPanel/blocks/ToolCallBlock/LlmUsageBadge"; @@ -34,7 +36,10 @@ import { getEventBlockContentClasses, useEventBlockHeader, } from "@src/engines/ChatPanel/blocks/primitives"; -import { useStreamingDeltaForSession } from "@src/engines/SessionCore"; +import { + useCanvasRevisionDraftForSession, + useStreamingDeltaForSession, +} from "@src/engines/SessionCore"; import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms"; import { type RawEventInput, @@ -182,6 +187,10 @@ const ChatVariant: React.FC = ({ ); const streamingCanvasPayload = streamingCanvas.payload; const canvasPayload = isStreaming ? streamingCanvasPayload : null; + const revisionDraft = useCanvasRevisionDraftForSession( + isStreaming ? sessionId : null + ); + const showRevisionReceiving = revisionDraft?.phase === "receiving"; if (!content && !thinkingContent && !isStreaming && !canvasPayload) return null; @@ -191,7 +200,8 @@ const ChatVariant: React.FC = ({ // is populated. In that case we render only the inline thinking block // and skip the empty assistant bubble — otherwise the user sees a blank // chat row with no testid content. - const hasVisibleContent = Boolean(content) || isStreaming; + const hasVisibleContent = + Boolean(content) || (isStreaming && revisionDraft === null); return ( <> @@ -233,7 +243,12 @@ const ChatVariant: React.FC = ({ )} - {canvasPayload && ( + {showRevisionReceiving && ( +
+ +
+ )} + {canvasPayload && !isCanvasRevisionPayload(canvasPayload) && (
({ + default: () => createElement("div", { "data-testid": "canvas-card" }), +})); + +vi.mock( + "@src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionActivity", + () => ({ + default: ({ + errorDetail, + eventId, + }: { + errorDetail?: string; + eventId?: string; + }) => + createElement( + "div", + { + "data-testid": "canvas-revision-activity", + "data-event-id": eventId, + }, + errorDetail + ), + }) +); + +vi.mock("@src/engines/SessionCore/rendering/registry", () => ({ + statusToLifecycle: (status: string) => status, + useLifecycleLabels: () => ({ + completed: "Completed", + failed: "Failed", + running: "Running", + }), +})); + +vi.mock("@src/util/ui/rendering/toolAction", () => ({ + deriveToolAction: () => "render", +})); + +function canvasProps( + args: Record, + status: "success" | "running" | "failed" = "success", + functionName = "revise_inline_canvas" +): UniversalEventProps { + return { + eventId: "event-b", + eventType: "canvas_inline", + functionName, + sessionId: "session-a", + args, + result: status === "failed" ? { error: "Revision failed" } : {}, + status, + } as unknown as UniversalEventProps; +} + +describe("CanvasInlineAdapter revisions", () => { + it("renders a persistent activity record instead of a second Canvas card", () => { + const markup = renderToStaticMarkup( + createElement( + CanvasInlineAdapter, + canvasProps({ + mode: "react", + content: "function App() {}", + target_event_id: "event-a", + }) + ) + ); + + expect(markup).toContain('data-testid="canvas-revision-activity"'); + expect(markup).toContain('data-event-id="event-b"'); + expect(markup).not.toContain('data-testid="canvas-card"'); + }); + + it("still surfaces a failed revision", () => { + const markup = renderToStaticMarkup( + createElement( + CanvasInlineAdapter, + canvasProps( + { + mode: "react", + content: "function App() {}", + target_event_id: "event-a", + }, + "failed" + ) + ) + ); + + expect(markup).toContain("Revision failed"); + }); + + it("does not hide a legacy create event solely because it has malformed revision metadata", () => { + const markup = renderToStaticMarkup( + createElement( + CanvasInlineAdapter, + canvasProps( + { + mode: "react", + content: "function App() {}", + revises_event_id: "missing-event", + }, + "success", + "render_inline_canvas" + ) + ) + ); + + expect(markup).toContain('data-testid="canvas-card"'); + }); +}); diff --git a/src/engines/ChatPanel/rendering/adapters/CanvasInlineAdapter.tsx b/src/engines/ChatPanel/rendering/adapters/CanvasInlineAdapter.tsx index c361bcf20..40b4cfd72 100644 --- a/src/engines/ChatPanel/rendering/adapters/CanvasInlineAdapter.tsx +++ b/src/engines/ChatPanel/rendering/adapters/CanvasInlineAdapter.tsx @@ -17,6 +17,8 @@ import React from "react"; import CanvasInlineCard from "@src/engines/ChatPanel/blocks/CanvasInlineCard"; +import CanvasRevisionActivity from "@src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionActivity"; +import { isCanvasRevisionToolName } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevision"; import type { CanvasInlineMode } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/types"; import { statusToLifecycle, @@ -80,6 +82,17 @@ export const CanvasInlineAdapter: React.FC = (props) => { ? props.result.observation : labels[state] || "Canvas render failed"; + if (isCanvasRevisionToolName(props.functionName)) { + return ( + + ); + } + return (
= (props) => { ); } + // A revision updates the existing logical Canvas in Simulator. Keep its + // factual work record in chat without rendering a duplicate preview card. + if (isCanvasRevisionToolName(props.functionName)) { + return ( + + ); + } + return (
{ + it("recognizes both create and revision tools before registry hydration", () => { + expect(isCanvasEvent(event("render_inline_canvas"))).toBe(true); + expect(isCanvasEvent(event("revise_inline_canvas"))).toBe(true); + expect(isCanvasEvent(event("unknown", "canvas_inline"))).toBe(true); + expect(isCanvasEvent(event("read_file"))).toBe(false); + }); +}); diff --git a/src/engines/SessionCore/core/store/snapshotMaterialization.canvasPreview.ts b/src/engines/SessionCore/core/store/snapshotMaterialization.canvasPreview.ts index 5c050e702..614492588 100644 --- a/src/engines/SessionCore/core/store/snapshotMaterialization.canvasPreview.ts +++ b/src/engines/SessionCore/core/store/snapshotMaterialization.canvasPreview.ts @@ -15,7 +15,8 @@ export function isCanvasEvent(event: SessionEvent | undefined): boolean { return Boolean( event && (event.uiCanonical === "canvas_inline" || - event.functionName === "render_inline_canvas") + event.functionName === "render_inline_canvas" || + event.functionName === "revise_inline_canvas") ); } diff --git a/src/engines/SessionCore/hooks/useCanvasRevisionDraftForSession.ts b/src/engines/SessionCore/hooks/useCanvasRevisionDraftForSession.ts new file mode 100644 index 000000000..9b7e84547 --- /dev/null +++ b/src/engines/SessionCore/hooks/useCanvasRevisionDraftForSession.ts @@ -0,0 +1,52 @@ +import { useAtomValue } from "jotai"; +import { selectAtom } from "jotai/utils"; +import { useMemo } from "react"; + +import { + type CanvasRevisionDraft, + canvasRevisionDraftsAtom, +} from "@src/store/session/canvasRevisionDraftAtom"; + +function stringArraysEqual( + left: readonly string[] | undefined, + right: readonly string[] | undefined +): boolean { + if (left === right) return true; + if (!left || !right || left.length !== right.length) return false; + return left.every((value, index) => value === right[index]); +} + +function draftEqual( + left: CanvasRevisionDraft | null, + right: CanvasRevisionDraft | null +): boolean { + if (left === right) return true; + if (!left || !right) return false; + return ( + left.toolCallId === right.toolCallId && + left.targetEventId === right.targetEventId && + left.mode === right.mode && + left.title === right.title && + stringArraysEqual(left.agentSteps, right.agentSteps) && + left.receivedCharacters === right.receivedCharacters && + left.phase === right.phase + ); +} + +/** Subscribe only to one session's revision progress. */ +export function useCanvasRevisionDraftForSession( + sessionId: string | null | undefined +): CanvasRevisionDraft | null { + const scopedAtom = useMemo( + () => + selectAtom( + canvasRevisionDraftsAtom, + (drafts) => (sessionId ? (drafts.get(sessionId) ?? null) : null), + draftEqual + ), + [sessionId] + ); + return useAtomValue(scopedAtom); +} + +export default useCanvasRevisionDraftForSession; diff --git a/src/engines/SessionCore/index.ts b/src/engines/SessionCore/index.ts index 223ff0104..9a0e1eee6 100644 --- a/src/engines/SessionCore/index.ts +++ b/src/engines/SessionCore/index.ts @@ -218,6 +218,7 @@ export type { UseSessionStoreReturn } from "./hooks/useSessionStore"; // Per-session live streaming delta selector (avoids whole-Map subscriptions) export { useStreamingDeltaForSession } from "./hooks/useStreamingDeltaForSession"; +export { useCanvasRevisionDraftForSession } from "./hooks/useCanvasRevisionDraftForSession"; // Session management (hooks/session/) — imported per-file to avoid barrel circularity export { useSessionManager } from "./hooks/session/useSessionManager"; diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/canvasRevisionStreaming.test.ts b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/canvasRevisionStreaming.test.ts new file mode 100644 index 000000000..eac1db0eb --- /dev/null +++ b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/canvasRevisionStreaming.test.ts @@ -0,0 +1,133 @@ +import { createStore } from "jotai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { canvasRevisionDraftsAtom } from "@src/store/session/canvasRevisionDraftAtom"; + +import { handleToolCallDelta } from "../streamHandlers"; +import { resetAllStreamingState } from "../streamHelpers"; +import { handleToolCall, handleToolResult } from "../toolHandlers"; +import type { EventHandlerContext } from "../types"; + +vi.mock( + "@src/engines/ChatPanel/blocks/CanvasInlineCard/openInSimulatorCanvas", + () => ({ openInSimulatorCanvas: vi.fn() }) +); + +function ref(value: T): { current: T } { + return { current: value }; +} + +function context(store: ReturnType): EventHandlerContext { + return { + filterSessionIdRef: ref("session-a"), + assistantStreamRef: ref({ idRef: ref(""), contentRef: ref("") }), + thinkingStreamRef: ref({ idRef: ref(""), contentRef: ref("") }), + toolCallDeltaBuffersRef: ref(new Map()), + onAgentCompleteRef: ref(undefined), + onContextUsageRef: ref(undefined), + onTokenUpdateRef: ref(undefined), + onStatusChangeRef: ref(undefined), + onQuestionRequestRef: ref(undefined), + setStreaming: vi.fn(), + features: { hasToolCallDelta: true }, + getDefaultStore: () => store, + }; +} + +describe("Canvas revision streaming lifecycle", () => { + beforeEach(() => { + vi.stubGlobal("window", { dispatchEvent: vi.fn() }); + vi.stubGlobal( + "CustomEvent", + class CustomEventStub { + constructor( + public type: string, + public init?: { detail?: unknown } + ) {} + } + ); + }); + + it("publishes ephemeral progress, applies the final call, then clears it", async () => { + const store = createStore(); + const ctx = context(store); + + handleToolCallDelta( + { + type: "agent:tool_call_delta", + tool: "revise_inline_canvas", + toolCallId: "revision-a", + index: 0, + argumentsDelta: + '{"agent_steps":["替换按钮文案","核对原有交互"],"target_event_id":"canvas-a","mode":"react","title":"Coffee","edits":[', + }, + "session-a", + ctx + ); + + expect(store.get(canvasRevisionDraftsAtom).get("session-a")).toMatchObject({ + toolCallId: "revision-a", + targetEventId: "canvas-a", + mode: "react", + title: "Coffee", + agentSteps: ["替换按钮文案", "核对原有交互"], + phase: "receiving", + }); + + handleToolCall( + { + type: "agent:tool_call", + sessionId: "session-a", + tool: "revise_inline_canvas", + toolCallId: "revision-a", + args: { + target_event_id: "canvas-a", + mode: "react", + agent_steps: ["替换按钮文案", "核对原有交互"], + edits: [{ find: "Start", replace: "Start setup" }], + }, + }, + "session-a", + "session-a", + ctx + ); + + expect(store.get(canvasRevisionDraftsAtom).get("session-a")?.phase).toBe( + "applying" + ); + + await handleToolResult( + { + type: "agent:tool_result", + sessionId: "session-a", + tool: "revise_inline_canvas", + toolCallId: "revision-a", + result: "accepted", + }, + "session-a", + ctx + ); + + expect(store.get(canvasRevisionDraftsAtom).has("session-a")).toBe(false); + }); + + it("clears a partial draft on cancellation, error, or adapter disposal", () => { + const store = createStore(); + const ctx = context(store); + handleToolCallDelta( + { + type: "agent:tool_call_delta", + tool: "revise_inline_canvas", + toolCallId: "revision-a", + argumentsDelta: '{"target_event_id":"canvas-a"', + }, + "session-a", + ctx + ); + expect(store.get(canvasRevisionDraftsAtom).has("session-a")).toBe(true); + + resetAllStreamingState(ctx); + + expect(store.get(canvasRevisionDraftsAtom).has("session-a")).toBe(false); + }); +}); diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/toolHandlers.test.ts b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/toolHandlers.test.ts index 26f7601dc..a7111af18 100644 --- a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/toolHandlers.test.ts +++ b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/__tests__/toolHandlers.test.ts @@ -1,9 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { openInSimulatorCanvas } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/openInSimulatorCanvas"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import type { AgentWSEvent } from "@src/engines/SessionCore/sync/adapters/shared/types"; -import { handleToolResult } from "../toolHandlers"; +import { handleToolCall, handleToolResult } from "../toolHandlers"; import type { EventHandlerContext } from "../types"; const { events, updateByIdSpy, getEventsSpy } = vi.hoisted(() => { @@ -92,6 +93,7 @@ describe("rust agent tool result handler", () => { events.clear(); updateByIdSpy.mockClear(); getEventsSpy.mockClear(); + vi.mocked(openInSimulatorCanvas).mockClear(); }); it("does not downgrade an authoritative completed subagent parent card back to running", async () => { @@ -111,4 +113,30 @@ describe("rust agent tool result handler", () => { expect(updateByIdSpy).not.toHaveBeenCalled(); expect(events.get("parent-agent-call")?.displayStatus).toBe("completed"); }); + + it("dispatches revise_inline_canvas with its target identity intact", () => { + const event: AgentWSEvent = { + type: "agent:tool_call", + sessionId: "session-1", + tool: "revise_inline_canvas", + toolCallId: "call-revision-1", + args: { + target_event_id: "tool-call-original", + mode: "react", + content: "function App() { return
Updated
; }", + title: "Updated Canvas", + }, + }; + + handleToolCall(event, "session-1", "session-1", createCtx()); + + expect(openInSimulatorCanvas).toHaveBeenCalledWith( + "session-1", + expect.objectContaining({ + eventId: "tool-call-call-revision-1", + revisesEventId: "tool-call-original", + content: expect.stringContaining("Updated"), + }) + ); + }); }); diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/streamHandlers.ts b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/streamHandlers.ts index 4b89bff2c..121ce2186 100644 --- a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/streamHandlers.ts +++ b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/streamHandlers.ts @@ -4,13 +4,20 @@ * Handlers for message, thinking, and tool call delta events. * Also handles agent:streaming_complete from Rust StreamingBuffer. */ +import { + CANVAS_REVISION_AGENT_STEPS_ARG, + CANVAS_REVISION_TOOL_NAME, + getCanvasRevisionAgentSteps, +} from "@src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevision"; import { createLogger } from "@src/hooks/logger"; +import { bufferCanvasRevisionDraft } from "@src/store/session/canvasRevisionDraftAtom"; import { appendBoundedToolCallArgs, makeRoomForToolCallDelta, mergeStreamingText, } from "../../shared/streamTextAccumulator"; +import { parseCanvasRevisionDeltaMetadata } from "../../shared/streamingParsers"; import { capStreamContent } from "../../shared/subagentTracking"; import type { AgentWSEvent, StreamRefs } from "../../shared/types"; import { @@ -171,6 +178,26 @@ export function handleToolCallDelta( return; } + if (buffer.toolName === CANVAS_REVISION_TOOL_NAME) { + const store = ctx.getDefaultStore(); + if (store) { + const metadata = parseCanvasRevisionDeltaMetadata(buffer.argsJson); + const agentSteps = getCanvasRevisionAgentSteps({ + [CANVAS_REVISION_AGENT_STEPS_ARG]: metadata.agentSteps, + }); + bufferCanvasRevisionDraft(store, { + sessionId, + toolCallId: buffer.toolCallId, + targetEventId: metadata.targetEventId, + mode: metadata.mode, + title: metadata.title, + agentSteps: agentSteps ?? undefined, + receivedCharacters: buffer.argsJson.length, + phase: "receiving", + }); + } + } + // Tool-call deltas stay ephemeral; the authoritative tool_call event is written by Rust. } diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/streamHelpers.ts b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/streamHelpers.ts index 30a519c6b..e756f9db4 100644 --- a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/streamHelpers.ts +++ b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/streamHelpers.ts @@ -3,8 +3,9 @@ * * Helper functions for finalizing streaming content and common event accessors. */ -import type { StreamRefs } from "../../shared/types"; -import type { AgentWSEvent } from "../../shared/types"; +import { clearCanvasRevisionDraft } from "@src/store/session/canvasRevisionDraftAtom"; + +import type { AgentWSEvent, StreamRefs } from "../../shared/types"; import type { EventHandlerContext } from "./types"; const STOPPED_TURNS_PER_SESSION_LIMIT = 20; @@ -125,6 +126,9 @@ export function resetAllStreamingState(ctx: EventHandlerContext): void { if (ctx.streamingCompleteHandledRef) { ctx.streamingCompleteHandledRef.current = false; } + const sessionId = ctx.filterSessionIdRef.current; + const store = ctx.getDefaultStore(); + if (sessionId && store) clearCanvasRevisionDraft(store, sessionId); } export function getToolCallId(event: AgentWSEvent): string | undefined { diff --git a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/toolHandlers.ts b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/toolHandlers.ts index c0abfee59..d20bd9c3c 100644 --- a/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/toolHandlers.ts +++ b/src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/toolHandlers.ts @@ -5,6 +5,11 @@ * Shell process / exec-output handlers live in shellHandlers.ts. */ import { switchModeForSession } from "@src/engines/ChatPanel/InputArea/ModeSwitchCard/useModeSwitchActions"; +import { + getCanvasRevisionTargetId, + isCanvasRevisionToolName, + isCanvasToolName, +} from "@src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevision"; import { openInSimulatorCanvas } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/openInSimulatorCanvas"; import type { CanvasInlineMode, @@ -12,6 +17,10 @@ import type { } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/types"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import { createLogger } from "@src/hooks/logger"; +import { + clearCanvasRevisionDraft, + markCanvasRevisionDraftApplying, +} from "@src/store/session/canvasRevisionDraftAtom"; import { clearMcpProgressForCallAtom } from "@src/store/session/mcpProgressAtom"; import { makeToolResultEvent } from "../../shared/eventBuilders"; @@ -83,6 +92,18 @@ export function handleToolCall( } } + if (isCanvasRevisionToolName(event.tool)) { + const store = ctx.getDefaultStore(); + if (store) { + markCanvasRevisionDraftApplying( + store, + sessionId, + toolCallId, + event.args ? JSON.stringify(event.args).length : 0 + ); + } + } + // Rust pushes the authoritative `tool-call-${toolCallId}` event into the // EventStore before broadcasting `agent:tool_call`. Do not synthesize or // upsert a duplicate frontend event here: a delayed broadcast handler can @@ -104,7 +125,7 @@ export function handleToolCall( // Dispatch canvas-inline-event from tool_call (not tool_result) so the // full args payload is available — tool_result only carries a 4000-char // preview of the result string, not the original args. - if (event.tool === "render_inline_canvas" && event.args) { + if (isCanvasToolName(event.tool) && event.args) { dispatchCanvasInlineEventFromArgs(sessionId, event.args, toolCallId); } } @@ -121,6 +142,7 @@ export async function handleToolResult( if (toolCallId) { const store = ctx.getDefaultStore(); if (store) { + clearCanvasRevisionDraft(store, sessionId, toolCallId); store.set(clearMcpProgressForCallAtom, { sessionId, toolCallId, @@ -199,7 +221,7 @@ function isCanvasInlineMode(value: unknown): value is CanvasInlineMode { } /** - * Dispatch a canvas-inline-event from a `render_inline_canvas` tool_call's + * Dispatch a canvas-inline-event from a Canvas create/revise tool_call's * args object. Reading from args (not the tool_result string) guarantees the * full content is available — the Rust broadcast truncates tool_result to * 4 000 chars, which would corrupt large HTML payloads. @@ -217,6 +239,7 @@ function dispatchCanvasInlineEventFromArgs( title: typeof args.title === "string" ? args.title : undefined, streaming: typeof args.streaming === "boolean" ? args.streaming : undefined, eventId: `tool-call-${toolCallId}`, + revisesEventId: getCanvasRevisionTargetId(args) ?? undefined, }; openInSimulatorCanvas(sessionId, payload); diff --git a/src/engines/SessionCore/sync/adapters/shared/__tests__/streamingParsers.test.ts b/src/engines/SessionCore/sync/adapters/shared/__tests__/streamingParsers.test.ts index 9d883280b..2cbe6beed 100644 --- a/src/engines/SessionCore/sync/adapters/shared/__tests__/streamingParsers.test.ts +++ b/src/engines/SessionCore/sync/adapters/shared/__tests__/streamingParsers.test.ts @@ -3,10 +3,61 @@ import { describe, expect, it } from "vitest"; import { buildToolArgsFromParsed, extractThinkContent, + parseCanvasRevisionDeltaMetadata, parsePartialToolArgs, stripThinkTags, } from "../streamingParsers"; +describe("parseCanvasRevisionDeltaMetadata", () => { + it("extracts complete metadata without decoding the streamed source", () => { + const parsed = parseCanvasRevisionDeltaMetadata( + '{"agent_steps":["替换按钮文案","核对原有交互"],"target_event_id":"tool-call-original","mode":"react","title":"Coffee \\"M\\"","content":"function App() {' + ); + + expect(parsed).toEqual({ + targetEventId: "tool-call-original", + mode: "react", + title: 'Coffee "M"', + agentSteps: ["替换按钮文案", "核对原有交互"], + }); + }); + + it("leaves fields undefined until their JSON strings close", () => { + expect( + parseCanvasRevisionDeltaMetadata( + '{"target_event_id":"tool-call-original","mode":"rea' + ) + ).toEqual({ + targetEventId: "tool-call-original", + mode: undefined, + title: undefined, + agentSteps: undefined, + }); + }); + + it("waits for the complete agent step array and handles escaped labels", () => { + expect( + parseCanvasRevisionDeltaMetadata('{"agent_steps":["替换\\"按钮') + .agentSteps + ).toBeUndefined(); + + expect( + parseCanvasRevisionDeltaMetadata( + '{"agent_steps":["替换\\"按钮","核对[交互]"]}' + ).agentSteps + ).toEqual(['替换"按钮', "核对[交互]"]); + }); + + it("finds late agent steps through the bounded suffix window", () => { + const largeContent = "x".repeat(20_000); + expect( + parseCanvasRevisionDeltaMetadata( + `{"content":"${largeContent}","agent_steps":["验证结果"]}` + ).agentSteps + ).toEqual(["验证结果"]); + }); +}); + describe("stripThinkTags", () => { it("removes a complete ... block", () => { const input = "beforesecretafter"; diff --git a/src/engines/SessionCore/sync/adapters/shared/streamingParsers.ts b/src/engines/SessionCore/sync/adapters/shared/streamingParsers.ts index f12627336..611ae0878 100644 --- a/src/engines/SessionCore/sync/adapters/shared/streamingParsers.ts +++ b/src/engines/SessionCore/sync/adapters/shared/streamingParsers.ts @@ -26,6 +26,92 @@ export interface PartialToolArgs { reason?: string; } +export interface CanvasRevisionDeltaMetadata { + targetEventId?: string; + mode?: string; + title?: string; + agentSteps?: unknown[]; +} + +const CANVAS_REVISION_METADATA_PREFIX_CHARS = 16_384; + +function parseCompleteJsonStringField( + jsonPrefix: string, + field: string +): string | undefined { + const match = jsonPrefix.match( + new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`) + ); + if (!match?.[1]) return undefined; + try { + return JSON.parse(`"${match[1]}"`) as string; + } catch { + return match[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + } +} + +function parseCompleteJsonArrayField( + jsonWindow: string, + field: string +): unknown[] | undefined { + const fieldMatch = new RegExp(`"${field}"\\s*:\\s*\\[`).exec(jsonWindow); + if (!fieldMatch) return undefined; + + const start = fieldMatch.index + fieldMatch[0].lastIndexOf("["); + let depth = 0; + let inString = false; + let escaped = false; + for (let index = start; index < jsonWindow.length; index += 1) { + const character = jsonWindow[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === '"') { + inString = false; + } + continue; + } + if (character === '"') { + inString = true; + } else if (character === "[") { + depth += 1; + } else if (character === "]") { + depth -= 1; + if (depth === 0) { + try { + const parsed = JSON.parse(jsonWindow.slice(start, index + 1)); + return Array.isArray(parsed) ? parsed : undefined; + } catch { + return undefined; + } + } + } + } + return undefined; +} + +/** + * Read only bounded metadata windows of a potentially megabyte-sized Canvas + * tool argument stream. The generated source itself is intentionally not + * decoded per token; a suffix window covers metadata emitted after content. + */ +export function parseCanvasRevisionDeltaMetadata( + argsJson: string +): CanvasRevisionDeltaMetadata { + const prefix = argsJson.slice(0, CANVAS_REVISION_METADATA_PREFIX_CHARS); + const suffix = argsJson.slice(-CANVAS_REVISION_METADATA_PREFIX_CHARS); + return { + targetEventId: parseCompleteJsonStringField(prefix, "target_event_id"), + mode: parseCompleteJsonStringField(prefix, "mode"), + title: parseCompleteJsonStringField(prefix, "title"), + agentSteps: + parseCompleteJsonArrayField(prefix, "agent_steps") ?? + parseCompleteJsonArrayField(suffix, "agent_steps"), + }; +} + /** * Mapping from PartialToolArgs keys to tool argument keys. * Used by buildToolArgsFromParsed to convert parsed args to event args. diff --git a/src/engines/Simulator/apps/canvas/CanvasApp.test.ts b/src/engines/Simulator/apps/canvas/CanvasApp.test.ts new file mode 100644 index 000000000..b149b1cd3 --- /dev/null +++ b/src/engines/Simulator/apps/canvas/CanvasApp.test.ts @@ -0,0 +1,417 @@ +// @vitest-environment jsdom +import { Profiler, type ReactNode, act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import type { CanvasRevisionDraft } from "@src/store/session/canvasRevisionDraftAtom"; + +import CanvasApp from "./CanvasApp"; + +const testState = vi.hoisted(() => ({ + appEvents: [] as SessionEvent[], + previewEventId: null as string | null, + revisionDraft: null as CanvasRevisionDraft | null, + publishedHeader: null as ReactNode, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + }), +})); +vi.mock("lucide-react", () => ({ + Layout: () => null, + PenTool: () => null, + RefreshCw: () => null, +})); +vi.mock("jotai", () => ({ + useAtomValue: (atom: string) => { + if (atom === "canvas-preview") { + return testState.previewEventId + ? { payload: { eventId: testState.previewEventId } } + : null; + } + if (atom === "sidebar-collapsed") return false; + if (atom === "sidebar-position") return "left"; + if (atom === "sidebar-width") return 240; + return null; + }, + useSetAtom: () => vi.fn(), +})); +vi.mock("@src/store/session/canvasPreviewAtom", () => ({ + canvasPreviewAtom: "canvas-preview", +})); +vi.mock("@src/engines/SessionCore", () => ({ + useCanvasRevisionDraftForSession: () => testState.revisionDraft, +})); +vi.mock( + "@src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionProgress", + () => ({ + default: ({ draft }: { draft: CanvasRevisionDraft }) => + createElement("div", { + "data-testid": "canvas-revision-progress", + "data-phase": draft.phase, + }), + }) +); +vi.mock("@src/store/ui/simulatorAtom", () => ({ + simulatorPrimarySidebarCollapsedAtom: "sidebar-collapsed", + simulatorPrimarySidebarPositionAtom: "sidebar-position", + simulatorPrimarySidebarWidthAtom: "sidebar-width", + simulatorPrimarySidebarWidthPersistAtom: "sidebar-width-persist", +})); +vi.mock("../core/useSimulatorAppState", () => ({ + useSimulatorAppState: () => ({ appEvents: testState.appEvents }), +})); +vi.mock("./canvasConfig", () => ({ CANVAS_APP_CONFIG: {} })); +vi.mock("@src/hooks/workStation", () => ({ + usePublishWorkstationTabHeader: ({ content }: { content: ReactNode }) => { + testState.publishedHeader = content; + }, +})); +vi.mock("@src/components/WindowChrome", () => ({ + NoDragRegion: ({ children }: { children?: ReactNode }) => + createElement("div", null, children), +})); +vi.mock("@src/components/DiffStatsBadge", () => ({ default: () => null })); +vi.mock("@src/components/Button", () => ({ + default: ({ + children, + htmlType: type, + ...props + }: { htmlType?: "button" } & React.ComponentProps<"button">) => + createElement("button", { type, ...props }, children), +})); +vi.mock("@src/components/IconButton", () => ({ + default: ({ children, ...props }: React.ComponentProps<"button">) => + createElement("button", props, children), +})); +vi.mock("@src/components/TabPill", () => ({ + default: ({ + tabs, + activeTab, + onChange, + }: { + tabs: string[]; + activeTab: string; + onChange: (tab: string) => void; + }) => + createElement( + "div", + { "data-testid": "canvas-tabs", "data-active-tab": activeTab }, + tabs.map((tab) => + createElement("button", { + key: tab, + "data-testid": `tab-${tab}`, + onClick: () => onChange(tab), + }) + ) + ), +})); +vi.mock("./design/CanvasDesignSurface", () => ({ + default: ({ + payload, + reloadKey, + designEnabled, + sessionId, + }: { + payload: { content?: string }; + reloadKey: number; + designEnabled: boolean; + sessionId: string; + }) => + createElement("div", { + "data-testid": "canvas-preview-surface", + "data-content": payload.content, + "data-reload-key": reloadKey, + "data-design-enabled": String(designEnabled), + "data-session-id": sessionId, + }), +})); +vi.mock( + "@src/modules/WorkStation/CodeEditor/SessionReplay/CodePanel/SessionReplayCodeMirrorViewer", + () => ({ + SessionReplayCodeMirrorViewer: ({ content }: { content: string }) => + createElement("div", { + "data-testid": "canvas-source", + "data-content": content, + }), + }) +); +vi.mock("@src/modules/shared/layouts/blocks", () => ({ + Placeholder: ({ title }: { title: string }) => + createElement("div", { "data-testid": "placeholder" }, title), +})); +vi.mock("@src/modules/WorkStation/shared", () => ({ + buildPrimarySidebarConfig: (config: unknown) => config, + PrimarySidebarLayoutWithSections: ({ + tabs, + }: { + tabs: Array<{ sections: Array<{ content: ReactNode }> }>; + }) => tabs[0]?.sections[0]?.content ?? null, + SimulatorReplayChrome: ({ + activeEventId, + children, + }: { + activeEventId: string; + children?: ReactNode; + }) => + createElement( + "div", + { "data-testid": "simulator-chrome", "data-active-id": activeEventId }, + testState.publishedHeader, + children + ), + WorkStationShell: ({ + primarySidebarConfig, + content, + }: { + primarySidebarConfig: { content: ReactNode }; + content: ReactNode; + }) => + createElement( + "div", + null, + createElement("aside", null, primarySidebarConfig.content), + createElement("main", null, content) + ), + WorkstationToolbarTooltip: ({ children }: { children?: ReactNode }) => + children ?? null, +})); + +function canvasEvent(id: string): SessionEvent { + return { + id, + sessionId: `session-${id}`, + functionName: "render_inline_canvas", + displayStatus: "completed", + args: { mode: "html", content: `content-${id}`, title: id }, + } as unknown as SessionEvent; +} + +function canvasRevision(id: string, revisesEventId: string): SessionEvent { + const event = canvasEvent(id); + event.sessionId = `session-${revisesEventId}`; + event.functionName = "revise_inline_canvas"; + event.args.target_event_id = revisesEventId; + return event; +} + +describe("CanvasApp interaction lifecycle", () => { + let container: HTMLDivElement; + let root: Root; + let commitCount: number; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + testState.appEvents = []; + testState.previewEventId = null; + testState.revisionDraft = null; + testState.publishedHeader = null; + commitCount = 0; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + function render() { + act(() => { + root.render( + createElement( + Profiler, + { + id: "canvas-app", + onRender: () => { + commitCount += 1; + }, + }, + createElement(CanvasApp, { + state: { + currentEventId: null, + appEvents: [], + selectedItemId: null, + isReplaying: false, + }, + currentEvent: null, + selectedItemId: null, + onSelectItem: vi.fn(), + }) + ) + ); + }); + } + + function previewSurface() { + return container.querySelector( + "[data-testid='canvas-preview-surface']" + ); + } + + function buttonWithText(text: string) { + return [...container.querySelectorAll("button")].find( + (button) => button.textContent?.includes(text) + ); + } + + it("commits a pending chat preview in the same render that hydrates it", () => { + testState.previewEventId = "a"; + render(); + expect( + container.querySelector("[data-testid='placeholder']") + ).not.toBeNull(); + + testState.appEvents = [canvasEvent("a"), canvasEvent("b")]; + commitCount = 0; + render(); + + expect( + container.querySelector("[data-testid='simulator-chrome']") + ?.dataset.activeId + ).toBe("a"); + expect(previewSurface()?.dataset.content).toBe("content-a"); + expect(previewSurface()?.dataset.reloadKey).toBe("1"); + expect(commitCount).toBe(1); + }); + + it("handles selection and compare transitions without Effect commits", () => { + testState.appEvents = [canvasEvent("a"), canvasEvent("b")]; + render(); + expect(previewSurface()?.dataset.content).toBe("content-b"); + expect(previewSurface()?.dataset.reloadKey).toBe("1"); + + commitCount = 0; + act(() => buttonWithText("a")?.click()); + expect(previewSurface()?.dataset.content).toBe("content-a"); + expect(previewSurface()?.dataset.reloadKey).toBe("2"); + expect(commitCount).toBe(1); + + const compareButtons = () => + [...container.querySelectorAll("button")].filter( + (button) => button.title === "Compare" + ); + + act(() => compareButtons()[0]?.click()); + commitCount = 0; + act(() => compareButtons()[1]?.click()); + expect( + container.querySelector("[data-testid='canvas-tabs']") + ?.dataset.activeTab + ).toBe("compare"); + expect(container.textContent).toContain("content-a"); + expect(container.textContent).toContain("content-b"); + expect(commitCount).toBe(1); + + commitCount = 0; + act(() => compareButtons()[1]?.click()); + expect( + container.querySelector("[data-testid='canvas-tabs']") + ?.dataset.activeTab + ).toBe("canvas"); + expect(previewSurface()?.dataset.content).toBe("content-a"); + expect(commitCount).toBe(1); + }); + + it("scopes Design mode to the selected Canvas event", () => { + testState.appEvents = [canvasEvent("a")]; + render(); + + expect(previewSurface()?.dataset.designEnabled).toBe("false"); + act(() => buttonWithText("Design")?.click()); + expect(previewSurface()?.dataset.designEnabled).toBe("true"); + + testState.appEvents = [canvasEvent("a"), canvasEvent("b")]; + render(); + expect(previewSurface()?.dataset.content).toBe("content-b"); + expect(previewSurface()?.dataset.designEnabled).toBe("false"); + expect(previewSurface()?.dataset.sessionId).toBe("session-b"); + }); + + it("keeps the last valid preview visible while a revision streams", () => { + testState.appEvents = [canvasEvent("original")]; + testState.revisionDraft = { + sessionId: "session-original", + toolCallId: "revision-a", + targetEventId: "original", + receivedCharacters: 1_200, + phase: "receiving", + startedAt: 1, + }; + + render(); + + expect(previewSurface()?.dataset.content).toBe("content-original"); + expect( + container.querySelector("[data-testid='canvas-revision-progress']") + ).not.toBeNull(); + expect(buttonWithText("Design")?.disabled).toBe(true); + + testState.revisionDraft = null; + render(); + expect( + container.querySelector("[data-testid='canvas-revision-progress']") + ).toBeNull(); + expect(buttonWithText("Design")?.disabled).toBe(false); + }); + + it("shows a Design result as the latest version of the original Canvas", () => { + testState.appEvents = [canvasEvent("original")]; + render(); + expect(container.querySelectorAll("aside button")).toHaveLength(2); + expect(previewSurface()?.dataset.content).toBe("content-original"); + + testState.previewEventId = "revision"; + testState.appEvents = [ + canvasEvent("original"), + canvasRevision("revision", "original"), + ]; + render(); + + expect(container.querySelectorAll("aside button")).toHaveLength(2); + expect(previewSurface()?.dataset.content).toBe("content-revision"); + expect( + container.querySelector("[data-testid='simulator-chrome']") + ?.dataset.activeId + ).toBe("revision"); + }); + + it("restores the last valid Canvas when the persisted revision failed", () => { + const failedRevision = canvasRevision("failed-revision", "original"); + failedRevision.displayStatus = "failed"; + testState.previewEventId = "failed-revision"; + testState.appEvents = [canvasEvent("original"), failedRevision]; + + render(); + + expect(container.querySelectorAll("aside button")).toHaveLength(2); + expect(previewSurface()?.dataset.content).toBe("content-original"); + expect( + container.querySelector("[data-testid='simulator-chrome']") + ?.dataset.activeId + ).toBe("original"); + }); +}); diff --git a/src/engines/Simulator/apps/canvas/CanvasApp.tsx b/src/engines/Simulator/apps/canvas/CanvasApp.tsx index f4d7c0d60..b587de655 100644 --- a/src/engines/Simulator/apps/canvas/CanvasApp.tsx +++ b/src/engines/Simulator/apps/canvas/CanvasApp.tsx @@ -16,23 +16,20 @@ * - Source tab shows raw JSONL/HTML in a
 block
  */
 import { useAtomValue, useSetAtom } from "jotai";
-import { Layout, RefreshCw } from "lucide-react";
-import React, {
-  useCallback,
-  useEffect,
-  useMemo,
-  useRef,
-  useState,
-} from "react";
+import { Layout, PenTool, RefreshCw } from "lucide-react";
+import React, { useCallback, useMemo, useState } from "react";
 import { useTranslation } from "react-i18next";
 
+import Button from "@src/components/Button";
 import DiffStatsBadge from "@src/components/DiffStatsBadge";
 import IconButton from "@src/components/IconButton";
 import TabPill from "@src/components/TabPill";
 import { NoDragRegion } from "@src/components/WindowChrome";
 import { SIMULATOR_PRIMARY_SIDEBAR } from "@src/config/simulatorPrimarySidebar";
-import CanvasPreviewSurface from "@src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasPreviewSurface";
+import CanvasRevisionProgress from "@src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasRevisionProgress";
+import { isCanvasRevisionDraftRelevant } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevisionProgressState";
 import type { CanvasInlineMode } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/types";
+import { useCanvasRevisionDraftForSession } from "@src/engines/SessionCore";
 import type { SessionEvent } from "@src/engines/SessionCore/core/types";
 import { usePublishWorkstationTabHeader } from "@src/hooks/workStation";
 import { SessionReplayCodeMirrorViewer } from "@src/modules/WorkStation/CodeEditor/SessionReplay/CodePanel/SessionReplayCodeMirrorViewer";
@@ -40,6 +37,7 @@ import {
   PrimarySidebarLayoutWithSections,
   SimulatorReplayChrome,
   WorkStationShell,
+  WorkstationToolbarTooltip,
   buildPrimarySidebarConfig,
 } from "@src/modules/WorkStation/shared";
 import type { PrimarySidebarTab } from "@src/modules/WorkStation/shared/PrimarySidebarLayout/PrimarySidebarLayoutWithSections";
@@ -55,11 +53,20 @@ import {
 import type { SimulatorAppProps } from "../core/types";
 import { useSimulatorAppState } from "../core/useSimulatorAppState";
 import { CANVAS_APP_CONFIG } from "./canvasConfig";
+import {
+  type CanvasViewTab,
+  createCanvasInteractionState,
+  reconcileCanvasInteractionState,
+  reloadCanvas,
+  selectCanvasEvent,
+  setCanvasViewTab,
+  toggleCanvasComparison,
+} from "./canvasInteractionState";
+import { projectLatestCanvasEvents } from "./canvasRevisionProjection";
+import CanvasDesignSurface from "./design/CanvasDesignSurface";
 
 // ─── types ────────────────────────────────────────────────────────────────────
 
-type ViewTab = "canvas" | "source" | "compare";
-
 interface CanvasPayload {
   mode: CanvasInlineMode;
   content?: string;
@@ -391,30 +398,31 @@ interface CanvasIframeProps {
   payload: CanvasPayload;
   reloadKey: number;
   title: string;
+  eventId: string;
+  sessionId: string;
+  designEnabled: boolean;
+  onRequestDisableDesign: () => void;
 }
 
 const CanvasIframe: React.FC = ({
   payload,
   reloadKey,
   title,
+  eventId,
+  sessionId,
+  designEnabled,
+  onRequestDisableDesign,
 }) => {
-  const { t } = useTranslation("sessions");
-
   return (
-    
-          
-            {payload.streaming
-              ? t("canvasCard.waiting", "Waiting for content…")
-              : t("canvasCard.empty", "No content")}
-          
-        
- } + title={title} + eventId={eventId} + sessionId={sessionId} + designEnabled={designEnabled} + onRequestDisable={onRequestDisableDesign} /> ); }; @@ -422,12 +430,15 @@ const CanvasIframe: React.FC = ({ // ─── tab header content ─────────────────────────────────────────────────────── interface CanvasTabHeaderProps { - tab: ViewTab; - onSetTab: (tab: ViewTab) => void; + tab: CanvasViewTab; + onSetTab: (tab: CanvasViewTab) => void; title: string; isStreaming: boolean; onReload: () => void; showCompare: boolean; + designAvailable: boolean; + designEnabled: boolean; + onToggleDesign: () => void; } const CanvasTabHeader: React.FC = ({ @@ -437,10 +448,13 @@ const CanvasTabHeader: React.FC = ({ isStreaming, onReload, showCompare, + designAvailable, + designEnabled, + onToggleDesign, }) => { const { t } = useTranslation("sessions"); - const tabs: ViewTab[] = showCompare + const tabs: CanvasViewTab[] = showCompare ? ["canvas", "source", "compare"] : ["canvas", "source"]; @@ -458,13 +472,35 @@ const CanvasTabHeader: React.FC = ({ )}
+ {tab === "canvas" && ( + + + + )} onSetTab(key as ViewTab)} + onChange={(key) => onSetTab(key as CanvasViewTab)} /> {tab === "canvas" && !isStreaming && ( = ({ const CanvasApp: React.FC = () => { const { t } = useTranslation("sessions"); - const { appEvents } = useSimulatorAppState({ + const { appEvents: canvasRenderEvents } = useSimulatorAppState({ config: CANVAS_APP_CONFIG as never, }); + const appEvents = useMemo( + () => projectLatestCanvasEvents(canvasRenderEvents), + [canvasRenderEvents] + ); const canvasPreviewEntry = useAtomValue(canvasPreviewAtom); @@ -512,41 +552,37 @@ const CanvasApp: React.FC = () => { // ── selection state ────────────────────────────────────────────────────── - const [selectedEventId, setSelectedEventId] = useState(null); - const [compareEventIds, setCompareEventIds] = useState([]); - const prevEventCountRef = useRef(0); - - // Auto-advance to the latest event when a new one arrives - useEffect(() => { - if (appEvents.length === 0) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setSelectedEventId(null); - prevEventCountRef.current = 0; - return; - } - if (appEvents.length > prevEventCountRef.current) { - prevEventCountRef.current = appEvents.length; - // eslint-disable-next-line react-hooks/set-state-in-effect - setSelectedEventId(appEvents[appEvents.length - 1].id); - } - }, [appEvents]); - - // Jump to matching event when canvasPreviewAtom changes (chat card click) - useEffect(() => { - const eventId = canvasPreviewEntry?.payload.eventId; - if (!eventId) return; - if (appEvents.some((event) => event.id === eventId)) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setSelectedEventId(eventId); - } - }, [canvasPreviewEntry?.payload.eventId, appEvents]); + const appEventIds = useMemo( + () => appEvents.map((event) => event.id), + [appEvents] + ); + const previewEventId = canvasPreviewEntry?.payload.eventId ?? null; + const [interactionState, setInteractionState] = useState(() => + createCanvasInteractionState(appEventIds, previewEventId) + ); + const [designEventId, setDesignEventId] = useState(null); + + // React's render-time adjustment pattern keeps external event/preview facts + // and the committed UI in the same render, without a cascading Effect pass. + const reconciledInteractionState = reconcileCanvasInteractionState( + interactionState, + appEventIds, + previewEventId + ); + if (reconciledInteractionState !== interactionState) { + setInteractionState(reconciledInteractionState); + } + + const { selectedEventId, compareEventIds, activeTab, reloadKey } = + reconciledInteractionState; + + const handleSelect = useCallback((id: string) => { + setInteractionState((state) => selectCanvasEvent(state, id)); + }, []); const handleCompareToggle = useCallback((id: string) => { - setCompareEventIds((prev) => { - if ((prev as string[]).includes(id)) return prev.filter((x) => x !== id); - if (prev.length >= 2) return [prev[1], id]; - return [...prev, id]; - }); + setDesignEventId(null); + setInteractionState((state) => toggleCanvasComparison(state, id)); }, []); const selectedEvent = useMemo( @@ -558,6 +594,17 @@ const CanvasApp: React.FC = () => { () => (selectedEvent ? extractPayload(selectedEvent) : null), [selectedEvent] ); + const activeSessionId = + selectedEvent?.sessionId ?? canvasPreviewEntry?.sessionId ?? null; + const revisionDraftCandidate = + useCanvasRevisionDraftForSession(activeSessionId); + const revisionDraft = isCanvasRevisionDraftRelevant( + revisionDraftCandidate, + activeSessionId, + selectedEventId + ) + ? revisionDraftCandidate + : null; // Compare payloads (only valid when exactly 2 are selected) const comparePayloads = useMemo(() => { @@ -587,39 +634,35 @@ const CanvasApp: React.FC = () => { }; }, [compareEventIds, appEvents, t]); - // ── tab + reload state ─────────────────────────────────────────────────── - - const [activeTab, setActiveTab] = useState("canvas"); - const [reloadKey, setReloadKey] = useState(0); - - // Reset reload key and tab when selection changes - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setReloadKey((k) => k + 1); - // eslint-disable-next-line react-hooks/set-state-in-effect - setActiveTab("canvas"); - }, [selectedEventId]); - - // Auto-switch to compare tab when 2 items are selected - useEffect(() => { - if (compareEventIds.length === 2) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setActiveTab("compare"); - } else if (activeTab === "compare") { - // eslint-disable-next-line react-hooks/set-state-in-effect - setActiveTab("canvas"); - } - // activeTab intentionally omitted — only react to compareEventIds changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [compareEventIds]); + const handleSetTab = useCallback((tab: CanvasViewTab) => { + if (tab !== "canvas") setDesignEventId(null); + setInteractionState((state) => setCanvasViewTab(state, tab)); + }, []); const handleReload = useCallback(() => { - setReloadKey((k) => k + 1); + setInteractionState(reloadCanvas); }, []); const cardTitle = selectedPayload ? getDefaultTitle(selectedPayload, t) : t("canvasCard.titleHtml", "Agent Preview"); + const designAvailable = + activeTab === "canvas" && + selectedPayload !== null && + selectedPayload.mode !== "url" && + !selectedPayload.streaming && + revisionDraft === null; + const designEnabled = + designAvailable && + selectedEventId !== null && + designEventId === selectedEventId; + const handleToggleDesign = useCallback(() => { + if (!selectedEventId) return; + setDesignEventId((current) => + current === selectedEventId ? null : selectedEventId + ); + }, [selectedEventId]); + const handleDisableDesign = useCallback(() => setDesignEventId(null), []); // ── publish to SimulatorWorkstationTabHeader ───────────────────────────── @@ -628,21 +671,28 @@ const CanvasApp: React.FC = () => { appEvents.length > 0 && selectedPayload ? ( ) : null, - // eslint-disable-next-line react-hooks/exhaustive-deps [ appEvents.length, selectedPayload, activeTab, cardTitle, + handleSetTab, handleReload, compareEventIds.length, + designAvailable, + designEnabled, + handleToggleDesign, + revisionDraft, ] ); @@ -662,7 +712,7 @@ const CanvasApp: React.FC = () => { appEvents={appEvents} selectedEventId={selectedEventId} compareEventIds={compareEventIds} - onSelect={setSelectedEventId} + onSelect={handleSelect} onCompareToggle={handleCompareToggle} t={t} /> @@ -681,6 +731,7 @@ const CanvasApp: React.FC = () => { primarySidebarCollapsed, primarySidebarWidth, handlePrimarySidebarWidthChange, + handleSelect, handleCompareToggle, t, ] @@ -711,19 +762,28 @@ const CanvasApp: React.FC = () => { olderTitle={comparePayloads.olderTitle} newerTitle={comparePayloads.newerTitle} /> - ) : activeTab === "canvas" ? ( + ) : activeTab === "canvas" && selectedEvent ? ( <> - {selectedPayload.streaming && ( + {(selectedPayload.streaming || revisionDraft) && (
)} + {revisionDraft && ( +
+ +
+ )} ) : ( /* source tab */ diff --git a/src/engines/Simulator/apps/canvas/__tests__/TEST_CASES.md b/src/engines/Simulator/apps/canvas/__tests__/TEST_CASES.md new file mode 100644 index 000000000..553161ec2 --- /dev/null +++ b/src/engines/Simulator/apps/canvas/__tests__/TEST_CASES.md @@ -0,0 +1,68 @@ +# Test Cases: Canvas Revision Streaming + +## Preconditions + +- A session contains at least one valid `render_inline_canvas` event. +- The Canvas app is open on that event and the agent supports tool-call deltas. + +## Happy Path + +| # | Steps | Expected Result | +| --- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | Select an element in Design mode and request a localized copy/style change | The existing Canvas remains visible and a live “Updating Canvas” status appears as revision arguments arrive. | +| 2 | Let a compact `edits` revision finish | The exact source match changes in the existing logical Canvas; unrelated source and UI state remain intact. | +| 3 | Request a structural change that uses complete replacement content | The existing full-replacement revision path still updates the same logical Canvas. | +| 4 | Observe the chat after either revision completes | A persistent “Updated Canvas” activity remains with locate, generate, and apply/validate steps; no duplicate Canvas preview is added. | +| 5 | Select a Canvas element and open the contextual composer | The shared compact capsule appears in one row with the selected-element pill, editor, model controls, microphone, and send action. | +| 6 | Click the completed “Updated Canvas” activity header or its navigate icon | Replay locates that revision event, Agent Station opens the Canvas app, and the corresponding logical Canvas is selected at its latest materialized version. | +| 7 | Hover the selected-element reference and click it | Its pointer icon changes to the shared editor-pill close icon; activating it clears the selection and closes the contextual composer. | + +## Edge Cases + +| # | Scenario | Steps | Expected Result | +| --- | --------------------------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Metadata is incomplete | Observe the first tool-call delta before `target_event_id` closes | Generic Canvas progress appears without rendering incomplete React source. | +| 2 | Ambiguous exact match | Send an edit whose `find` occurs twice without `all=true` | Backend rejects the revision and the last valid Canvas remains visible. | +| 3 | Deliberate replace all | Send an edit with `all=true` | Every exact occurrence changes and no other text is modified. | +| 4 | Rapid delta burst | Stream many argument fragments | UI updates are coalesced to at most 20Hz; the final character count is not lost. | +| 5 | Newer operation supersedes old terminal | Start a new revision before a late terminal from the previous id | The stale terminal cannot clear the newer progress state. | +| 6 | Provider emits no reasoning stream | Complete a short revision with no thinking event | The UI shows factual Canvas work steps but does not invent or label them as private model reasoning. | +| 7 | Selection reference with an empty draft | Open the contextual composer and do not type | The reference remains visual-only; the input is still logically empty and the placeholder remains visible. | +| 8 | Revision activity missing an event id | Render a legacy/incomplete activity without a stable event id | The activity remains readable but has no pointer cursor or navigate affordance; no navigation is attempted. | +| 9 | Narrow chat column | Render a revision with a long Canvas title and change summary | The activity title and summary stay within the row, truncate with an ellipsis, and retain native hover text. | +| 10 | Multiline Design instruction | Type a newline or enough structured content to make the editor multiline | The shared composer expands out of the capsule without remounting the editor or losing the selection reference, caret, or draft. | +| 11 | Wide Canvas viewport | Select an element in a Canvas wider than the Design composer | The composer stays centered at no more than 640px, and only the shared `ComposerShell` paints its background, border, and radius. | + +## Error / Degraded States + +| # | Scenario | Steps | Expected Result | +| --- | ------------------------------ | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| 1 | User stops the turn | Stop while revision arguments are streaming | Progress clears and the previous valid Canvas remains unchanged. | +| 2 | Tool validation fails | Use stale source text or change rendering mode through `edits` | Failed revision is shown in chat; Simulator keeps the previous valid Canvas. | +| 3 | Session switches or is deleted | Leave/remove the session during generation | Pending timer and session-scoped draft state are released; no progress leaks into another session. | + +## Accessibility + +- [ ] Progress uses `role="status"` and polite live announcements. +- [ ] Completed work steps remain readable without relying on color alone. +- [ ] Progress is readable in light and dark themes. +- [ ] Reduced-motion disables the spinner animation. +- [ ] Design controls remain visible but disabled while a revision is active. +- [ ] The selected-element reference and editor text share one visual line at normal zoom and remain readable in both themes. +- [ ] Focus order across the compact Design composer follows selected reference, editor, model controls, microphone, and send action. +- [ ] The selected-element reference is keyboard operable and exposes a clear-selection accessible name; hover changes its icon to a close glyph. +- [ ] The Canvas revision activity exposes the shared navigate affordance on hover, and the full header hit area opens the same destination. + +## Acceptance Criteria + +- [ ] Canvas revision progress appears on the first identifiable tool-call delta. +- [ ] Incomplete React/HTML source is never rendered over the last valid Canvas. +- [ ] Localized changes can use compact exact edits instead of a full Canvas payload. +- [ ] Full replacement revisions remain backward compatible. +- [ ] Final, failed, cancelled, switched, and deleted sessions release transient revision state. +- [ ] A completed or failed revision keeps one persistent work record in chat history without a second Canvas card. +- [ ] Canvas selection context is rendered as an inline editor adornment and is not duplicated into the typed instruction. +- [ ] A single-line Design prompt uses the shared compact `ComposerShell`/`ComposerBar` layout and expands safely for multiline input. +- [ ] The Design prompt has one visual shell, no mismatched rounded background behind it, and never exceeds 640px. +- [ ] Long Canvas revision titles and summaries cannot overflow the chat activity row. +- [ ] Completed, running, failed, and chained revision events navigate through the existing replay/Canvas projection path; missing event ids remain inert. diff --git a/src/engines/Simulator/apps/canvas/canvasInteractionState.test.ts b/src/engines/Simulator/apps/canvas/canvasInteractionState.test.ts new file mode 100644 index 000000000..a3a46acaf --- /dev/null +++ b/src/engines/Simulator/apps/canvas/canvasInteractionState.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { + createCanvasInteractionState, + reconcileCanvasInteractionState, + selectCanvasEvent, + setCanvasViewTab, + toggleCanvasComparison, +} from "./canvasInteractionState"; + +describe("canvas interaction state", () => { + it("selects the latest event initially and lets a valid preview win", () => { + expect(createCanvasInteractionState(["a", "b"], null)).toMatchObject({ + selectedEventId: "b", + activeTab: "canvas", + reloadKey: 1, + observedEventCount: 2, + }); + + expect(createCanvasInteractionState(["a", "b"], "a")).toMatchObject({ + selectedEventId: "a", + reloadKey: 1, + }); + }); + + it("honors a pending preview when its event hydrates without double reloading", () => { + const pending = createCanvasInteractionState([], "a"); + const hydrated = reconcileCanvasInteractionState(pending, ["a", "b"], "a"); + + expect(hydrated).toMatchObject({ + selectedEventId: "a", + activeTab: "canvas", + reloadKey: 1, + observedEventCount: 2, + }); + expect(reconcileCanvasInteractionState(hydrated, ["a", "b"], "a")).toBe( + hydrated + ); + }); + + it("retains selection across a partial shrink and follows events after a full clear", () => { + const initial = createCanvasInteractionState(["a", "b", "c"], null); + const selected = selectCanvasEvent(initial, "a"); + expect(selectCanvasEvent(selected, "a")).toBe(selected); + const shrunk = reconcileCanvasInteractionState(selected, ["a"], null); + + expect(shrunk).toMatchObject({ + selectedEventId: "a", + observedEventCount: 3, + reloadKey: 2, + }); + + const cleared = reconcileCanvasInteractionState(shrunk, [], null); + const repopulated = reconcileCanvasInteractionState(cleared, ["d"], null); + expect(repopulated).toMatchObject({ + selectedEventId: "d", + observedEventCount: 1, + reloadKey: 4, + }); + }); + + it("moves into and out of compare in the compare-toggle transition", () => { + const initial = setCanvasViewTab( + createCanvasInteractionState(["a", "b"], null), + "source" + ); + const one = toggleCanvasComparison(initial, "a"); + const two = toggleCanvasComparison(one, "b"); + const backToOne = toggleCanvasComparison(two, "b"); + + expect(one.activeTab).toBe("source"); + expect(two).toMatchObject({ + compareEventIds: ["a", "b"], + activeTab: "compare", + }); + expect(backToOne).toMatchObject({ + compareEventIds: ["a"], + activeTab: "canvas", + }); + }); + + it("follows a same-slot Canvas revision and removes stale comparisons", () => { + const initial = toggleCanvasComparison( + toggleCanvasComparison( + createCanvasInteractionState(["original", "other"], null), + "original" + ), + "other" + ); + const selectedOriginal = selectCanvasEvent(initial, "original"); + const revised = reconcileCanvasInteractionState( + selectedOriginal, + ["revision", "other"], + "revision" + ); + + expect(revised).toMatchObject({ + selectedEventId: "revision", + compareEventIds: ["other"], + activeTab: "canvas", + reloadKey: selectedOriginal.reloadKey + 1, + }); + }); +}); diff --git a/src/engines/Simulator/apps/canvas/canvasInteractionState.ts b/src/engines/Simulator/apps/canvas/canvasInteractionState.ts new file mode 100644 index 000000000..0722a3400 --- /dev/null +++ b/src/engines/Simulator/apps/canvas/canvasInteractionState.ts @@ -0,0 +1,147 @@ +export type CanvasViewTab = "canvas" | "source" | "compare"; + +export interface CanvasInteractionState { + selectedEventId: string | null; + compareEventIds: string[]; + activeTab: CanvasViewTab; + reloadKey: number; + observedEventCount: number; + observedEventIdsKey: string; + observedPreviewEventId: string | null; +} + +function eventIdsKey(eventIds: readonly string[]): string { + return JSON.stringify(eventIds); +} + +function preferredEventId( + eventIds: readonly string[], + previewEventId: string | null +): string | null { + if (previewEventId && eventIds.includes(previewEventId)) { + return previewEventId; + } + return eventIds.at(-1) ?? null; +} + +export function createCanvasInteractionState( + eventIds: readonly string[], + previewEventId: string | null +): CanvasInteractionState { + const selectedEventId = preferredEventId(eventIds, previewEventId); + return { + selectedEventId, + compareEventIds: [], + activeTab: "canvas", + reloadKey: selectedEventId === null ? 0 : 1, + observedEventCount: eventIds.length, + observedEventIdsKey: eventIdsKey(eventIds), + observedPreviewEventId: previewEventId, + }; +} + +/** + * Reconcile external event/preview facts before children commit. + * + * Selection priority intentionally matches the previous Effect ordering: + * a growing event list follows the newest event, then a valid chat preview + * overrides it. A non-empty shrinking list retains the user's selection until + * the list is fully cleared. + */ +export function reconcileCanvasInteractionState( + state: CanvasInteractionState, + eventIds: readonly string[], + previewEventId: string | null +): CanvasInteractionState { + const nextEventIdsKey = eventIdsKey(eventIds); + if ( + state.observedEventIdsKey === nextEventIdsKey && + state.observedPreviewEventId === previewEventId + ) { + return state; + } + + let selectedEventId = state.selectedEventId; + let observedEventCount = state.observedEventCount; + const validEventIds = new Set(eventIds); + const compareEventIds = state.compareEventIds.filter((eventId) => + validEventIds.has(eventId) + ); + + if (eventIds.length === 0) { + selectedEventId = null; + observedEventCount = 0; + } else if (eventIds.length > observedEventCount) { + selectedEventId = eventIds[eventIds.length - 1]; + observedEventCount = eventIds.length; + } else if (selectedEventId && !validEventIds.has(selectedEventId)) { + selectedEventId = preferredEventId(eventIds, previewEventId); + } + + if (previewEventId && eventIds.includes(previewEventId)) { + selectedEventId = previewEventId; + } + + const selectionChanged = selectedEventId !== state.selectedEventId; + const comparisonChanged = + compareEventIds.length !== state.compareEventIds.length; + return { + ...state, + selectedEventId, + compareEventIds, + activeTab: + selectionChanged || (comparisonChanged && compareEventIds.length !== 2) + ? "canvas" + : state.activeTab, + reloadKey: selectionChanged ? state.reloadKey + 1 : state.reloadKey, + observedEventCount, + observedEventIdsKey: nextEventIdsKey, + observedPreviewEventId: previewEventId, + }; +} + +export function selectCanvasEvent( + state: CanvasInteractionState, + eventId: string +): CanvasInteractionState { + if (eventId === state.selectedEventId) return state; + return { + ...state, + selectedEventId: eventId, + activeTab: "canvas", + reloadKey: state.reloadKey + 1, + }; +} + +export function toggleCanvasComparison( + state: CanvasInteractionState, + eventId: string +): CanvasInteractionState { + const compareEventIds = state.compareEventIds.includes(eventId) + ? state.compareEventIds.filter((id) => id !== eventId) + : state.compareEventIds.length >= 2 + ? [state.compareEventIds[1], eventId] + : [...state.compareEventIds, eventId]; + + const activeTab = + compareEventIds.length === 2 + ? "compare" + : state.activeTab === "compare" + ? "canvas" + : state.activeTab; + + return { ...state, compareEventIds, activeTab }; +} + +export function setCanvasViewTab( + state: CanvasInteractionState, + activeTab: CanvasViewTab +): CanvasInteractionState { + return activeTab === state.activeTab ? state : { ...state, activeTab }; +} + +export function reloadCanvas( + state: CanvasInteractionState +): CanvasInteractionState { + return { ...state, reloadKey: state.reloadKey + 1 }; +} diff --git a/src/engines/Simulator/apps/canvas/canvasRevisionProjection.test.ts b/src/engines/Simulator/apps/canvas/canvasRevisionProjection.test.ts new file mode 100644 index 000000000..5130e2be1 --- /dev/null +++ b/src/engines/Simulator/apps/canvas/canvasRevisionProjection.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { projectLatestCanvasEvents } from "./canvasRevisionProjection"; + +function canvasEvent( + id: string, + options: { + sessionId?: string; + revises?: string; + content?: string; + status?: "completed" | "failed"; + legacyRevision?: boolean; + edits?: Array<{ find: string; replace: string; all?: boolean }>; + } = {} +): SessionEvent { + return { + id, + sessionId: options.sessionId ?? "session-a", + functionName: options.revises + ? options.legacyRevision + ? "render_inline_canvas" + : "revise_inline_canvas" + : "render_inline_canvas", + displayStatus: options.status ?? "completed", + args: { + mode: "react", + content: options.content ?? `content-${id}`, + ...(options.edits ? { content: undefined, edits: options.edits } : {}), + ...(options.revises + ? options.legacyRevision + ? { revises_event_id: options.revises } + : { target_event_id: options.revises } + : {}), + }, + } as unknown as SessionEvent; +} + +describe("Canvas revision projection", () => { + it("keeps unrelated Canvas events as separate logical entries", () => { + const first = canvasEvent("first"); + const second = canvasEvent("second"); + + expect(projectLatestCanvasEvents([first, second])).toEqual([first, second]); + }); + + it("replaces the original logical Canvas with its latest revision", () => { + const original = canvasEvent("original"); + const unrelated = canvasEvent("unrelated"); + const revision = canvasEvent("revision", { + revises: "original", + content: "updated-content", + }); + + expect(projectLatestCanvasEvents([original, unrelated, revision])).toEqual([ + revision, + unrelated, + ]); + }); + + it("collapses a multi-step revision chain to its newest event", () => { + const original = canvasEvent("original"); + const revision = canvasEvent("revision", { revises: "original" }); + const latest = canvasEvent("latest", { revises: "revision" }); + + expect(projectLatestCanvasEvents([original, revision, latest])).toEqual([ + latest, + ]); + }); + + it("materializes compact text edits across a revision chain", () => { + const original = canvasEvent("original", { + content: "

Keep me

", + }); + const firstPatch = canvasEvent("first-patch", { + revises: "original", + edits: [{ find: "Start", replace: "Start setup" }], + }); + const secondPatch = canvasEvent("second-patch", { + revises: "first-patch", + edits: [{ find: "Keep me", replace: "Still here" }], + }); + + const [latest] = projectLatestCanvasEvents([ + original, + firstPatch, + secondPatch, + ]); + + expect(latest.id).toBe("second-patch"); + expect(latest.args.content).toBe( + "

Still here

" + ); + }); + + it("keeps the last valid Canvas when a compact edit is stale or ambiguous", () => { + const original = canvasEvent("original", { + content: "SameSame", + }); + const ambiguous = canvasEvent("ambiguous", { + revises: "original", + edits: [{ find: "Same", replace: "Changed" }], + }); + + expect(projectLatestCanvasEvents([original, ambiguous])).toEqual([ + original, + ]); + }); + + it("supports deliberate replace-all edits", () => { + const original = canvasEvent("original", { + content: "small small", + }); + const revision = canvasEvent("revision", { + revises: "original", + edits: [{ find: "small", replace: "large", all: true }], + }); + + const [latest] = projectLatestCanvasEvents([original, revision]); + expect(latest.args.content).toBe("large large"); + }); + + it("ignores malformed dedicated revisions instead of creating new Canvases", () => { + const missing = canvasEvent("missing", { revises: "not-present" }); + const futureRevision = canvasEvent("future-revision", { + revises: "future-target", + }); + const futureTarget = canvasEvent("future-target"); + const otherSession = canvasEvent("other-session", { + sessionId: "session-b", + revises: "future-target", + }); + + expect( + projectLatestCanvasEvents([ + missing, + futureRevision, + futureTarget, + otherSession, + ]) + ).toEqual([futureTarget]); + }); + + it("keeps the last valid Canvas when a revision fails", () => { + const original = canvasEvent("original"); + const failed = canvasEvent("failed-revision", { + revises: "original", + status: "failed", + }); + + expect(projectLatestCanvasEvents([original, failed])).toEqual([original]); + }); + + it("still projects persisted legacy revision chains", () => { + const original = canvasEvent("original"); + const legacyRevision = canvasEvent("legacy-revision", { + revises: "original", + legacyRevision: true, + }); + + expect(projectLatestCanvasEvents([original, legacyRevision])).toEqual([ + legacyRevision, + ]); + }); + + it("keeps malformed legacy metadata visible as a separate historical Canvas", () => { + const legacy = canvasEvent("legacy", { + revises: "missing", + legacyRevision: true, + }); + + expect(projectLatestCanvasEvents([legacy])).toEqual([legacy]); + }); +}); diff --git a/src/engines/Simulator/apps/canvas/canvasRevisionProjection.ts b/src/engines/Simulator/apps/canvas/canvasRevisionProjection.ts new file mode 100644 index 000000000..0570868ac --- /dev/null +++ b/src/engines/Simulator/apps/canvas/canvasRevisionProjection.ts @@ -0,0 +1,68 @@ +import { + getCanvasRevisionTargetId, + isCanvasRevisionToolName, + materializeCanvasRevisionArgs, +} from "@src/engines/ChatPanel/blocks/CanvasInlineCard/canvasRevision"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +/** + * Project immutable render events into logical Canvases. + * + * A valid revision must point backwards to another Canvas event in the same + * session. The latest event replaces that logical Canvas at its original list + * position, while every event remains available in the session history for + * replay and diagnostics. + */ +export function projectLatestCanvasEvents( + events: readonly SessionEvent[] +): SessionEvent[] { + const projected: SessionEvent[] = []; + const eventById = new Map(); + const rootIdByEventId = new Map(); + const projectedIndexByRootId = new Map(); + + for (const event of events) { + const targetId = getCanvasRevisionTargetId(event.args); + const isDedicatedRevision = isCanvasRevisionToolName(event.functionName); + if (targetId && event.displayStatus === "failed") { + continue; + } + const targetEvent = targetId ? eventById.get(targetId) : undefined; + const canRevise = + targetEvent !== undefined && targetEvent.sessionId === event.sessionId; + + if (canRevise && targetId) { + const materializedArgs = materializeCanvasRevisionArgs( + targetEvent.args, + event.args + ); + if (!materializedArgs) { + continue; + } + const materializedEvent = + materializedArgs === event.args + ? event + : { ...event, args: materializedArgs }; + const rootId = rootIdByEventId.get(targetId) ?? targetId; + const projectedIndex = projectedIndexByRootId.get(rootId); + if (projectedIndex !== undefined) { + projected[projectedIndex] = materializedEvent; + eventById.set(event.id, materializedEvent); + rootIdByEventId.set(event.id, rootId); + continue; + } + } + + if (isDedicatedRevision) { + continue; + } + + const projectedIndex = projected.length; + projected.push(event); + eventById.set(event.id, event); + rootIdByEventId.set(event.id, event.id); + projectedIndexByRootId.set(event.id, projectedIndex); + } + + return projected; +} diff --git a/src/engines/Simulator/apps/canvas/design/CanvasDesignSurface.test.ts b/src/engines/Simulator/apps/canvas/design/CanvasDesignSurface.test.ts new file mode 100644 index 000000000..73ebb0a6c --- /dev/null +++ b/src/engines/Simulator/apps/canvas/design/CanvasDesignSurface.test.ts @@ -0,0 +1,404 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import CanvasDesignSurface from "./CanvasDesignSurface"; + +const testState = vi.hoisted(() => ({ + inputAreaProps: null as Record | null, + submit: vi.fn(), +})); + +vi.mock("@src/engines/ChatPanel/InputArea", async () => { + const React = await import("react"); + return { + default: (props: Record) => { + testState.inputAreaProps = props; + return React.createElement( + "div", + { "data-testid": "canvas-design-input-area" }, + props.topRowPills as React.ReactNode + ); + }, + }; +}); + +vi.mock( + "@src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasPreviewSurface", + async () => { + const React = await import("react"); + return { + default: () => + React.createElement( + "button", + { + type: "button", + "data-component": "Stat", + "data-testid": "canvas-target", + }, + "M" + ), + }; + } +); + +vi.mock("@src/engines/ChatPanel/hooks/useWorkspaceChat", () => ({ + useWorkspaceChat: () => ({ handleSessChatSubmit: testState.submit }), +})); + +class ResizeObserverStub { + observe() {} + disconnect() {} +} + +function rect( + left: number, + top: number, + width: number, + height: number +): DOMRect { + return { + x: left, + y: top, + left, + top, + right: left + width, + bottom: top + height, + width, + height, + toJSON: () => ({}), + }; +} + +function pointerEvent( + type: string, + clientX: number, + clientY: number +): MouseEvent { + const event = new MouseEvent(type, { + bubbles: true, + composed: true, + cancelable: true, + button: 0, + clientX, + clientY, + }); + Object.defineProperty(event, "pointerId", { value: 1 }); + return event; +} + +describe("CanvasDesignSurface", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + vi.stubGlobal("ResizeObserver", ResizeObserverStub); + vi.stubGlobal("CSS", { + escape: (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"), + }); + testState.inputAreaProps = null; + testState.submit.mockReset(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + function renderSurface(onRequestDisable = vi.fn()) { + act(() => + root.render( + createElement(CanvasDesignSurface, { + payload: { mode: "html", content: "" }, + reloadKey: 0, + title: "Coffee Order Sketch", + eventId: "event-a", + sessionId: "session-a", + designEnabled: true, + onRequestDisable, + }) + ) + ); + } + + it("keeps hover, portals InputArea beyond the clipped Canvas, and submits the selected context", async () => { + renderSurface(); + const surface = container.querySelector( + "[data-testid='canvas-design-surface']" + )!; + const target = container.querySelector( + "[data-testid='canvas-target']" + )!; + vi.spyOn(surface, "getBoundingClientRect").mockReturnValue( + rect(0, 0, 800, 600) + ); + vi.spyOn(target, "getBoundingClientRect").mockReturnValue( + rect(120, 90, 160, 80) + ); + + act(() => target.dispatchEvent(pointerEvent("pointermove", 140, 110))); + expect(container.textContent).toContain("Stat · button"); + + act(() => target.dispatchEvent(pointerEvent("pointerdown", 140, 110))); + expect(container.textContent).toContain("Stat · button"); + + act(() => window.dispatchEvent(pointerEvent("pointerup", 140, 110))); + expect( + document.body.querySelector("[data-testid='canvas-design-input-area']") + ).not.toBeNull(); + expect( + container.querySelector("[data-testid='canvas-design-input-area']") + ).toBeNull(); + expect( + document.body.querySelector("[data-canvas-design-prompt]")?.parentElement + ).toBe(document.body); + const prompt = document.body.querySelector( + "[data-canvas-design-prompt]" + ); + expect(prompt?.style.width).toBe("640px"); + expect(prompt?.className).toContain("drop-shadow-2xl"); + expect(prompt?.className).not.toContain("rounded"); + expect(prompt?.className).not.toContain("bg-bg-1"); + expect(testState.inputAreaProps).toMatchObject({ + sessionId: "session-a", + sessionScope: "none", + autoFocus: true, + allowFileAttachments: false, + enableAgentInterceptors: false, + presentation: "contextual", + }); + const onSubmitOverride = testState.inputAreaProps + ?.onSubmitOverride as (input: { + displayText: string; + }) => Promise; + testState.submit.mockRejectedValueOnce(new Error("offline")); + await act(async () => { + await expect( + onSubmitOverride({ displayText: "字体变大一些" }) + ).rejects.toThrow("offline"); + }); + expect( + document.body.querySelector("[data-testid='canvas-design-input-area']") + ).not.toBeNull(); + + await act(async () => { + await expect( + onSubmitOverride({ displayText: "字体变大一些" }) + ).resolves.toBe(true); + }); + expect(testState.submit).toHaveBeenCalledWith( + undefined, + expect.stringContaining("字体变大一些"), + expect.stringContaining('"origin": "canvas-design"') + ); + expect( + document.body.querySelector("[data-testid='canvas-design-input-area']") + ).toBeNull(); + expect( + container.querySelector("[data-canvas-design-close]") + ).not.toBeNull(); + + act(() => target.dispatchEvent(pointerEvent("pointerdown", 140, 110))); + act(() => window.dispatchEvent(pointerEvent("pointerup", 140, 110))); + expect( + document.body.querySelector("[data-testid='canvas-design-input-area']") + ).not.toBeNull(); + + const removeListener = vi.spyOn(surface, "removeEventListener"); + renderSurface(vi.fn()); + expect( + document.body.querySelector("[data-testid='canvas-design-input-area']") + ).not.toBeNull(); + expect(removeListener).not.toHaveBeenCalledWith( + "pointerdown", + expect.any(Function), + true + ); + }); + + it("replaces the hover label with a close control after selection", () => { + renderSurface(); + const surface = container.querySelector( + "[data-testid='canvas-design-surface']" + )!; + const target = container.querySelector( + "[data-testid='canvas-target']" + )!; + vi.spyOn(surface, "getBoundingClientRect").mockReturnValue( + rect(0, 0, 800, 600) + ); + vi.spyOn(target, "getBoundingClientRect").mockReturnValue( + rect(120, 90, 160, 80) + ); + + act(() => target.dispatchEvent(pointerEvent("pointermove", 140, 110))); + expect( + container.querySelector("[data-canvas-design-hover-label]") + ).not.toBeNull(); + + act(() => target.dispatchEvent(pointerEvent("pointerdown", 140, 110))); + act(() => window.dispatchEvent(pointerEvent("pointerup", 140, 110))); + expect( + container.querySelector("[data-canvas-design-hover-label]") + ).toBeNull(); + const closeButton = container.querySelector( + "[data-canvas-design-close]" + ); + expect(closeButton).not.toBeNull(); + + act(() => closeButton?.click()); + expect(container.querySelector("[data-canvas-design-close]")).toBeNull(); + expect( + document.body.querySelector("[data-canvas-design-prompt]") + ).toBeNull(); + }); + + it("turns the selected-element pill icon into a dismiss action on hover", () => { + renderSurface(); + const surface = container.querySelector( + "[data-testid='canvas-design-surface']" + )!; + const target = container.querySelector( + "[data-testid='canvas-target']" + )!; + vi.spyOn(surface, "getBoundingClientRect").mockReturnValue( + rect(0, 0, 800, 600) + ); + vi.spyOn(target, "getBoundingClientRect").mockReturnValue( + rect(120, 90, 160, 80) + ); + + act(() => target.dispatchEvent(pointerEvent("pointerdown", 140, 110))); + act(() => window.dispatchEvent(pointerEvent("pointerup", 140, 110))); + + const selectionPill = document.body.querySelector( + "[data-canvas-design-selection-pill]" + ); + expect(selectionPill).not.toBeNull(); + expect( + selectionPill?.querySelector(".lucide-mouse-pointer-2") + ).not.toBeNull(); + + act(() => + selectionPill?.dispatchEvent( + new MouseEvent("mouseover", { bubbles: true }) + ) + ); + expect(selectionPill?.querySelector(".lucide-x")).not.toBeNull(); + + act(() => selectionPill?.click()); + expect( + document.body.querySelector("[data-canvas-design-prompt]") + ).toBeNull(); + expect(container.querySelector("[data-canvas-design-close]")).toBeNull(); + }); + + it("docks the shared InputArea above the floating replay controls for a full-surface selection", () => { + renderSurface(); + const surface = container.querySelector( + "[data-testid='canvas-design-surface']" + )!; + const target = container.querySelector( + "[data-testid='canvas-target']" + )!; + vi.spyOn(surface, "getBoundingClientRect").mockReturnValue( + rect(40, 50, 800, 600) + ); + vi.spyOn(target, "getBoundingClientRect").mockReturnValue( + rect(40, 50, 800, 600) + ); + + act(() => target.dispatchEvent(pointerEvent("pointerdown", 140, 110))); + act(() => window.dispatchEvent(pointerEvent("pointerup", 140, 110))); + + const prompt = document.body.querySelector( + "[data-canvas-design-prompt]" + ); + expect(prompt?.dataset.placement).toBe("docked"); + expect(prompt?.style.position).toBe("fixed"); + expect(prompt?.style.bottom).not.toBe(""); + expect(Number.parseFloat(prompt?.style.bottom ?? "0")).toBe( + window.innerHeight - 650 + 72 + ); + expect(prompt?.style.top).toBe(""); + expect(testState.inputAreaProps).toMatchObject({ bottomAnchored: true }); + }); + + it("keeps the compact prompt below the selection when one row fits", () => { + renderSurface(); + const surface = container.querySelector( + "[data-testid='canvas-design-surface']" + )!; + const target = container.querySelector( + "[data-testid='canvas-target']" + )!; + vi.spyOn(surface, "getBoundingClientRect").mockReturnValue( + rect(0, 0, 800, 400) + ); + vi.spyOn(target, "getBoundingClientRect").mockReturnValue( + rect(100, 230, 160, 30) + ); + + act(() => target.dispatchEvent(pointerEvent("pointerdown", 140, 240))); + act(() => window.dispatchEvent(pointerEvent("pointerup", 140, 240))); + + const prompt = document.body.querySelector( + "[data-canvas-design-prompt]" + ); + expect(prompt?.dataset.placement).toBe("below"); + expect(prompt?.style.top).toBe("272px"); + expect(prompt?.style.bottom).toBe(""); + expect(testState.inputAreaProps).toMatchObject({ bottomAnchored: false }); + }); + + it("docks a tall outer element instead of flipping the prompt above the Canvas", () => { + renderSurface(); + const surface = container.querySelector( + "[data-testid='canvas-design-surface']" + )!; + const target = container.querySelector( + "[data-testid='canvas-target']" + )!; + vi.spyOn(surface, "getBoundingClientRect").mockReturnValue( + rect(80, 200, 900, 900) + ); + vi.spyOn(target, "getBoundingClientRect").mockReturnValue( + rect(120, 400, 820, 400) + ); + + act(() => target.dispatchEvent(pointerEvent("pointerdown", 140, 420))); + act(() => window.dispatchEvent(pointerEvent("pointerup", 140, 420))); + + const prompt = document.body.querySelector( + "[data-canvas-design-prompt]" + ); + expect(prompt?.dataset.placement).toBe("docked"); + expect(prompt?.style.bottom).not.toBe(""); + expect(prompt?.style.top).toBe(""); + }); +}); diff --git a/src/engines/Simulator/apps/canvas/design/CanvasDesignSurface.tsx b/src/engines/Simulator/apps/canvas/design/CanvasDesignSurface.tsx new file mode 100644 index 000000000..97c4cffa1 --- /dev/null +++ b/src/engines/Simulator/apps/canvas/design/CanvasDesignSurface.tsx @@ -0,0 +1,454 @@ +import { MousePointer2, X } from "lucide-react"; +import React, { useCallback, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; + +import BasePill from "@src/components/ComposerInput/BasePill"; +import IconButton from "@src/components/IconButton"; +import { PILL_SIZE } from "@src/config/pillTokens"; +import InputArea from "@src/engines/ChatPanel/InputArea"; +import CanvasPreviewSurface from "@src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasPreviewSurface"; +import type { SubmitOverrideInput } from "@src/engines/ChatPanel/hooks/useInputArea/types"; +import { useWorkspaceChat } from "@src/engines/ChatPanel/hooks/useWorkspaceChat"; +import { + buildDomComponentJsonFromElementInfo, + buildDomComponentUserMessage, +} from "@src/features/DomSelection/domComponentPayload"; +import type { DomSelectionRect } from "@src/features/DomSelection/types"; + +import type { CanvasDesignSelection } from "./canvasDomCapture"; +import { useCanvasDesignInspector } from "./useCanvasDesignInspector"; + +interface CanvasPayload { + mode: "html" | "react" | "a2ui" | "url"; + content?: string; + url?: string; + title?: string; + streaming?: boolean; +} + +interface CanvasDesignSurfaceProps { + payload: CanvasPayload; + reloadKey: number; + title: string; + eventId: string; + sessionId: string; + designEnabled: boolean; + onRequestDisable: () => void; +} + +interface CanvasDesignPromptProps { + selection: CanvasDesignSelection; + rootRect: DomSelectionRect; + payload: CanvasPayload; + eventId: string; + sessionId: string; + title: string; + onSubmitted: () => void; + onDismiss: () => void; +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.min(Math.max(value, minimum), Math.max(minimum, maximum)); +} + +type CanvasDesignPromptPlacement = "below" | "docked"; + +interface CanvasDesignPromptLayout { + placement: CanvasDesignPromptPlacement; + style: React.CSSProperties; +} + +const PROMPT_GAP = 12; +const PROMPT_MAX_WIDTH = 640; +const PROMPT_ESTIMATED_HEIGHT = 52; +// FloatingReplayContainer occupies the bottom of every active Simulator app. +// Keep the Canvas composer above that shared chrome instead of placing both +// controls on the same bottom edge. +const PROMPT_DOCK_BOTTOM_INSET = 72; +const VIEWPORT_MARGIN = 8; + +interface CanvasSelectionPillProps { + selection: CanvasDesignSelection; + onDismiss: () => void; + dismissLabel: string; +} + +const CanvasSelectionPill: React.FC = ({ + selection, + onDismiss, + dismissLabel, +}) => { + const [isHovered, setIsHovered] = useState(false); + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onDismiss(); + }, + [onDismiss] + ); + + return ( + + ) : ( + + ) + } + className="max-w-40 cursor-pointer" + title={selection.tooltipLabel} + aria-label={dismissLabel} + role="button" + tabIndex={0} + onClick={onDismiss} + onMouseDown={(event) => event.preventDefault()} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onKeyDown={handleKeyDown} + data-canvas-design-selection-pill + > + + {/^(h[1-6])$/i.test(selection.elementInfo.tagName) + ? selection.elementInfo.tagName.toUpperCase() + : selection.label} + + + ); +}; + +export function computeCanvasDesignPromptLayout( + selection: CanvasDesignSelection, + rootRect: DomSelectionRect, + viewportSize: { width: number; height: number } +): CanvasDesignPromptLayout { + const visibleLeft = Math.max(rootRect.x, VIEWPORT_MARGIN); + const visibleTop = Math.max(rootRect.y, VIEWPORT_MARGIN); + const visibleRight = Math.min( + rootRect.x + rootRect.width, + viewportSize.width - VIEWPORT_MARGIN + ); + const visibleBottom = Math.min( + rootRect.y + rootRect.height, + viewportSize.height - VIEWPORT_MARGIN + ); + const visibleWidth = Math.max(1, visibleRight - visibleLeft); + const width = Math.max( + 1, + Math.min(PROMPT_MAX_WIDTH, visibleWidth - PROMPT_GAP * 2) + ); + const selectionTop = rootRect.y + selection.rect.y; + const selectionBottom = selectionTop + selection.rect.height; + const availableBelow = + visibleBottom - PROMPT_DOCK_BOTTOM_INSET - selectionBottom - PROMPT_GAP; + const coversMostOfCanvas = + rootRect.height > 0 && selection.rect.height >= rootRect.height * 0.55; + const docked = coversMostOfCanvas || availableBelow < PROMPT_ESTIMATED_HEIGHT; + const placement: CanvasDesignPromptPlacement = docked ? "docked" : "below"; + const availableDockHeight = Math.max( + PROMPT_GAP, + visibleBottom - visibleTop - PROMPT_ESTIMATED_HEIGHT - PROMPT_GAP + ); + const dockBottomInset = Math.min( + PROMPT_DOCK_BOTTOM_INSET, + availableDockHeight + ); + const left = visibleLeft + (visibleWidth - width) / 2; + + return { + placement, + style: + placement === "below" + ? { + position: "fixed", + top: Math.max( + visibleTop + PROMPT_GAP, + selectionBottom + PROMPT_GAP + ), + left, + width, + } + : { + position: "fixed", + bottom: viewportSize.height - visibleBottom + dockBottomInset, + left, + width, + }, + }; +} + +const CanvasDesignPrompt: React.FC = ({ + selection, + rootRect, + payload, + eventId, + sessionId, + title, + onSubmitted, + onDismiss, +}) => { + const { t } = useTranslation("sessions"); + const { handleSessChatSubmit } = useWorkspaceChat({ sessionId }); + const promptLayout = computeCanvasDesignPromptLayout(selection, rootRect, { + width: window.innerWidth, + height: window.innerHeight, + }); + const handleSubmitOverride = useCallback( + async ({ displayText }: SubmitOverrideInput): Promise => { + const instruction = displayText.trim(); + const canvasSelection = { + schemaVersion: 1 as const, + origin: "canvas-design" as const, + canvas: { + sessionId, + eventId, + mode: payload.mode, + title, + }, + selection: { + kind: selection.kind, + label: selection.label, + rect: selection.rect, + targets: selection.targets, + }, + previewHtml: selection.previewHtml, + }; + const built = buildDomComponentJsonFromElementInfo( + selection.elementInfo, + `canvas://${encodeURIComponent(sessionId)}/${encodeURIComponent(eventId)}`, + { displayLabel: selection.label, canvasSelection } + ); + const message = buildDomComponentUserMessage( + built, + instruction, + eventId, + { + currentCanvas: { + mode: payload.mode, + content: payload.content, + url: payload.url, + title: payload.title ?? title, + streaming: payload.streaming, + }, + } + ); + + await handleSessChatSubmit( + undefined, + message.displayContent, + message.agentContent + ); + onSubmitted(); + return true; + }, + [ + eventId, + handleSessChatSubmit, + onSubmitted, + payload.content, + payload.mode, + payload.title, + payload.url, + payload.streaming, + selection, + sessionId, + title, + ] + ); + + return createPortal( +
+ + } + /> +
, + document.body + ); +}; + +const CanvasDesignSurface: React.FC = ({ + payload, + reloadKey, + title, + eventId, + sessionId, + designEnabled, + onRequestDisable, +}) => { + const { t } = useTranslation("sessions"); + const rootRef = useRef(null); + const inspector = useCanvasDesignInspector( + rootRef, + designEnabled, + onRequestDisable + ); + const visibleSelection = inspector.selected ?? inspector.hovered; + + return ( +
+ + + {payload.streaming + ? t("canvasCard.waiting", "Waiting for content…") + : t("canvasCard.empty", "No content")} + +
+ } + /> + + {designEnabled && ( +
+ {visibleSelection && ( + <> +
+ {!inspector.selected && ( +
= 32 + ? visibleSelection.rect.y - 30 + : visibleSelection.rect.y + + visibleSelection.rect.height + + 4, + }} + > + {visibleSelection.tooltipLabel} + + {t("canvasApp.designHint", "Click to select, drag to draw")} + +
+ )} + {inspector.selected && ( + = 36 + ? visibleSelection.rect.y - 34 + : visibleSelection.rect.y + 4, + }} + aria-label={t( + "canvasApp.clearDesignSelection", + "Clear Canvas selection" + )} + onClick={inspector.clearSelection} + > + + + )} + + )} + {inspector.marquee && ( +
+ )} +
+ )} + {designEnabled && inspector.selected && inspector.promptOpen && ( + + )} +
+ ); +}; + +CanvasDesignSurface.displayName = "CanvasDesignSurface"; + +export default CanvasDesignSurface; diff --git a/src/engines/Simulator/apps/canvas/design/canvasDomCapture.test.ts b/src/engines/Simulator/apps/canvas/design/canvasDomCapture.test.ts new file mode 100644 index 000000000..85c0e3192 --- /dev/null +++ b/src/engines/Simulator/apps/canvas/design/canvasDomCapture.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + buildCanvasPreviewHtml, + captureCanvasElement, + elementFromComposedPath, +} from "./canvasDomCapture"; + +describe("Canvas DOM capture", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("resolves the deepest inspectable element through an open ShadowRoot", () => { + const root = document.createElement("div"); + const host = document.createElement("div"); + const shadow = host.attachShadow({ mode: "open" }); + const target = document.createElement("button"); + shadow.appendChild(target); + root.appendChild(host); + + const event = { + composedPath: () => [target, shadow, host, root, document, window], + } as unknown as Event; + + expect(elementFromComposedPath(event, root)).toBe(target); + }); + + it("ignores Design overlay controls", () => { + const root = document.createElement("div"); + const control = document.createElement("button"); + control.setAttribute("data-canvas-design-ui", ""); + root.appendChild(control); + const event = { + composedPath: () => [control, root, document, window], + } as unknown as Event; + + expect(elementFromComposedPath(event, root)).toBeNull(); + }); + + it("captures bounded context and a sanitized visual preview", () => { + const target = document.createElement("div"); + target.dataset.component = "Stat"; + target.setAttribute("data-value", "M"); + target.setAttribute("onclick", "alert(1)"); + target.innerHTML = 'M'; + vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ + x: 10, + y: 20, + left: 10, + top: 20, + right: 130, + bottom: 100, + width: 120, + height: 80, + toJSON: () => ({}), + }); + document.body.appendChild(target); + + const capture = captureCanvasElement(target); + const preview = buildCanvasPreviewHtml(target); + + expect(capture.label).toBe("Stat"); + expect(capture.elementInfo.attributes).toEqual({ + "data-component": "Stat", + "data-value": "M", + }); + expect(capture.rect).toEqual({ x: 10, y: 20, width: 120, height: 80 }); + expect(preview).toContain("font-size:28px"); + expect(preview).not.toContain("onclick"); + + target.remove(); + }); + + it("falls back to a text preview when computed styles cannot be serialized", () => { + const target = document.createElement("div"); + target.textContent = "Selection stays usable"; + document.body.appendChild(target); + const getComputedStyle = window.getComputedStyle.bind(window); + let styleReadCount = 0; + vi.spyOn(window, "getComputedStyle").mockImplementation((element) => { + styleReadCount += 1; + if (styleReadCount > 1) { + throw new DOMException("Unsupported computed style"); + } + return getComputedStyle(element); + }); + + const capture = captureCanvasElement(target); + + expect(capture.previewHtml).toContain("Selection stays usable"); + target.remove(); + }); + + it("preserves the nearest opaque ancestor background for readable previews", () => { + const surface = document.createElement("section"); + surface.style.backgroundColor = "rgb(12, 18, 28)"; + const target = document.createElement("h1"); + target.style.color = "white"; + target.textContent = "Readable heading"; + surface.appendChild(target); + document.body.appendChild(surface); + + const preview = buildCanvasPreviewHtml(target); + + expect(preview).toContain('data-canvas-preview-context="true"'); + expect(preview).toContain("background-color: rgb(12, 18, 28)"); + expect(preview).toContain("Readable heading"); + surface.remove(); + }); +}); diff --git a/src/engines/Simulator/apps/canvas/design/canvasDomCapture.ts b/src/engines/Simulator/apps/canvas/design/canvasDomCapture.ts new file mode 100644 index 000000000..0c7b02c6b --- /dev/null +++ b/src/engines/Simulator/apps/canvas/design/canvasDomCapture.ts @@ -0,0 +1,315 @@ +import { sanitizeDomPreviewHtml } from "@src/features/DomSelection/domPreviewHtml"; +import type { + CanvasDomSelectionTargetSummary, + DomSelectionElementInfo, + DomSelectionRect, +} from "@src/features/DomSelection/types"; +import { buildCssSelector } from "@src/util/core/error/componentIssueTracker/domAnalysis"; +import { getReactComponentInfo } from "@src/util/core/error/componentIssueTracker/elementExtraction"; +import { generatePreviewHtml } from "@src/util/core/error/componentIssueTracker/previewGenerator"; + +const INNER_HTML_LIMIT = 2_000; +const MAX_REGION_SCAN_ELEMENTS = 2_000; +const MAX_REGION_TARGETS = 12; +const MAX_PREVIEW_BACKGROUND_DEPTH = 12; + +const SAFE_ATTRIBUTE_NAMES = new Set([ + "id", + "class", + "role", + "name", + "type", + "title", + "value", +]); + +export interface CanvasDesignSelection { + kind: "element" | "region"; + label: string; + tooltipLabel: string; + rect: DomSelectionRect; + elementInfo: DomSelectionElementInfo; + previewHtml?: string; + targets?: CanvasDomSelectionTargetSummary[]; +} + +function isSafeAttribute(name: string): boolean { + return ( + SAFE_ATTRIBUTE_NAMES.has(name) || + name.startsWith("aria-") || + name.startsWith("data-") + ); +} + +function readAttributes(element: HTMLElement): Record { + const attributes: Record = {}; + for (const attribute of Array.from(element.attributes)) { + if (isSafeAttribute(attribute.name)) { + attributes[attribute.name] = attribute.value.slice(0, 500); + } + } + return attributes; +} + +function siblingIndex(element: Element): number { + return Array.from(element.parentElement?.children ?? []).indexOf(element) + 1; +} + +function buildXPath(element: Element): string { + const parts: string[] = []; + let current: Element | null = element; + while (current) { + const tag = current.tagName.toLowerCase(); + const index = siblingIndex(current); + parts.unshift(`${tag}[${Math.max(index, 1)}]`); + const root = current.getRootNode(); + if (root instanceof ShadowRoot) { + parts.unshift("shadow-root()"); + current = root.host; + } else { + current = current.parentElement; + } + } + return `/${parts.join("/")}`; +} + +function normalizeText( + value: string | null | undefined, + limit: number +): string { + return (value ?? "").replace(/\s+/g, " ").trim().slice(0, limit); +} + +function getDisplayLabel(element: HTMLElement): string { + const reactName = getReactComponentInfo(element)?.name?.trim(); + return ( + element.dataset.component?.trim() || + reactName || + element.getAttribute("aria-label")?.trim() || + element.id.trim() || + element.tagName.toLowerCase() + ); +} + +function rectFromDomRect(rect: DOMRect): DomSelectionRect { + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }; +} + +function parentAcrossShadowBoundary(element: HTMLElement): HTMLElement | null { + if (element.parentElement) return element.parentElement; + const root = element.getRootNode(); + return root instanceof ShadowRoot && root.host instanceof HTMLElement + ? root.host + : null; +} + +function isTransparentBackground(value: string): boolean { + const normalized = value.toLowerCase().replace(/\s+/g, ""); + return ( + normalized === "" || + normalized === "transparent" || + normalized === "rgba(0,0,0,0)" + ); +} + +function resolvePreviewBackgroundColor(element: HTMLElement): string | null { + let current: HTMLElement | null = element; + for ( + let depth = 0; + current && depth < MAX_PREVIEW_BACKGROUND_DEPTH; + depth++ + ) { + try { + const backgroundColor = window.getComputedStyle(current).backgroundColor; + if (!isTransparentBackground(backgroundColor)) return backgroundColor; + } catch { + return null; + } + current = parentAcrossShadowBoundary(current); + } + return null; +} + +function wrapPreviewWithContextBackground( + previewHtml: string, + backgroundColor: string | null +): string { + if (!backgroundColor) return previewHtml; + const wrapper = document.createElement("div"); + wrapper.dataset.canvasPreviewContext = "true"; + wrapper.style.display = "inline-flex"; + wrapper.style.maxWidth = "100%"; + wrapper.style.maxHeight = "100%"; + wrapper.style.padding = "12px"; + wrapper.style.borderRadius = "8px"; + wrapper.style.backgroundColor = backgroundColor; + wrapper.innerHTML = previewHtml; + return sanitizeDomPreviewHtml(wrapper.outerHTML); +} + +export function buildCanvasPreviewHtml(element: HTMLElement): string { + const backgroundColor = resolvePreviewBackgroundColor(element); + try { + const raw = generatePreviewHtml(element); + const result = sanitizeDomPreviewHtml(raw); + const contextualized = wrapPreviewWithContextBackground( + result, + backgroundColor + ); + if (contextualized.length < 32_000) return contextualized; + } catch { + // Preview serialization is best-effort. Selection metadata remains useful + // even when a browser-specific computed style cannot be read. + } + + const fallback = document.createElement("div"); + try { + const computed = window.getComputedStyle(element); + fallback.style.cssText = [ + `color:${computed.color}`, + `background:${computed.backgroundColor}`, + `font:${computed.font}`, + `padding:${computed.padding}`, + `border:${computed.border}`, + `border-radius:${computed.borderRadius}`, + ].join(";"); + } catch { + // Text-only fallback below still gives chat a stable visual placeholder. + } + fallback.textContent = normalizeText(element.textContent, 500); + return wrapPreviewWithContextBackground( + sanitizeDomPreviewHtml(fallback.outerHTML), + backgroundColor + ); +} + +export function captureCanvasElement( + element: HTMLElement, + options: { includePreview?: boolean } = {} +): CanvasDesignSelection { + const rect = element.getBoundingClientRect(); + const computed = window.getComputedStyle(element); + const label = getDisplayLabel(element); + const tagName = element.tagName.toLowerCase(); + const info: DomSelectionElementInfo = { + tagName, + selector: buildCssSelector(element), + id: element.id || null, + className: element.className || null, + attributes: readAttributes(element), + innerText: normalizeText(element.innerText || element.textContent, 1_000), + innerHTML: element.innerHTML.slice(0, INNER_HTML_LIMIT), + rect: rectFromDomRect(rect), + computedStyle: { + display: computed.display, + position: computed.position, + color: computed.color, + backgroundColor: computed.backgroundColor, + fontSize: computed.fontSize, + fontFamily: computed.fontFamily, + }, + role: element.getAttribute("role") || tagName, + xpath: buildXPath(element), + sourceLocation: null, + }; + + return { + kind: "element", + label, + tooltipLabel: `${label} · ${tagName}`, + rect: info.rect, + elementInfo: info, + previewHtml: + options.includePreview === false + ? undefined + : buildCanvasPreviewHtml(element), + }; +} + +function visitInspectableElements( + root: ParentNode, + result: HTMLElement[] +): void { + for (const element of Array.from(root.querySelectorAll("*"))) { + if (result.length >= MAX_REGION_SCAN_ELEMENTS) return; + if (element.closest("[data-canvas-design-ui]")) continue; + result.push(element); + if (element.shadowRoot) + visitInspectableElements(element.shadowRoot, result); + } +} + +function intersects(a: DomSelectionRect, b: DomSelectionRect): boolean { + return !( + a.x + a.width < b.x || + b.x + b.width < a.x || + a.y + a.height < b.y || + b.y + b.height < a.y + ); +} + +export function captureCanvasRegion( + root: HTMLElement, + region: DomSelectionRect +): CanvasDesignSelection | null { + const elements: HTMLElement[] = []; + visitInspectableElements(root, elements); + const matches = elements + .map((element) => ({ + element, + rect: rectFromDomRect(element.getBoundingClientRect()), + })) + .filter(({ rect }) => { + const { width, height } = rect; + return width >= 2 && height >= 2 && intersects(rect, region); + }) + .sort((a, b) => a.rect.width * a.rect.height - b.rect.width * b.rect.height) + .slice(0, MAX_REGION_TARGETS); + + const primary = matches[0]; + if (!primary) return null; + const primaryCapture = captureCanvasElement(primary.element); + const targets = matches.map(({ element, rect }) => ({ + label: getDisplayLabel(element), + selector: buildCssSelector(element), + tagName: element.tagName.toLowerCase(), + rect, + })); + const label = + targets.length === 1 ? primaryCapture.label : `${targets.length} elements`; + + return { + ...primaryCapture, + kind: "region", + label, + tooltipLabel: `Region · ${label}`, + rect: region, + targets, + }; +} + +export function elementFromComposedPath( + event: Event, + root: HTMLElement +): HTMLElement | null { + const path = event.composedPath(); + if ( + path.some( + (item) => + item instanceof HTMLElement && + item.hasAttribute("data-canvas-design-ui") + ) + ) { + return null; + } + for (const item of path) { + if (item === root) break; + if (item instanceof HTMLElement) return item; + } + return null; +} diff --git a/src/engines/Simulator/apps/canvas/design/useCanvasDesignInspector.test.ts b/src/engines/Simulator/apps/canvas/design/useCanvasDesignInspector.test.ts new file mode 100644 index 000000000..68a7582a2 --- /dev/null +++ b/src/engines/Simulator/apps/canvas/design/useCanvasDesignInspector.test.ts @@ -0,0 +1,272 @@ +// @vitest-environment jsdom +import { type RefObject, act, createElement, createRef } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { useCanvasDesignInspector } from "./useCanvasDesignInspector"; + +const resizeObserverObserve = vi.fn(); +const resizeObserverDisconnect = vi.fn(); + +class ResizeObserverStub { + observe = resizeObserverObserve; + disconnect = resizeObserverDisconnect; +} + +function rect( + left: number, + top: number, + width: number, + height: number +): DOMRect { + return { + x: left, + y: top, + left, + top, + right: left + width, + bottom: top + height, + width, + height, + toJSON: () => ({}), + }; +} + +function pointerEvent( + type: string, + clientX: number, + clientY: number +): MouseEvent { + const event = new MouseEvent(type, { + bubbles: true, + composed: true, + cancelable: true, + button: 0, + clientX, + clientY, + }); + Object.defineProperty(event, "pointerId", { value: 1 }); + return event; +} + +describe("useCanvasDesignInspector", () => { + let container: HTMLDivElement; + let root: Root; + let inspectorRootRef: RefObject; + const onCanvasAction = vi.fn(); + const onRequestDisable = vi.fn(); + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + vi.stubGlobal("ResizeObserver", ResizeObserverStub); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + inspectorRootRef = createRef(); + onCanvasAction.mockReset(); + onRequestDisable.mockReset(); + resizeObserverObserve.mockReset(); + resizeObserverDisconnect.mockReset(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + function Harness({ enabled = true }: { enabled?: boolean }) { + const inspector = useCanvasDesignInspector( + inspectorRootRef, + enabled, + onRequestDisable + ); + return createElement( + "div", + { ref: inspectorRootRef, "data-testid": "root" }, + createElement( + "button", + { + type: "button", + "data-component": "Stat", + "data-testid": "target", + onClick: onCanvasAction, + }, + "M" + ), + createElement( + "output", + { "data-testid": "selection" }, + inspector.selected + ? `${inspector.selected.kind}:${inspector.selected.label}` + : "none" + ), + createElement( + "output", + { "data-testid": "hover" }, + inspector.hovered + ? `${inspector.hovered.kind}:${inspector.hovered.label}` + : "none" + ) + ); + } + + function mountHarness(enabled = true) { + act(() => root.render(createElement(Harness, { enabled }))); + const inspectRoot = container.querySelector( + "[data-testid='root']" + )!; + const target = container.querySelector( + "[data-testid='target']" + )!; + vi.spyOn(inspectRoot, "getBoundingClientRect").mockReturnValue( + rect(0, 0, 500, 400) + ); + vi.spyOn(target, "getBoundingClientRect").mockReturnValue( + rect(40, 60, 120, 80) + ); + return { inspectRoot, target }; + } + + it("selects an element without activating the Canvas control underneath", () => { + const { target } = mountHarness(); + + act(() => { + target.dispatchEvent(pointerEvent("pointerdown", 50, 70)); + target.dispatchEvent(pointerEvent("pointerup", 50, 70)); + target.click(); + }); + + expect( + container.querySelector("[data-testid='selection']")?.textContent + ).toBe("element:Stat"); + expect(onCanvasAction).not.toHaveBeenCalled(); + }); + + it("keeps hover visible until a window-level pointerup commits selection", () => { + const { inspectRoot, target } = mountHarness(); + const unavailablePointerCapture = vi.fn(() => { + throw new DOMException("Pointer capture unavailable"); + }); + Object.defineProperty(inspectRoot, "setPointerCapture", { + configurable: true, + value: unavailablePointerCapture, + }); + + act(() => target.dispatchEvent(pointerEvent("pointermove", 50, 70))); + expect(container.querySelector("[data-testid='hover']")?.textContent).toBe( + "element:Stat" + ); + + act(() => target.dispatchEvent(pointerEvent("pointerdown", 50, 70))); + expect(container.querySelector("[data-testid='hover']")?.textContent).toBe( + "element:Stat" + ); + + act(() => window.dispatchEvent(pointerEvent("pointerup", 50, 70))); + expect( + container.querySelector("[data-testid='selection']")?.textContent + ).toBe("element:Stat"); + expect(unavailablePointerCapture).not.toHaveBeenCalled(); + }); + + it("turns a drag gesture into a bounded region selection", () => { + const { target } = mountHarness(); + + act(() => { + target.dispatchEvent(pointerEvent("pointerdown", 45, 65)); + target.dispatchEvent(pointerEvent("pointermove", 150, 130)); + target.dispatchEvent(pointerEvent("pointerup", 150, 130)); + }); + + expect( + container.querySelector("[data-testid='selection']")?.textContent + ).toBe("region:Stat"); + }); + + it("clears selection on the first Escape and exits on the second", () => { + const { target } = mountHarness(); + act(() => { + target.dispatchEvent(pointerEvent("pointerdown", 50, 70)); + target.dispatchEvent(pointerEvent("pointerup", 50, 70)); + }); + + act(() => + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })) + ); + expect( + container.querySelector("[data-testid='selection']")?.textContent + ).toBe("none"); + expect(onRequestDisable).not.toHaveBeenCalled(); + + act(() => + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })) + ); + expect(onRequestDisable).toHaveBeenCalledTimes(1); + }); + + it("owns listeners only while Design mode is enabled", () => { + const { inspectRoot } = mountHarness(false); + const addListener = vi.spyOn(inspectRoot, "addEventListener"); + const removeListener = vi.spyOn(inspectRoot, "removeEventListener"); + const removeWindowListener = vi.spyOn(window, "removeEventListener"); + const cancelAnimationFrame = vi.spyOn(window, "cancelAnimationFrame"); + + act(() => root.render(createElement(Harness, { enabled: true }))); + expect(addListener).toHaveBeenCalledWith( + "pointerdown", + expect.any(Function), + true + ); + expect(addListener).toHaveBeenCalledWith( + "scroll", + expect.any(Function), + true + ); + act(() => + inspectRoot.dispatchEvent(new Event("scroll", { bubbles: true })) + ); + + act(() => root.render(createElement(Harness, { enabled: false }))); + expect(removeListener).toHaveBeenCalledWith( + "pointerdown", + expect.any(Function), + true + ); + expect(removeListener).toHaveBeenCalledWith( + "scroll", + expect.any(Function), + true + ); + expect(removeWindowListener).toHaveBeenCalledWith( + "resize", + expect.any(Function) + ); + expect(removeWindowListener).toHaveBeenCalledWith( + "keydown", + expect.any(Function), + true + ); + expect(resizeObserverDisconnect).toHaveBeenCalled(); + expect(cancelAnimationFrame).toHaveBeenCalled(); + }); +}); diff --git a/src/engines/Simulator/apps/canvas/design/useCanvasDesignInspector.ts b/src/engines/Simulator/apps/canvas/design/useCanvasDesignInspector.ts new file mode 100644 index 000000000..245c721f0 --- /dev/null +++ b/src/engines/Simulator/apps/canvas/design/useCanvasDesignInspector.ts @@ -0,0 +1,375 @@ +import { + useCallback, + useEffect, + useEffectEvent, + useRef, + useState, +} from "react"; + +import type { DomSelectionRect } from "@src/features/DomSelection/types"; + +import { + type CanvasDesignSelection, + captureCanvasElement, + captureCanvasRegion, + elementFromComposedPath, +} from "./canvasDomCapture"; + +const DRAG_THRESHOLD = 5; + +interface Point { + x: number; + y: number; +} + +interface DragState { + pointerId: number; + start: Point; + latest: Point; + target: HTMLElement; +} + +export interface CanvasDesignInspectorState { + hovered: CanvasDesignSelection | null; + selected: CanvasDesignSelection | null; + marquee: DomSelectionRect | null; + rootRect: DomSelectionRect; + rootSize: { width: number; height: number }; + promptOpen: boolean; +} + +export interface UseCanvasDesignInspectorResult extends CanvasDesignInspectorState { + clearSelection: () => void; + closePrompt: () => void; +} + +function distance(a: Point, b: Point): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +function regionFromPoints(a: Point, b: Point): DomSelectionRect { + return { + x: Math.min(a.x, b.x), + y: Math.min(a.y, b.y), + width: Math.abs(a.x - b.x), + height: Math.abs(a.y - b.y), + }; +} + +function localizeRect( + rect: DomSelectionRect, + rootRect: DOMRect +): DomSelectionRect { + return { + x: rect.x - rootRect.left, + y: rect.y - rootRect.top, + width: rect.width, + height: rect.height, + }; +} + +function viewportRect(rect: DOMRect): DomSelectionRect { + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }; +} + +function localizeSelection( + selection: CanvasDesignSelection, + rootRect: DOMRect +): CanvasDesignSelection { + return { + ...selection, + rect: localizeRect(selection.rect, rootRect), + elementInfo: { + ...selection.elementInfo, + rect: localizeRect(selection.elementInfo.rect, rootRect), + }, + targets: selection.targets?.map((target) => ({ + ...target, + rect: localizeRect(target.rect, rootRect), + })), + }; +} + +export function useCanvasDesignInspector( + rootRef: React.RefObject, + enabled: boolean, + onRequestDisable: () => void +): UseCanvasDesignInspectorResult { + const [state, setState] = useState({ + hovered: null, + selected: null, + marquee: null, + rootRect: { x: 0, y: 0, width: 0, height: 0 }, + rootSize: { width: 0, height: 0 }, + promptOpen: false, + }); + const hoveredElementRef = useRef(null); + const selectedElementRef = useRef(null); + const dragRef = useRef(null); + const hasSelectionRef = useRef(false); + const requestDisable = useEffectEvent(onRequestDisable); + + const clearSelection = useCallback(() => { + selectedElementRef.current = null; + hasSelectionRef.current = false; + setState((current) => ({ + ...current, + selected: null, + marquee: null, + promptOpen: false, + })); + }, []); + + const closePrompt = useCallback(() => { + setState((current) => ({ ...current, promptOpen: false })); + }, []); + + useEffect(() => { + const root = rootRef.current; + if (!enabled || !root) { + hoveredElementRef.current = null; + selectedElementRef.current = null; + dragRef.current = null; + hasSelectionRef.current = false; + return; + } + const activeRoot = root; + + let frameId: number | null = null; + const resizeObserver = new ResizeObserver(() => scheduleGeometryRefresh()); + + const refreshGeometry = () => { + frameId = null; + const rootRect = activeRoot.getBoundingClientRect(); + const hoveredElement = hoveredElementRef.current; + const selectedElement = selectedElementRef.current; + setState((current) => { + const refreshSelectionRect = ( + selection: CanvasDesignSelection, + element: HTMLElement + ) => { + const fresh = captureCanvasElement(element, { + includePreview: false, + }); + return localizeSelection( + { + ...selection, + rect: fresh.rect, + elementInfo: { + ...selection.elementInfo, + rect: fresh.elementInfo.rect, + }, + }, + rootRect + ); + }; + const hovered = + hoveredElement?.isConnected && current.hovered + ? refreshSelectionRect(current.hovered, hoveredElement) + : null; + const selected = + selectedElement?.isConnected && current.selected + ? refreshSelectionRect(current.selected, selectedElement) + : current.selected?.kind === "region" + ? current.selected + : null; + return { + ...current, + hovered, + selected, + rootRect: viewportRect(rootRect), + rootSize: { width: rootRect.width, height: rootRect.height }, + }; + }); + }; + + function scheduleGeometryRefresh() { + if (frameId !== null) return; + frameId = window.requestAnimationFrame(refreshGeometry); + } + + const updateHover = (event: PointerEvent) => { + if (dragRef.current) return; + const element = elementFromComposedPath(event, root); + if (!element || element === hoveredElementRef.current) return; + hoveredElementRef.current = element; + resizeObserver.disconnect(); + resizeObserver.observe(root); + resizeObserver.observe(element); + if (selectedElementRef.current) { + resizeObserver.observe(selectedElementRef.current); + } + const rootRect = activeRoot.getBoundingClientRect(); + const hovered = localizeSelection( + captureCanvasElement(element, { includePreview: false }), + rootRect + ); + setState((current) => ({ + ...current, + hovered, + rootRect: viewportRect(rootRect), + rootSize: { width: rootRect.width, height: rootRect.height }, + })); + }; + + const handlePointerDown = (event: PointerEvent) => { + if (event.button !== 0) return; + const target = elementFromComposedPath(event, root); + if (!target) return; + event.preventDefault(); + event.stopImmediatePropagation(); + const point = { x: event.clientX, y: event.clientY }; + dragRef.current = { + pointerId: event.pointerId, + start: point, + latest: point, + target, + }; + setState((current) => ({ ...current, marquee: null })); + window.addEventListener("pointermove", handleDragMove, true); + window.addEventListener("pointerup", finishPointer, true); + window.addEventListener("pointercancel", cancelPointer, true); + }; + + const releaseDragListeners = () => { + window.removeEventListener("pointermove", handleDragMove, true); + window.removeEventListener("pointerup", finishPointer, true); + window.removeEventListener("pointercancel", cancelPointer, true); + }; + + const handleRootPointerMove = (event: PointerEvent) => { + if (dragRef.current) return; + updateHover(event); + }; + + function handleDragMove(event: PointerEvent) { + const drag = dragRef.current; + if (!drag) return; + if (drag.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopImmediatePropagation(); + drag.latest = { x: event.clientX, y: event.clientY }; + if (distance(drag.start, drag.latest) < DRAG_THRESHOLD) return; + const rootRect = activeRoot.getBoundingClientRect(); + const region = localizeRect( + regionFromPoints(drag.start, drag.latest), + rootRect + ); + setState((current) => ({ ...current, marquee: region })); + } + + function finishPointer(event: PointerEvent) { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopImmediatePropagation(); + const finish = { x: event.clientX, y: event.clientY }; + const rootRect = activeRoot.getBoundingClientRect(); + const isRegion = distance(drag.start, finish) >= DRAG_THRESHOLD; + let selected: CanvasDesignSelection | null = null; + try { + if (isRegion) { + const viewportRegion = regionFromPoints(drag.start, finish); + selected = captureCanvasRegion(activeRoot, viewportRegion); + } else { + selected = captureCanvasElement(drag.target); + } + } catch { + // A visual preview is enrichment, not a selection gate. Some Canvas + // trees expose styles that WebKit cannot serialize; keep the editor + // reachable with the already-inspectable DOM metadata in that case. + if (!isRegion) { + try { + selected = captureCanvasElement(drag.target, { + includePreview: false, + }); + } catch { + selected = null; + } + } + } + if (selected) selected = localizeSelection(selected, rootRect); + dragRef.current = null; + releaseDragListeners(); + if (!selected) { + setState((current) => ({ ...current, marquee: null })); + return; + } + selectedElementRef.current = isRegion ? null : drag.target; + hasSelectionRef.current = true; + setState((current) => ({ + ...current, + selected, + hovered: selected, + marquee: null, + promptOpen: true, + rootRect: viewportRect(rootRect), + rootSize: { width: rootRect.width, height: rootRect.height }, + })); + } + + const blockClick = (event: MouseEvent) => { + if (!elementFromComposedPath(event, root)) return; + event.preventDefault(); + event.stopImmediatePropagation(); + }; + + function cancelPointer(event: PointerEvent) { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopImmediatePropagation(); + dragRef.current = null; + releaseDragListeners(); + setState((current) => ({ ...current, marquee: null })); + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + if (hasSelectionRef.current) { + clearSelection(); + } else { + requestDisable(); + } + }; + + const handleScrollOrResize = () => scheduleGeometryRefresh(); + const initialRect = root.getBoundingClientRect(); + setState((current) => ({ + ...current, + rootRect: viewportRect(initialRect), + rootSize: { width: initialRect.width, height: initialRect.height }, + })); + resizeObserver.observe(root); + root.addEventListener("pointerdown", handlePointerDown, true); + root.addEventListener("pointermove", handleRootPointerMove, true); + root.addEventListener("click", blockClick, true); + root.addEventListener("scroll", handleScrollOrResize, true); + window.addEventListener("resize", handleScrollOrResize); + window.addEventListener("keydown", handleKeyDown, true); + + return () => { + root.removeEventListener("pointerdown", handlePointerDown, true); + root.removeEventListener("pointermove", handleRootPointerMove, true); + releaseDragListeners(); + root.removeEventListener("click", blockClick, true); + root.removeEventListener("scroll", handleScrollOrResize, true); + window.removeEventListener("resize", handleScrollOrResize); + window.removeEventListener("keydown", handleKeyDown, true); + resizeObserver.disconnect(); + if (frameId !== null) window.cancelAnimationFrame(frameId); + hoveredElementRef.current = null; + selectedElementRef.current = null; + dragRef.current = null; + hasSelectionRef.current = false; + }; + }, [clearSelection, enabled, rootRef]); + + return { ...state, clearSelection, closePrompt }; +} diff --git a/src/features/DomSelection/CanvasDomComponentPreview.tsx b/src/features/DomSelection/CanvasDomComponentPreview.tsx new file mode 100644 index 000000000..d3a99bf62 --- /dev/null +++ b/src/features/DomSelection/CanvasDomComponentPreview.tsx @@ -0,0 +1,55 @@ +import React, { memo, useMemo } from "react"; + +import { IFRAME_STYLE_NONCE } from "@src/util/iframeCspNonce"; + +import { parseCanvasDomComponent } from "./domComponentPayload"; +import { sanitizeDomPreviewHtml } from "./domPreviewHtml"; + +interface CanvasDomComponentPreviewProps { + jsonText: string; +} + +function buildPreviewDocument(previewHtml: string): string { + return ` + + + + + +
${previewHtml}
+`; +} + +const CanvasDomComponentPreview: React.FC = + memo(({ jsonText }) => { + const srcDoc = useMemo(() => { + const parsed = parseCanvasDomComponent(jsonText); + if (!parsed?.previewHtml) return null; + return buildPreviewDocument( + sanitizeDomPreviewHtml(parsed.previewHtml, 32_000) + ); + }, [jsonText]); + + if (!srcDoc) return null; + + return ( +
+