diff --git a/apps/mesh/src/harnesses/decopilot/build-agent-system-prompt.ts b/apps/mesh/src/harnesses/decopilot/build-agent-system-prompt.ts index 7938b21c9c..88c8c79776 100644 --- a/apps/mesh/src/harnesses/decopilot/build-agent-system-prompt.ts +++ b/apps/mesh/src/harnesses/decopilot/build-agent-system-prompt.ts @@ -199,6 +199,16 @@ export async function buildAgentSystemPrompt( add("todoWrite", buildTodoWritePrompt()); + add( + "openPreview", + ` +After writing or editing an HTML page or deck in the org home volume \ +(e.g. \`pages/landing.html\` or \`decks/q3.html\`), call \`open\` with the \ +home-volume-relative path so the user sees the result immediately in the \ +preview panel. Pass only the path relative to the home volume — no leading slash. +`, + ); + // Attached files/skills ride along in agentInstructions (folded in by the // passthrough client's getInstructions), so they reach both the cluster and // sandbox-daemon run paths uniformly — no separate block here. diff --git a/apps/mesh/src/harnesses/decopilot/built-in-tools/index.ts b/apps/mesh/src/harnesses/decopilot/built-in-tools/index.ts index 27befec18a..a6e2b0e274 100644 --- a/apps/mesh/src/harnesses/decopilot/built-in-tools/index.ts +++ b/apps/mesh/src/harnesses/decopilot/built-in-tools/index.ts @@ -26,6 +26,7 @@ const BUILTIN_TOOL_ANNOTATIONS: Record< web_search: { readOnly: true, destructive: false }, generate_image: { readOnly: false, destructive: false }, open_in_agent: { readOnly: false, destructive: false }, + open: { readOnly: true, destructive: false }, subtask: { readOnly: false, destructive: false }, user_ask: { readOnly: true, destructive: false }, propose_plan: { readOnly: true, destructive: false }, @@ -34,6 +35,7 @@ const BUILTIN_TOOL_ANNOTATIONS: Record< update_interests: { readOnly: false, destructive: false }, }; import { createReadToolOutputTool } from "@decocms/harness/decopilot/built-in-tools/read-tool-output"; +import { createOpenTool } from "@decocms/harness/decopilot/built-in-tools/open"; import { type VirtualClient } from "@decocms/harness/decopilot/built-in-tools/sandbox"; import { createVmTools } from "@decocms/harness/decopilot/built-in-tools/vm-tools/index"; import type { HtmlArtifactBuffer } from "@decocms/harness/decopilot/built-in-tools/vm-tools/types"; @@ -178,6 +180,9 @@ async function buildAllTools( isPlanMode, objectStorage: ctx.objectStorage, }); + // open() is Studio-specific (navigates the React preview panel via + // data-open-preview) — registered here, not in portable-built-ins. + tools.open = createOpenTool(writer); if (userId) { // Cluster `interests.write` hook: closes over ctx/storage and forwards the // org/agent/user carried in the InterestsWrite payload. The tool itself no @@ -349,6 +354,7 @@ async function buildAllTools( update_interests: ReturnType; subtask: ReturnType; read_tool_output: ReturnType; + open: ReturnType; generate_image: ReturnType; web_search: ReturnType; take_screenshot: ReturnType; diff --git a/apps/mesh/src/web/components/chat/chat-context.tsx b/apps/mesh/src/web/components/chat/chat-context.tsx index 99f1145528..0a3ca400fd 100644 --- a/apps/mesh/src/web/components/chat/chat-context.tsx +++ b/apps/mesh/src/web/components/chat/chat-context.tsx @@ -867,6 +867,32 @@ export function ActiveTaskProvider({ }); return; } + // open() tool: agent explicitly requested preview of a file. Invalidate + // the stat cache (same as data-deck-updated so the iframe renders fresh) + // then navigate. Emitted without an `id` so this chunk is NOT persisted + // as a message part and won't re-trigger navigation on reload. + if (chunk.type === "data-open-preview") { + const data = (chunk as unknown as { data: { filepath?: string } }) + .data; + if (!data?.filepath) return; + const filepath = data.filepath; + const cb = cbRef.current; + cb.queryClient.invalidateQueries({ + queryKey: KEYS.orgFsStat(cb.orgId, "home", filepath), + }); + cb.queryClient.invalidateQueries({ + queryKey: KEYS.orgFsRecent(cb.orgId), + }); + cb.navigate({ + to: ".", + search: (prev: Record) => ({ + ...prev, + main: formatDeckTabId(filepath), + }), + replace: true, + }); + return; + } // Deck preview (slides skill): the harness emits `data-deck-updated` // when `decks/.html` changes in the org home volume. Refresh // the stat (rolls the deck tab's cache-busted iframe src) and diff --git a/apps/mesh/src/web/components/chat/message/assistant.tsx b/apps/mesh/src/web/components/chat/message/assistant.tsx index 4a6e71f5a2..d5cf68cae1 100644 --- a/apps/mesh/src/web/components/chat/message/assistant.tsx +++ b/apps/mesh/src/web/components/chat/message/assistant.tsx @@ -533,6 +533,8 @@ function MessagePart({ return null; case "tool-update_interests": return null; + case "tool-open": + return null; case "tool-user_ask": return ( = { // System tools enable_tool: { icon: Tool01, label: "Enable Tool" }, open_in_agent: { icon: Server01, label: "Open in Agent" }, + open: { icon: Eye, label: "Open Preview" }, // Sandbox / code execution tools sandbox: { icon: Code02, label: "Run Code" }, diff --git a/apps/mesh/src/web/layouts/library/index.tsx b/apps/mesh/src/web/layouts/library/index.tsx index ab9b06f56f..28b9d5f3d7 100644 --- a/apps/mesh/src/web/layouts/library/index.tsx +++ b/apps/mesh/src/web/layouts/library/index.tsx @@ -11,7 +11,7 @@ import { useRef, useState } from "react"; import type { ComponentType, SVGProps } from "react"; -import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { useProjectContext } from "@decocms/mesh-sdk"; import { toast } from "sonner"; @@ -20,6 +20,7 @@ import { Eye, Globe01, Home01, + MessageCircle01, Plus, RefreshCw01, Stars01, @@ -51,7 +52,15 @@ import { homeMountPath } from "@/file-storage/home-mount"; import { ErrorBoundary } from "@/web/components/error-boundary"; import { FileTypeIcon } from "@/web/components/file-type-icon"; import { Toolbar } from "@/web/layouts/agent-shell-layout/toolbar"; +import { ToolbarIconButton } from "@/web/components/toolbar-icon-button"; import { HeaderTabButton } from "@/web/layouts/main-panel-tabs/header-tab-button"; +import { readLastLocation } from "@/web/lib/last-location"; +import { formatDeckTabId } from "@/web/layouts/main-panel-tabs/tab-id"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; import { KEYS } from "@/web/lib/query-keys"; import { type OrgFsEntry, @@ -696,9 +705,13 @@ function LibraryPage() { ); } +const isHtmlPath = (name: string) => /\.html?$/i.test(name); + export default function Library() { const isMobile = useIsMobile(); const navigate = useNavigate(); + const urlParams = useParams({ strict: false }) as { org?: string }; + const orgSlug = urlParams.org ?? ""; const search = useSearch({ strict: false }) as { preview?: string }; const previewPath = search.preview; const previewName = previewPath ? basename(previewPath) : ""; @@ -711,6 +724,36 @@ export default function Library() { }), }); + // Show a chat button in the top-left Toggles slot when a home-volume HTML + // file is being previewed, so the user can jump straight to the agent chat. + const previewLocation = previewPath ? parseLibraryPath(previewPath) : null; + const showChatToggle = + !isMobile && + !!previewPath && + previewLocation?.volume === "home" && + isHtmlPath(previewName); + + const openInChat = () => { + if (!previewLocation) return; + const lastLoc = readLastLocation(); + const sameOrg = lastLoc?.org === orgSlug; + const taskId = + sameOrg && lastLoc?.taskId ? lastLoc.taskId : crypto.randomUUID(); + const chatSearch: Record = { + chat: 1, + // Keep the asset visible in the deck tab so it stays open alongside chat. + main: formatDeckTabId(previewLocation.dirPath), + }; + if (sameOrg && lastLoc?.virtualmcpid) { + chatSearch.virtualmcpid = lastLoc.virtualmcpid; + } + navigate({ + to: "/$org/$taskId", + params: { org: orgSlug, taskId }, + search: () => chatSearch, + }); + }; + if (isMobile) { return (
@@ -754,6 +797,21 @@ export default function Library() { onClick={closePreview} /> + {showChatToggle && ( + + + + + + + + Open in chat + + + )}
diff --git a/apps/mesh/src/web/layouts/main-panel-tabs/tab-id.test.ts b/apps/mesh/src/web/layouts/main-panel-tabs/tab-id.test.ts index 46288df90b..106bce9867 100644 --- a/apps/mesh/src/web/layouts/main-panel-tabs/tab-id.test.ts +++ b/apps/mesh/src/web/layouts/main-panel-tabs/tab-id.test.ts @@ -79,8 +79,8 @@ describe("deck tab id", () => { expect(parseDeckTabId("deck:%E0%A4%A")).toBeNull(); }); - test("deck tabs are per-thread", () => { - expect(isPerThreadTab(formatDeckTabId("decks/a.html"))).toBe(true); + test("deck tabs are NOT per-thread (org home volume survives task switches)", () => { + expect(isPerThreadTab(formatDeckTabId("decks/a.html"))).toBe(false); }); }); diff --git a/apps/mesh/src/web/layouts/main-panel-tabs/tab-id.ts b/apps/mesh/src/web/layouts/main-panel-tabs/tab-id.ts index 2379aaf752..fc4b554c5c 100644 --- a/apps/mesh/src/web/layouts/main-panel-tabs/tab-id.ts +++ b/apps/mesh/src/web/layouts/main-panel-tabs/tab-id.ts @@ -135,14 +135,16 @@ const FIXED_SYSTEM_TAB_SET = new Set(FIXED_SYSTEM_TABS); * - "app::" (expanded tool / pinned view) * - "automation:" (ephemeral automation detail) * - "file:" (ephemeral thread-output file preview) - * - "deck:" (ephemeral HTML-artifact preview/editor) + * + * Note: "deck:" is intentionally NOT per-thread — deck files live + * in the org home volume and outlive any thread, so the preview should persist + * when the user starts a new chat to continue working on the same page. */ export function isPerThreadTab(tabId: string): boolean { return ( tabId.startsWith("app:") || tabId.startsWith("automation:") || - tabId.startsWith("file:") || - tabId.startsWith("deck:") + tabId.startsWith("file:") ); } diff --git a/packages/harness/src/decopilot/built-in-tools/open.ts b/packages/harness/src/decopilot/built-in-tools/open.ts new file mode 100644 index 0000000000..39fb0ff14b --- /dev/null +++ b/packages/harness/src/decopilot/built-in-tools/open.ts @@ -0,0 +1,41 @@ +/** + * open — tells the Studio UI to open a file in the preview panel. + * + * The tool itself has no server-side side-effects: it emits a transient + * `data-open-preview` stream part that the React client picks up in onChunk + * to navigate the preview panel, then returns immediately. + */ + +import { tool, type UIMessageStreamWriter } from "ai"; +import { z } from "zod"; + +export const OpenInputSchema = z.object({ + filepath: z + .string() + .describe( + "Home-volume-relative path of the HTML file to open in the preview panel " + + "(e.g. `pages/landing.html` or `decks/q3-launch.html`).", + ), +}); + +export function createOpenTool(writer: UIMessageStreamWriter) { + return tool({ + description: + "Open an HTML file in the Studio preview panel so the user can see it. " + + "Call this after creating or editing a page or deck. " + + "Pass the home-volume-relative path, e.g. `pages/landing.html`.", + inputSchema: OpenInputSchema, + execute: async ({ filepath }) => { + // Strip any leading slash so "pages/landing.html" and + // "/pages/landing.html" both produce the same deck tab id. + const normalizedPath = filepath.replace(/^\/+/, ""); + // Emitted without an `id` so the part is NOT persisted in the message — + // this is a one-shot navigation signal, not durable message state. + writer.write({ + type: "data-open-preview", + data: { filepath: normalizedPath }, + }); + return { success: true }; + }, + }); +}