Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/mesh/src/harnesses/decopilot/build-agent-system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,16 @@ export async function buildAgentSystemPrompt(

add("todoWrite", buildTodoWritePrompt());

add(
"openPreview",
`<open-preview>
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.
</open-preview>`,
);

// 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.
Expand Down
6 changes: 6 additions & 0 deletions apps/mesh/src/harnesses/decopilot/built-in-tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -349,6 +354,7 @@ async function buildAllTools(
update_interests: ReturnType<typeof createUpdateInterestsTool>;
subtask: ReturnType<typeof createSubtaskTool>;
read_tool_output: ReturnType<typeof createReadToolOutputTool>;
open: ReturnType<typeof createOpenTool>;
generate_image: ReturnType<typeof createGenerateImageTool>;
web_search: ReturnType<typeof createWebSearchTool>;
take_screenshot: ReturnType<typeof createTakeScreenshotTool>;
Expand Down
26 changes: 26 additions & 0 deletions apps/mesh/src/web/components/chat/chat-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => ({
...prev,
main: formatDeckTabId(filepath),
}),
replace: true,
});
return;
}
// Deck preview (slides skill): the harness emits `data-deck-updated`
// when `decks/<name>.html` changes in the org home volume. Refresh
// the stat (rolls the deck tab's cache-busted iframe src) and
Expand Down
2 changes: 2 additions & 0 deletions apps/mesh/src/web/components/chat/message/assistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,8 @@ function MessagePart({
return null;
case "tool-update_interests":
return null;
case "tool-open":
return null;
case "tool-user_ask":
return (
<UserAskPart
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Download01,
Edit01,
Edit02,
Eye,
File06,
Folder,
Globe02,
Expand Down Expand Up @@ -48,6 +49,7 @@ export const TOOL_DISPLAY_MAP: Record<string, ToolDisplay> = {
// 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" },
Expand Down
60 changes: 59 additions & 1 deletion apps/mesh/src/web/layouts/library/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -20,6 +20,7 @@ import {
Eye,
Globe01,
Home01,
MessageCircle01,
Plus,
RefreshCw01,
Stars01,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) : "";
Expand All @@ -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<string, unknown> = {
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 (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
Expand Down Expand Up @@ -754,6 +797,21 @@ export default function Library() {
onClick={closePreview}
/>
</Toolbar.Tabs>
{showChatToggle && (
<Toolbar.Toggles>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<ToolbarIconButton
onClick={openInChat}
aria-label="Open in chat"
>
<MessageCircle01 size={16} />
</ToolbarIconButton>
</TooltipTrigger>
<TooltipContent side="bottom">Open in chat</TooltipContent>
</Tooltip>
</Toolbar.Toggles>
)}
<ResizableHandle className="bg-sidebar" />
<ResizablePanel order={2} minSize={25} defaultSize={45}>
<div className="h-full p-0.5 pt-0.25">
Expand Down
4 changes: 2 additions & 2 deletions apps/mesh/src/web/layouts/main-panel-tabs/tab-id.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
8 changes: 5 additions & 3 deletions apps/mesh/src/web/layouts/main-panel-tabs/tab-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,16 @@ const FIXED_SYSTEM_TAB_SET = new Set<string>(FIXED_SYSTEM_TABS);
* - "app:<connectionId>:<toolName>" (expanded tool / pinned view)
* - "automation:<id>" (ephemeral automation detail)
* - "file:<encoded key>" (ephemeral thread-output file preview)
* - "deck:<encoded path>" (ephemeral HTML-artifact preview/editor)
*
* Note: "deck:<encoded path>" 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:")
);
}

Expand Down
41 changes: 41 additions & 0 deletions packages/harness/src/decopilot/built-in-tools/open.ts
Original file line number Diff line number Diff line change
@@ -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 };
},
});
}
Loading