From 1d81860633a7a438a131d23a304bcd252fb4c47c Mon Sep 17 00:00:00 2001 From: Wenqiang Wei <46308778+endxxxx@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:36:39 +0800 Subject: [PATCH 1/2] feat(desktop): add native workspace artifact previews Co-authored-by: Cursor --- App/backend/local-api-contracts/src/plugin.ts | 4 +- .../local-api-contracts/tests/plugin.test.ts | 19 +- .../http-memmy-agent-admin-client.ts | 22 ++ .../memmy-agent-admin-client/index.ts | 2 + .../http-memmy-agent-admin-client.test.ts | 12 + App/backend/src/services/index.ts | 16 +- .../services/plugin-local-artifact-service.ts | 109 ++++++- App/backend/src/services/plugin-service.ts | 42 ++- .../plugin-local-artifact-service.test.ts | 36 ++- .../src/services/tests/plugin-service.test.ts | 58 +++- .../desktop/src/api/memmy-agent-client.ts | 3 +- .../lib/tests/workspace-relative-path.test.ts | 34 ++ .../src/lib/workspace-relative-path.ts | 60 ++++ .../src/pages/agent-message-content.tsx | 15 + .../pages/file-preview/pdf-preview-state.ts | 24 ++ .../src/pages/file-preview/pdf-preview.tsx | 103 ++++-- App/frontend/desktop/src/pages/home-page.tsx | 87 +++++- .../tests/agent-thread-messages.test.tsx | 20 ++ .../src/pages/tests/home-page.test.tsx | 13 + .../src/pages/tests/pdf-preview.test.ts | 41 +++ ...kspace-artifact-panel.interaction.test.tsx | 68 +++- .../src/pages/workspace-artifact-panel.tsx | 295 +++++++++++------- App/frontend/desktop/src/styles.css | 124 ++++---- .../src/integrations/channels/websocket.ts | 5 + .../channels/websocket-http-routes.test.ts | 20 +- 25 files changed, 994 insertions(+), 238 deletions(-) create mode 100644 App/frontend/desktop/src/lib/tests/workspace-relative-path.test.ts create mode 100644 App/frontend/desktop/src/lib/workspace-relative-path.ts diff --git a/App/backend/local-api-contracts/src/plugin.ts b/App/backend/local-api-contracts/src/plugin.ts index 8eed394da..c783a75c9 100644 --- a/App/backend/local-api-contracts/src/plugin.ts +++ b/App/backend/local-api-contracts/src/plugin.ts @@ -264,7 +264,9 @@ export const PluginArtifactRefSchema = z.object({ name: z.string().trim().min(1), mediaType: z.string().trim().min(1), uri: z.string().trim().min(1), - downloadUri: z.string().trim().min(1).optional() + downloadUri: z.string().trim().min(1).optional(), + /** Absolute Host-published path when the artifact also belongs to the conversation workspace. */ + path: z.string().trim().min(1).optional() }); export type PluginArtifactRef = z.infer; diff --git a/App/backend/local-api-contracts/tests/plugin.test.ts b/App/backend/local-api-contracts/tests/plugin.test.ts index f658a389d..7097705de 100644 --- a/App/backend/local-api-contracts/tests/plugin.test.ts +++ b/App/backend/local-api-contracts/tests/plugin.test.ts @@ -149,12 +149,25 @@ describe("CapabilityEventSchema", () => { }); it("wraps generic plugin events with call routing context", () => { - expect(PluginCapabilityEventPayloadSchema.parse({ + const parsed = PluginCapabilityEventPayloadSchema.parse({ pluginId: manifest.id, capabilityId: "review", callId: "call-1", conversationId: "conversation-1", - event: { type: "artifact", artifact: { id: "report", name: "report.md", mediaType: "text/markdown", uri: "file:///report.md" } } - }).event.type).toBe("artifact"); + event: { + type: "artifact", + artifact: { + id: "report", + name: "report.md", + mediaType: "text/markdown", + uri: "/api/v1/plugins/review/artifacts/token/preview", + path: "/workspace/outputs/review/task-1/report.md" + } + } + }); + expect(parsed.event.type).toBe("artifact"); + if (parsed.event.type === "artifact") { + expect(parsed.event.artifact.path).toBe("/workspace/outputs/review/task-1/report.md"); + } }); }); diff --git a/App/backend/src/adapters/outbound/memmy-agent-admin-client/http-memmy-agent-admin-client.ts b/App/backend/src/adapters/outbound/memmy-agent-admin-client/http-memmy-agent-admin-client.ts index 6880fe990..9ce31850d 100644 --- a/App/backend/src/adapters/outbound/memmy-agent-admin-client/http-memmy-agent-admin-client.ts +++ b/App/backend/src/adapters/outbound/memmy-agent-admin-client/http-memmy-agent-admin-client.ts @@ -36,6 +36,12 @@ const McpReloadResponseSchema = z.object({ requires_restart: z.boolean() }); +const WorkspaceEnvironmentResponseSchema = z.object({ + snapshot: z.object({ + cwd: z.string() + }) +}); + export interface CreateHttpMemmyAgentAdminClientOptions { /** Memmy-agent WebUI HTTP base URL. */ baseUrl?: string; @@ -71,6 +77,15 @@ class HttpMemmyAgentAdminClient implements MemmyAgentAdminClient { return this.request("/api/channels/status", ChannelConnectionsResponseSchema); } + async getSessionWorkspace(sessionKey: string): Promise { + const normalizedSessionKey = normalizeGuiSessionKey(sessionKey); + const environment = await this.request( + `/api/sessions/${encodeURIComponent(normalizedSessionKey)}/environment`, + WorkspaceEnvironmentResponseSchema + ); + return environment.snapshot.cwd.trim() || null; + } + async configureChannel(runtimeChannel: string) { return this.request(`/api/channels/${encodeURIComponent(runtimeChannel)}/configure`, ChannelRuntimeActionResponseSchema, { method: "POST" }); } @@ -140,6 +155,13 @@ class HttpMemmyAgentAdminClient implements MemmyAgentAdminClient { } } +function normalizeGuiSessionKey(value: string): string { + const sessionKey = value.trim(); + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(sessionKey) + ? `websocket:${sessionKey}` + : sessionKey; +} + function normalizeBaseUrl(value: string): string { return value.replace(/\/+$/g, "") || DEFAULT_MEMMY_AGENT_ADMIN_BASE_URL; } diff --git a/App/backend/src/adapters/outbound/memmy-agent-admin-client/index.ts b/App/backend/src/adapters/outbound/memmy-agent-admin-client/index.ts index 6b52390a4..c5605d12d 100644 --- a/App/backend/src/adapters/outbound/memmy-agent-admin-client/index.ts +++ b/App/backend/src/adapters/outbound/memmy-agent-admin-client/index.ts @@ -8,6 +8,8 @@ import type { export interface MemmyAgentAdminClient { getChannelDefinitions(): Promise; getChannelConnections(): Promise; + /** Returns the canonical workspace currently bound to a WebUI conversation. */ + getSessionWorkspace?(sessionKey: string): Promise; configureChannel(runtimeChannel: string): Promise<{ status: ChannelStatus; running: boolean }>; stopChannel(runtimeChannel: string): Promise<{ status: ChannelStatus; running: boolean }>; startWeixinLogin(): Promise<{ status: ChannelStatus; qrCodeDataUrl?: string; pollToken?: string }>; diff --git a/App/backend/src/adapters/outbound/memmy-agent-admin-client/tests/http-memmy-agent-admin-client.test.ts b/App/backend/src/adapters/outbound/memmy-agent-admin-client/tests/http-memmy-agent-admin-client.test.ts index eaafc8b02..82c01bd04 100644 --- a/App/backend/src/adapters/outbound/memmy-agent-admin-client/tests/http-memmy-agent-admin-client.test.ts +++ b/App/backend/src/adapters/outbound/memmy-agent-admin-client/tests/http-memmy-agent-admin-client.test.ts @@ -54,6 +54,14 @@ describe("http memmy-agent admin client", () => { }); return; } + if (request.url === "/api/sessions/websocket%3Achat-1/environment") { + sendJson(response, { snapshot: { cwd: "/Users/test/workspace" } }); + return; + } + if (request.url === "/api/sessions/websocket%3A127c0697-c224-47e0-a032-684c7c1c2abb/environment") { + sendJson(response, { snapshot: { cwd: "/Users/test/standalone" } }); + return; + } if (request.url === "/api/channels/feishu/configure") { sendJson(response, { status: "connected", running: true }); return; @@ -78,6 +86,8 @@ describe("http memmy-agent admin client", () => { await expect(client.getChannelConnections()).resolves.toEqual({ connections: [{ id: "channel-wechat-local", provider: "wechat", runtimeChannel: "weixin", status: "connected", running: true, displayName: "WeChat" }] }); + await expect(client.getSessionWorkspace?.("websocket:chat-1")).resolves.toBe("/Users/test/workspace"); + await expect(client.getSessionWorkspace?.("127c0697-c224-47e0-a032-684c7c1c2abb")).resolves.toBe("/Users/test/standalone"); await expect(client.configureChannel("feishu")).resolves.toEqual({ status: "connected", running: true }); await expect(client.startWeixinLogin()).resolves.toMatchObject({ status: "pendingQr", pollToken: "poll-1" }); await expect(client.reloadMcpConfig()).resolves.toEqual({ @@ -88,6 +98,8 @@ describe("http memmy-agent admin client", () => { expect(requests).toEqual([ { method: "GET", path: "/webui/bootstrap", authorization: undefined }, { method: "GET", path: "/api/channels/status", authorization: "Bearer boot-token" }, + { method: "GET", path: "/api/sessions/websocket%3Achat-1/environment", authorization: "Bearer boot-token" }, + { method: "GET", path: "/api/sessions/websocket%3A127c0697-c224-47e0-a032-684c7c1c2abb/environment", authorization: "Bearer boot-token" }, { method: "POST", path: "/api/channels/feishu/configure", authorization: "Bearer boot-token" }, { method: "POST", path: "/api/channels/weixin/login/start", authorization: "Bearer boot-token" }, { method: "POST", path: "/api/settings/mcp-presets/reload", authorization: "Bearer boot-token" } diff --git a/App/backend/src/services/index.ts b/App/backend/src/services/index.ts index 3fff00613..e3c468636 100644 --- a/App/backend/src/services/index.ts +++ b/App/backend/src/services/index.ts @@ -188,6 +188,9 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba invoker: createPluginAsrService({ asr: asrService, audioRoots: pluginFileInputRoots }) } ]); + const memmyAgentAdminClient = + options.memmyAgentAdminClient ?? + createHttpMemmyAgentAdminClient({ bootstrapSecret: options.memmyAgentAdminBootstrapSecret }); const pluginRuntimeHost = options.pluginRuntimeHost ?? createPluginRuntimeHost(new PluginAdapterRegistry([ createMcpPluginAdapter(), createHttpPluginAdapter(), @@ -211,7 +214,15 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba }), skillManager: createPluginSkillManager({ skillsRoot: join(resolveAgentWorkspace(process.env), "skills") }), localArtifactService: createPluginLocalArtifactService({ - pluginDataRoot: join(dirname(options.appStateStore.databasePath), "plugin-data") + pluginDataRoot: join(dirname(options.appStateStore.databasePath), "plugin-data"), + resolveWorkspace: async (conversationId) => { + try { + return await memmyAgentAdminClient.getSessionWorkspace?.(conversationId) ?? null; + } catch { + // Non-WebUI invocations do not have a conversation workspace to publish into. + return null; + } + } }), isEntitlementGranted }); @@ -226,9 +237,6 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba createSkillDistributionService({ targetRegistry: skillTargetRegistry }); - const memmyAgentAdminClient = - options.memmyAgentAdminClient ?? - createHttpMemmyAgentAdminClient({ bootstrapSecret: options.memmyAgentAdminBootstrapSecret }); const resolveAnalyticsUserId = () => { const session = accountSessionRepository.get(); if (!session.authenticated) return null; diff --git a/App/backend/src/services/plugin-local-artifact-service.ts b/App/backend/src/services/plugin-local-artifact-service.ts index 0c72fe524..6690c94ad 100644 --- a/App/backend/src/services/plugin-local-artifact-service.ts +++ b/App/backend/src/services/plugin-local-artifact-service.ts @@ -1,7 +1,8 @@ /** Validates plugin-produced local files and exposes opaque local API references. */ import { randomUUID } from "node:crypto"; -import { lstat, realpath } from "node:fs/promises"; -import { isAbsolute, relative, resolve, sep } from "node:path"; +import { constants as fsConstants } from "node:fs"; +import { copyFile, lstat, mkdir, realpath, rename, rm } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import type { PluginArtifactRef, PluginPermission } from "@memmy/local-api-contracts"; @@ -12,20 +13,32 @@ export interface HostedPluginArtifact { } export interface PluginLocalArtifactService { - host(plugin: { id: string; approvedPermissions: PluginPermission[]; config: Record }, artifact: PluginArtifactRef): Promise; + host( + plugin: { id: string; approvedPermissions: PluginPermission[]; config: Record }, + artifact: PluginArtifactRef, + context?: PluginArtifactHostContext + ): Promise; open(pluginId: string, token: string): Promise; revokePlugin(pluginId: string): void; } +export interface PluginArtifactHostContext { + conversationId: string; + taskId?: string; + callId: string; +} + export interface CreatePluginLocalArtifactServiceOptions { /** Host-owned parent directory containing one writable data directory per plugin. */ pluginDataRoot?: string; + /** Resolves the workspace currently bound to one Agent conversation. */ + resolveWorkspace?: (conversationId: string) => Promise; } export function createPluginLocalArtifactService(options: CreatePluginLocalArtifactServiceOptions = {}): PluginLocalArtifactService { const artifacts = new Map(); return { - async host(plugin, artifact) { + async host(plugin, artifact, context) { let url: URL; try { url = new URL(artifact.uri); @@ -56,7 +69,15 @@ export function createPluginLocalArtifactService(options: CreatePluginLocalArtif const token = randomUUID(); artifacts.set(token, { pluginId: plugin.id, path, name: artifact.name, mediaType: artifact.mediaType }); const base = `/api/v1/plugins/${encodeURIComponent(plugin.id)}/artifacts/${encodeURIComponent(token)}`; - return { ...artifact, uri: `${base}/preview`, downloadUri: `${base}/download` }; + const publishedPath = context + ? await publishArtifactToWorkspace(path, plugin.id, artifact.name, context, options.resolveWorkspace) + : null; + return { + ...artifact, + uri: `${base}/preview`, + downloadUri: `${base}/download`, + ...(publishedPath ? { path: publishedPath } : {}) + }; }, async open(pluginId, token) { @@ -74,6 +95,84 @@ export function createPluginLocalArtifactService(options: CreatePluginLocalArtif }; } +async function publishArtifactToWorkspace( + sourcePath: string, + pluginId: string, + artifactName: string, + context: PluginArtifactHostContext, + resolveWorkspace: CreatePluginLocalArtifactServiceOptions["resolveWorkspace"] +): Promise { + if (!resolveWorkspace || pluginId !== "literature-review" || !context.taskId) return null; + const workspacePath = await resolveWorkspace(context.conversationId); + if (!workspacePath) return null; + const workspace = await realpath(workspacePath); + const relativeName = safeArtifactRelativePath(artifactName); + const taskDirectory = safePathSegment(context.taskId, "task"); + const parent = await ensureSafeDirectory(workspace, [ + "outputs", + safePathSegment(pluginId, "plugin"), + taskDirectory, + ...relativeName.slice(0, -1) + ]); + const target = join(parent, relativeName.at(-1)!); + const temporary = `${target}.${randomUUID()}.tmp`; + try { + await copyFile(sourcePath, temporary, fsConstants.COPYFILE_EXCL); + try { + await rename(temporary, target); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST" && code !== "EPERM") throw error; + const existing = await lstat(target).catch(() => null); + if (existing?.isSymbolicLink() || (existing && !existing.isFile())) { + throw pluginArtifactError("Plugin artifact destination is not a regular workspace file"); + } + await rm(target, { force: true }); + await rename(temporary, target); + } + return await realpath(target); + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + } +} + +function safeArtifactRelativePath(value: string): string[] { + if (value.includes("\0") || value.includes("\\") || isAbsolute(value)) { + throw pluginArtifactError("Plugin artifact name is not a safe workspace-relative path"); + } + const parts = value.split("/"); + if (!parts.length || parts.some((part) => !part || part === "." || part === "..")) { + throw pluginArtifactError("Plugin artifact name is not a safe workspace-relative path"); + } + return parts; +} + +function safePathSegment(value: string, fallback: string): string { + const safe = value.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 160); + return safe && safe !== "." && safe !== ".." ? safe : fallback; +} + +async function ensureSafeDirectory(root: string, parts: string[]): Promise { + let current = root; + for (const part of parts) { + current = join(current, part); + const existing = await lstat(current).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (!existing) { + await mkdir(current); + } else if (!existing.isDirectory() || existing.isSymbolicLink()) { + throw pluginArtifactError("Plugin artifact workspace destination contains an unsafe path"); + } + const canonical = await realpath(current); + if (!isWithin(root, canonical)) { + throw pluginArtifactError("Plugin artifact workspace destination escapes the workspace"); + } + } + return current; +} + async function approvedFilesystemRoots( plugin: { id: string; approvedPermissions: PluginPermission[] }, pluginDataRoot?: string diff --git a/App/backend/src/services/plugin-service.ts b/App/backend/src/services/plugin-service.ts index f6ee0ec86..6905390bb 100644 --- a/App/backend/src/services/plugin-service.ts +++ b/App/backend/src/services/plugin-service.ts @@ -11,6 +11,7 @@ import { type UpdatePluginConfigInput } from "@memmy/local-api-contracts"; import { Ajv } from "ajv"; +import { fileURLToPath } from "node:url"; import type { PluginRegistry } from "../adapters/outbound/plugin-registry/index.js"; import type { PluginArtifactManager } from "../adapters/outbound/plugin-artifact/index.js"; import type { PluginRuntimeHost } from "../adapters/outbound/plugin-runtime/index.js"; @@ -327,6 +328,7 @@ export function createPluginService(options: CreatePluginServiceOptions): Plugin const startedAt = Date.now(); let outcome: "success" | "error" | "interrupted" = "interrupted"; let errorCode: string | null = null; + const publishedPaths = new Map(); try { for await (const event of options.runtimeHost.invoke(call)) { if (event.type === "result") outcome = "success"; @@ -334,8 +336,19 @@ export function createPluginService(options: CreatePluginServiceOptions): Plugin outcome = "error"; errorCode = event.code; } - yield event.type === "artifact" - ? { ...event, artifact: await localArtifacts.host(plugin, event.artifact) } + if (event.type === "artifact") { + const sourcePath = localArtifactPath(event.artifact.uri); + const artifact = await localArtifacts.host(plugin, event.artifact, { + conversationId: call.conversationId, + taskId: capabilityTaskId(call.input), + callId: call.callId + }); + if (sourcePath && artifact.path) publishedPaths.set(sourcePath, artifact.path); + yield { ...event, artifact }; + continue; + } + yield event.type === "result" && publishedPaths.size + ? { ...event, output: replacePublishedPaths(event.output, publishedPaths) } : event; } } catch (error) { @@ -392,6 +405,31 @@ export function createPluginService(options: CreatePluginServiceOptions): Plugin }; } +function capabilityTaskId(input: unknown): string | undefined { + if (!input || typeof input !== "object" || Array.isArray(input)) return undefined; + const taskId = (input as Record).taskId; + return typeof taskId === "string" && taskId.trim() ? taskId.trim() : undefined; +} + +function localArtifactPath(uri: string): string | null { + try { + const url = new URL(uri); + return url.protocol === "file:" ? fileURLToPath(url) : null; + } catch { + return null; + } +} + +function replacePublishedPaths(value: unknown, paths: ReadonlyMap): unknown { + if (typeof value === "string") return paths.get(value) ?? value; + if (Array.isArray(value)) return value.map((item) => replacePublishedPaths(item, paths)); + if (!value || typeof value !== "object") return value; + return Object.fromEntries(Object.entries(value).map(([key, item]) => [ + key, + replacePublishedPaths(item, paths) + ])); +} + function publicPlugin(plugin: PluginRecord): InstalledPlugin { const { artifactHash: _artifactHash, rootPath: _rootPath, ...result } = plugin; return result; diff --git a/App/backend/src/services/tests/plugin-local-artifact-service.test.ts b/App/backend/src/services/tests/plugin-local-artifact-service.test.ts index 913b2d3a4..1b8ad6475 100644 --- a/App/backend/src/services/tests/plugin-local-artifact-service.test.ts +++ b/App/backend/src/services/tests/plugin-local-artifact-service.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -61,6 +61,40 @@ describe("PluginLocalArtifactService", () => { }); }); + it("publishes final artifacts into the bound conversation workspace", async () => { + root = mkdtempSync(join(tmpdir(), "memmy-plugin-publish-")); + const pluginDataRoot = join(root, "plugin-data"); + const workspace = join(root, "workspace"); + const output = join(pluginDataRoot, "literature-review", "tasks", "review-1", "outputs", "review.md"); + mkdirSync(join(pluginDataRoot, "literature-review", "tasks", "review-1", "outputs"), { recursive: true }); + mkdirSync(workspace); + writeFileSync(output, "# Published review"); + const service = createPluginLocalArtifactService({ + pluginDataRoot, + resolveWorkspace: async (conversationId) => conversationId === "websocket:chat-1" ? workspace : null + }); + + const hosted = await service.host({ + id: "literature-review", + approvedPermissions: [{ type: "host-service", services: ["plugin-data", "artifact-host"] }], + config: {} + }, { + id: "review", + name: "review.md", + mediaType: "text/markdown", + uri: pathToFileURL(output).href + }, { + conversationId: "websocket:chat-1", + taskId: "review-1", + callId: "call-1" + }); + + const published = join(workspace, "outputs", "literature-review", "review-1", "review.md"); + expect(hosted.path).toBe(realpathSync(published)); + expect(readFileSync(published, "utf8")).toBe("# Published review"); + expect(readFileSync(output, "utf8")).toBe("# Published review"); + }); + it("does not treat a plugin-configured taskRoot as a Host-approved path", async () => { root = mkdtempSync(join(tmpdir(), "memmy-plugin-output-")); const output = join(root, "review.md"); diff --git a/App/backend/src/services/tests/plugin-service.test.ts b/App/backend/src/services/tests/plugin-service.test.ts index de4007d24..7256a9227 100644 --- a/App/backend/src/services/tests/plugin-service.test.ts +++ b/App/backend/src/services/tests/plugin-service.test.ts @@ -9,6 +9,7 @@ import { type PluginArtifactManager, type PluginRuntimeHost } from "../plugin-service.js"; +import type { PluginLocalArtifactService } from "../plugin-local-artifact-service.js"; let root: string | undefined; let store: AppStateStore | undefined; @@ -48,7 +49,10 @@ const release = { } }; -function createContext(releases: PluginRelease[] = [release]) { +function createContext( + releases: PluginRelease[] = [release], + localArtifactService?: PluginLocalArtifactService +) { root = mkdtempSync(join(tmpdir(), "memmy-plugin-service-")); store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); const runtimeHost: PluginRuntimeHost = { @@ -83,7 +87,8 @@ function createContext(releases: PluginRelease[] = [release]) { registry: createInMemoryPluginRegistry(releases), runtimeHost, artifactManager, - skillManager + skillManager, + localArtifactService }) }; } @@ -238,6 +243,55 @@ describe("PluginService", () => { })).toThrow(/Disable plugin/); }); + it("rewrites result paths to the copies published in the conversation workspace", async () => { + const sourcePath = "/plugin-data/review-1/outputs/review.md"; + const publishedPath = "/workspace/outputs/com.example.review/review-1/review.md"; + const localArtifactService: PluginLocalArtifactService = { + host: vi.fn(async (_plugin, artifact) => ({ ...artifact, path: publishedPath })), + open: vi.fn(async () => ({ path: sourcePath, name: "review.md", mediaType: "text/markdown" })), + revokePlugin: vi.fn() + }; + const { service, runtimeHost } = createContext([release], localArtifactService); + runtimeHost.invoke = async function* () { + yield { + type: "artifact", + artifact: { + id: "review", + name: "review.md", + mediaType: "text/markdown", + uri: "file:///plugin-data/review-1/outputs/review.md" + } + }; + yield { type: "result", output: { data: { outputs: [{ path: sourcePath }] } } }; + }; + await service.install(release.manifest.id); + service.configure(release.manifest.id, { + config: { database: "crossref" }, + secrets: { "api-key": "secret" } + }); + await service.approvePermissions(release.manifest.id, release.manifest.permissions); + await service.enable(release.manifest.id); + + const events = []; + for await (const event of service.invoke({ + callId: "call-1", + pluginId: release.manifest.id, + capabilityId: "run", + conversationId: "websocket:chat-1", + input: { taskId: "review-1" } + })) events.push(event); + + expect(events).toEqual([ + expect.objectContaining({ type: "artifact", artifact: expect.objectContaining({ path: publishedPath }) }), + { type: "result", output: { data: { outputs: [{ path: publishedPath }] } } } + ]); + expect(localArtifactService.host).toHaveBeenCalledWith( + expect.objectContaining({ id: release.manifest.id }), + expect.objectContaining({ id: "review" }), + { conversationId: "websocket:chat-1", taskId: "review-1", callId: "call-1" } + ); + }); + it("updates an active plugin and reactivates it atomically", async () => { const nextRelease: PluginRelease = { manifest: { ...release.manifest, version: "2.0.0" } diff --git a/App/frontend/desktop/src/api/memmy-agent-client.ts b/App/frontend/desktop/src/api/memmy-agent-client.ts index eb037458c..b9d9be141 100644 --- a/App/frontend/desktop/src/api/memmy-agent-client.ts +++ b/App/frontend/desktop/src/api/memmy-agent-client.ts @@ -418,7 +418,8 @@ const ResolvedArtifactSchema = z.object({ path: z.string(), name: z.string(), kind: z.union([z.literal("image"), z.literal("video"), z.literal("file"), z.literal("directory")]), - media_url: z.string().optional() + media_url: z.string().optional(), + relative_path: z.string().optional() }); const RevealArtifactResponseSchema = z.object({ diff --git a/App/frontend/desktop/src/lib/tests/workspace-relative-path.test.ts b/App/frontend/desktop/src/lib/tests/workspace-relative-path.test.ts new file mode 100644 index 000000000..59eec20de --- /dev/null +++ b/App/frontend/desktop/src/lib/tests/workspace-relative-path.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { toWorkspaceRelativePath } from "../workspace-relative-path.js"; + +describe("toWorkspaceRelativePath", () => { + it("maps absolute workspace files to relative paths", () => { + expect(toWorkspaceRelativePath( + "/Users/yuan/project/outputs/review.pdf", + "/Users/yuan/project" + )).toBe("outputs/review.pdf"); + }); + + it("accepts already-relative paths", () => { + expect(toWorkspaceRelativePath("outputs/review.pdf", "/Users/yuan/project")).toBe("outputs/review.pdf"); + expect(toWorkspaceRelativePath("./notes/README.md", null)).toBe("notes/README.md"); + }); + + it("rejects paths outside the workspace", () => { + expect(toWorkspaceRelativePath( + "/Users/yuan/other/review.pdf", + "/Users/yuan/project" + )).toBeNull(); + }); + + it("handles file URLs and Windows roots", () => { + expect(toWorkspaceRelativePath( + "file:///Users/yuan/project/review.tex", + "/Users/yuan/project" + )).toBe("review.tex"); + expect(toWorkspaceRelativePath( + "C:\\Users\\yuan\\project\\review.docx", + "C:\\Users\\yuan\\project" + )).toBe("review.docx"); + }); +}); diff --git a/App/frontend/desktop/src/lib/workspace-relative-path.ts b/App/frontend/desktop/src/lib/workspace-relative-path.ts new file mode 100644 index 000000000..cb34f6c7c --- /dev/null +++ b/App/frontend/desktop/src/lib/workspace-relative-path.ts @@ -0,0 +1,60 @@ +/** + * Maps an absolute (or already relative) path into a workspace-relative path. + * Returns null when the path is outside the workspace or names the root itself. + */ +export function toWorkspaceRelativePath( + candidatePath: string, + workspaceRoot: string | null | undefined +): string | null { + const path = normalizePathSeparators(stripFileUrl(candidatePath)).replace(/\/+$/, ""); + if (!path) return null; + + if (!isAbsolutePath(path)) { + const relative = path.replace(/^\.\/+/, ""); + return relative && relative !== "." ? relative : null; + } + + const root = normalizePathSeparators(workspaceRoot ?? "").replace(/\/+$/, ""); + if (!root) return null; + if (pathsEqual(path, root)) return null; + + const prefix = `${root}/`; + if (path.startsWith(prefix) || (isWindowsPath(root) && path.toLowerCase().startsWith(prefix.toLowerCase()))) { + const relative = path.slice(root.length + 1); + return relative || null; + } + return null; +} + +function stripFileUrl(value: string): string { + const trimmed = value.trim(); + if (!/^file:/i.test(trimmed)) return trimmed; + try { + const url = new URL(trimmed); + let pathname = decodeURIComponent(url.pathname); + // Node/Chromium file URLs on Windows look like /C:/Users/... + if (/^\/[A-Za-z]:\//.test(pathname)) pathname = pathname.slice(1); + return pathname; + } catch { + return trimmed.replace(/^file:\/\//i, ""); + } +} + +function normalizePathSeparators(value: string): string { + return value.replace(/\\/g, "/"); +} + +function isAbsolutePath(value: string): boolean { + return value.startsWith("/") || isWindowsPath(value); +} + +function isWindowsPath(value: string): boolean { + return /^[A-Za-z]:\//.test(value); +} + +function pathsEqual(left: string, right: string): boolean { + if (isWindowsPath(left) || isWindowsPath(right)) { + return left.toLowerCase() === right.toLowerCase(); + } + return left === right; +} diff --git a/App/frontend/desktop/src/pages/agent-message-content.tsx b/App/frontend/desktop/src/pages/agent-message-content.tsx index 4f3295ba1..40421d6f2 100644 --- a/App/frontend/desktop/src/pages/agent-message-content.tsx +++ b/App/frontend/desktop/src/pages/agent-message-content.tsx @@ -16,6 +16,11 @@ export type AgentArtifactClient = { resolveArtifact(path: string): ReturnType; revealArtifact(path: string): Promise; openArtifact(path: string): Promise; + /** + * Prefer in-app workspace preview when the path belongs to the active + * workspace. Returns true when the preview panel handled the open. + */ + previewArtifact?: (path: string) => Promise; }; export type AttachmentActionStatus = "opened" | "revealed" | "downloaded" | "failed"; export type AttachmentCopyTarget = "path" | "url"; @@ -492,6 +497,16 @@ export async function runAttachmentAction(input: { } } + if (actionPath && input.artifactClient?.previewArtifact) { + try { + if (await input.artifactClient.previewArtifact(actionPath)) { + return "opened"; + } + } catch { + // Continue to system open / reveal / download fallbacks. + } + } + if (actionPath && input.artifactClient) { try { await input.artifactClient.openArtifact(actionPath); diff --git a/App/frontend/desktop/src/pages/file-preview/pdf-preview-state.ts b/App/frontend/desktop/src/pages/file-preview/pdf-preview-state.ts index baab78719..f374f213e 100644 --- a/App/frontend/desktop/src/pages/file-preview/pdf-preview-state.ts +++ b/App/frontend/desktop/src/pages/file-preview/pdf-preview-state.ts @@ -3,6 +3,30 @@ export function nextPdfMatchIndex(current: number, direction: -1 | 1, count: num return (current + direction + count) % count; } +/** Computes the rendered scale for manual zoom or fit-to-width / fit-to-page modes. */ +export function computePdfDisplayScale(input: { + pageWidth: number; + pageHeight: number; + viewportWidth: number; + viewportHeight: number; + fit: "width" | "page" | null; + scale: number; +}): number { + if (input.fit === "width") { + return Math.max(0.1, input.viewportWidth / input.pageWidth); + } + if (input.fit === "page") { + return Math.max( + 0.1, + Math.min( + input.viewportWidth / input.pageWidth, + input.viewportHeight / input.pageHeight + ) + ); + } + return input.scale; +} + export function highlightPdfTextLayer( container: HTMLDivElement | null, rawQuery: string, diff --git a/App/frontend/desktop/src/pages/file-preview/pdf-preview.tsx b/App/frontend/desktop/src/pages/file-preview/pdf-preview.tsx index 9bab671d2..a2d8d61b1 100644 --- a/App/frontend/desktop/src/pages/file-preview/pdf-preview.tsx +++ b/App/frontend/desktop/src/pages/file-preview/pdf-preview.tsx @@ -8,15 +8,14 @@ import { type ReactNode } from "react"; import { - ChevronDown, ChevronLeft, ChevronRight, - ChevronsUpDown, Minus, - PanelLeftClose, - PanelLeftOpen, Plus, + Scan, Search, + UnfoldHorizontal, + GalleryVertical, X } from "lucide-react"; import { @@ -29,7 +28,7 @@ import { import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; import "pdfjs-dist/web/pdf_viewer.css"; import { useTranslation } from "../../i18n/use-translation.js"; -import { highlightPdfTextLayer, nextPdfMatchIndex } from "./pdf-preview-state.js"; +import { computePdfDisplayScale, highlightPdfTextLayer, nextPdfMatchIndex } from "./pdf-preview-state.js"; import type { FilePreviewViewState } from "./file-preview-types.js"; GlobalWorkerOptions.workerSrc = pdfWorkerUrl; @@ -53,6 +52,9 @@ export function PdfPreview(props: PdfPreviewProps): ReactNode { const { t } = useTranslation(); const scrollRef = useRef(null); const searchRef = useRef(null); + const programmaticScrollUntilRef = useRef(0); + const pageRef = useRef(props.initialState?.page ?? 1); + const scrollSyncRafRef = useRef(0); const onStateChangeRef = useRef(props.onStateChange); onStateChangeRef.current = props.onStateChange; const [document, setDocument] = useState(null); @@ -67,6 +69,8 @@ export function PdfPreview(props: PdfPreviewProps): ReactNode { const [matches, setMatches] = useState([]); const [matchIndex, setMatchIndex] = useState(-1); const [viewportSize, setViewportSize] = useState({ width: 640, height: 720 }); + const [pageNaturalSize, setPageNaturalSize] = useState<{ width: number; height: number } | null>(null); + pageRef.current = page; useEffect(() => { let active = true; @@ -102,13 +106,37 @@ export function PdfPreview(props: PdfPreviewProps): ReactNode { }; }, [document]); + // Restore once when the document first becomes ready. Re-applying scrollTop on + // every persisted-state update fights trackpad momentum and feels like rollback. useEffect(() => { - if (!document || props.initialState?.scrollTop == null) return; + if (!document) return; + const top = props.initialState?.scrollTop; + if (top == null) return; const frame = window.requestAnimationFrame(() => { - if (scrollRef.current) scrollRef.current.scrollTop = props.initialState?.scrollTop ?? 0; + if (scrollRef.current) scrollRef.current.scrollTop = top; }); return () => window.cancelAnimationFrame(frame); - }, [document, props.initialState?.scrollTop]); + // intentionally only when document identity changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [document]); + + useEffect(() => { + if (!document) { + setPageNaturalSize(null); + return; + } + let active = true; + void document.getPage(Math.min(Math.max(page, 1), document.numPages)).then((pdfPage) => { + if (!active) return; + const natural = pdfPage.getViewport({ scale: 1 }); + setPageNaturalSize({ width: natural.width, height: natural.height }); + }).catch(() => { + if (active) setPageNaturalSize(null); + }); + return () => { + active = false; + }; + }, [document, page]); useEffect(() => { setPageDraft(String(page)); @@ -134,7 +162,10 @@ export function PdfPreview(props: PdfPreviewProps): ReactNode { if (!active) return; setMatches(next); setMatchIndex(next.length ? 0 : -1); - if (next[0]) scrollToPage(scrollRef.current, next[0].page); + if (next[0]) { + programmaticScrollUntilRef.current = Date.now() + 450; + scrollToPage(scrollRef.current, next[0].page); + } }); }, 180); return () => { @@ -161,8 +192,12 @@ export function PdfPreview(props: PdfPreviewProps): ReactNode { const changePage = useCallback((nextPage: number) => { if (!document) return; const normalized = Math.min(document.numPages, Math.max(1, Math.round(nextPage))); + programmaticScrollUntilRef.current = Date.now() + 350; + pageRef.current = normalized; setPage(normalized); - scrollToPage(scrollRef.current, normalized); + window.requestAnimationFrame(() => { + scrollToPage(scrollRef.current, normalized); + }); }, [document]); const navigateMatch = useCallback((direction: -1 | 1) => { @@ -180,11 +215,21 @@ export function PdfPreview(props: PdfPreviewProps): ReactNode { } const effectiveScale = fit === null ? scale : undefined; + const displayScale = pageNaturalSize + ? computePdfDisplayScale({ + pageWidth: pageNaturalSize.width, + pageHeight: pageNaturalSize.height, + viewportWidth: viewportSize.width, + viewportHeight: viewportSize.height, + fit, + scale + }) + : scale; return (
@@ -203,11 +248,11 @@ export function PdfPreview(props: PdfPreviewProps): ReactNode { - - {Math.round((effectiveScale ?? scale) * 100)}% - - - + + {Math.round(displayScale * 100)}% + + +
@@ -236,17 +281,25 @@ export function PdfPreview(props: PdfPreviewProps): ReactNode { ref={scrollRef} className="pdf-preview__pages" onScroll={(event) => { + if (Date.now() < programmaticScrollUntilRef.current) return; const container = event.currentTarget; - let closestPage = page; - let closestDistance = Number.POSITIVE_INFINITY; - for (const item of container.querySelectorAll("[data-pdf-page]")) { - const distance = Math.abs(item.offsetTop - container.scrollTop - 16); - if (distance < closestDistance) { - closestDistance = distance; - closestPage = Number(item.dataset.pdfPage); + if (scrollSyncRafRef.current) return; + scrollSyncRafRef.current = window.requestAnimationFrame(() => { + scrollSyncRafRef.current = 0; + let closestPage = pageRef.current; + let closestDistance = Number.POSITIVE_INFINITY; + for (const item of container.querySelectorAll("[data-pdf-page]")) { + const distance = Math.abs(item.offsetTop - container.scrollTop - 16); + if (distance < closestDistance) { + closestDistance = distance; + closestPage = Number(item.dataset.pdfPage); + } + } + if (closestPage !== pageRef.current) { + pageRef.current = closestPage; + setPage(closestPage); } - } - if (closestPage !== page) setPage(closestPage); + }); }} > {pageNumbers(document.numPages).map((number) => ( diff --git a/App/frontend/desktop/src/pages/home-page.tsx b/App/frontend/desktop/src/pages/home-page.tsx index 6160cbf10..3e58a0b33 100644 --- a/App/frontend/desktop/src/pages/home-page.tsx +++ b/App/frontend/desktop/src/pages/home-page.tsx @@ -38,6 +38,7 @@ import { } from "../lib/agent-attachment.js"; import { encodeAgentImage, type AgentImageMime } from "../lib/agent-image-encode.js"; import { formatConversationTitleForDisplay } from "../lib/format-conversation-title.js"; +import { toWorkspaceRelativePath } from "../lib/workspace-relative-path.js"; import { ImChannelTitleIcon, imChannelTitleDisplay } from "../integrations/integration-meta.js"; import { composerFolderReferenceFromFiles, @@ -448,7 +449,8 @@ export function isAgentConversationAtBottom(element: Pick({ open: false }); const [environmentPanelOpen, setEnvironmentPanelOpen] = useState(false); const [previewPanelOpen, setPreviewPanelOpen] = useState(false); + const [previewFocusFile, setPreviewFocusFile] = useState<{ path: string; nonce: number } | null>(null); + const [previewFocusExternalFile, setPreviewFocusExternalFile] = useState<{ + id: string; + name: string; + nonce: number; + load: (signal?: AbortSignal) => Promise; + open?: () => Promise; + reveal?: () => Promise; + } | null>(null); const [pluginArtifactPreview, setPluginArtifactPreview] = useState(null); const [recordingSession, setRecordingSession] = useState(null); const [installedPlugins, setInstalledPlugins] = useState([]); @@ -1333,6 +1344,11 @@ export function HomePage() { ?? selectedDraftProject?.name ?? activeTask?.title ?? t("workspaceArtifact.taskFolder"); + const previewWorkspaceRoot = activeSession?.cwd + ?? activeTask?.cwd + ?? activeProject?.rootPath + ?? selectedDraftProject?.rootPath + ?? null; const previewScope: WorkspaceFilesScope | null = previewSessionKey ? { kind: "session", key: previewSessionKey } : selectedDraftProject @@ -1383,6 +1399,8 @@ export function HomePage() { if (!previewScope) { setPreviewPanelOpen(false); setPluginArtifactPreview(null); + setPreviewFocusFile(null); + setPreviewFocusExternalFile(null); } }, [previewScope?.kind, previewScope?.key]); @@ -1408,9 +1426,62 @@ export function HomePage() { return { resolveArtifact: (path: string) => client.resolveArtifact(path, sessionKey), revealArtifact: (path: string) => client.revealArtifact(path, sessionKey), - openArtifact: (path: string) => client.openArtifact(path, sessionKey) + openArtifact: (path: string) => client.openArtifact(path, sessionKey), + previewArtifact: async (path: string) => { + if (!previewScope) return false; + let relativePath: string | null = null; + let mediaUrl: string | null = null; + let artifactName = path.replace(/\\/g, "/").split("/").pop() || path; + let artifactPath = path; + try { + const resolved = await client.resolveArtifact(path, sessionKey); + if (resolved.kind === "directory") return false; + artifactName = resolved.name || artifactName; + artifactPath = resolved.path; + mediaUrl = resolved.media_url ?? null; + relativePath = resolved.relative_path?.replace(/\\/g, "/") + ?? toWorkspaceRelativePath(resolved.path, previewWorkspaceRoot) + ?? toWorkspaceRelativePath(path, previewWorkspaceRoot); + } catch { + relativePath = toWorkspaceRelativePath(path, previewWorkspaceRoot); + } + if (recordingEntry.open) recordingEntry.close(); + setPluginArtifactPreview(null); + setPreviewPanelOpen(true); + if (relativePath) { + setPreviewFocusExternalFile(null); + setPreviewFocusFile({ path: relativePath, nonce: Date.now() }); + return true; + } + if (mediaUrl) { + const loadUrl = mediaUrl; + setPreviewFocusFile(null); + setPreviewFocusExternalFile({ + id: artifactPath, + name: artifactName, + nonce: Date.now(), + load: async (signal) => { + const response = await fetch(loadUrl, { signal }); + if (!response.ok) throw new Error("media_fetch_failed"); + return response.blob(); + }, + open: () => client.openArtifact(artifactPath, sessionKey), + reveal: () => client.revealArtifact(artifactPath, sessionKey) + }); + return true; + } + return false; + } }; - }, [clients?.memmyAgent, state.agent.currentSessionKey]); + }, [ + clients?.memmyAgent, + previewScope?.kind, + previewScope?.key, + previewWorkspaceRoot, + recordingEntry.close, + recordingEntry.open, + state.agent.currentSessionKey + ]); const loadPreviewDirectory = useCallback((scope: WorkspaceFilesScope, relativePath: string) => { const client = clients?.memmyAgent; if (!client) return Promise.reject(new Error("agent_client_unavailable")); @@ -3476,6 +3547,8 @@ export function HomePage() { revealFile={revealWorkspaceFile} onAddToChat={addComposerContextChip} refreshKey={`${currentHistoryVersion}:${isCurrentAgentRunning ? "running" : "idle"}`} + focusFile={previewFocusFile} + focusExternalFile={previewFocusExternalFile} onWidthChange={setPreviewPanelWidth} sharedRowWidth={workspaceLayoutWidth} sharedRowReservedWidth={SHARED_ROW_PRIMARY_RESERVE} @@ -3833,6 +3906,14 @@ export function HomePage() { asrClient={clients?.asr} onAddArtifact={(artifact) => setCurrentComposerDraft((current) => appendPluginArtifact(current, artifact))} onOpenArtifact={(artifact) => { + if (artifact.path && sessionArtifactClient) { + void sessionArtifactClient.previewArtifact(artifact.path).then((opened) => { + if (opened) return; + setPreviewPanelOpen(false); + setPluginArtifactPreview(artifact); + }); + return; + } setPreviewPanelOpen(false); setPluginArtifactPreview(artifact); }} diff --git a/App/frontend/desktop/src/pages/tests/agent-thread-messages.test.tsx b/App/frontend/desktop/src/pages/tests/agent-thread-messages.test.tsx index 88b578b0e..c95dae7c4 100644 --- a/App/frontend/desktop/src/pages/tests/agent-thread-messages.test.tsx +++ b/App/frontend/desktop/src/pages/tests/agent-thread-messages.test.tsx @@ -2547,6 +2547,26 @@ describe("AgentThreadMessages", () => { expect(html).not.toContain('img src="/Users/yuan/deck.pptx"'); }); + it("prefers in-app workspace preview before opening with the system default", async () => { + const previewArtifact = vi.fn(async () => true); + const openArtifact = vi.fn(async () => undefined); + const revealArtifact = vi.fn(async () => undefined); + const resolveArtifact = vi.fn(async () => ({ + ...fileArtifact("/Users/yuan/project/outputs/review.pdf"), + relative_path: "outputs/review.pdf" + })); + + await expect(runAttachmentAction({ + path: "/Users/yuan/project/outputs/review.pdf", + label: "review.pdf", + artifactClient: { resolveArtifact, openArtifact, revealArtifact, previewArtifact } + })).resolves.toBe("opened"); + + expect(previewArtifact).toHaveBeenCalledWith("/Users/yuan/project/outputs/review.pdf"); + expect(openArtifact).not.toHaveBeenCalled(); + expect(revealArtifact).not.toHaveBeenCalled(); + }); + it("runs file attachment actions as open, reveal, download, then final failure", async () => { const events: string[] = []; const openOutcomes = [true, false, false, false]; diff --git a/App/frontend/desktop/src/pages/tests/home-page.test.tsx b/App/frontend/desktop/src/pages/tests/home-page.test.tsx index 530ffe298..92d1bb5fb 100644 --- a/App/frontend/desktop/src/pages/tests/home-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/home-page.test.tsx @@ -16,6 +16,7 @@ import { AGENT_MEDIA_ACCEPT, ComposerCommandChip, addCapabilityBlockToDraft, + appendPluginArtifact, ComposerMediaPreviewStrip, ComposerSubmitButton, HomePage, @@ -87,6 +88,16 @@ function mockCallOrder(fn: { mock: { invocationCallOrder: readonly number[] } }, } describe("HomePage", () => { + it("adds the published workspace path for a hosted plugin artifact", () => { + expect(appendPluginArtifact("Inspect", { + id: "review", + name: "review.pdf", + mediaType: "application/pdf", + uri: "/api/v1/plugins/literature-review/artifacts/token/preview", + path: "/workspace/outputs/literature-review/review-1/review.pdf" + })).toBe("Inspect\nreview.pdf: /workspace/outputs/literature-review/review-1/review.pdf"); + }); + it("registers only active non-reserved plugin commands and parses their arguments", () => { const plugin = InstalledPluginSchema.parse({ id: "com.example.review", @@ -596,6 +607,8 @@ describe("HomePage", () => { expect(source).toContain("const previewToggle = previewScope ? ("); expect(source).toContain(" { expect(nextPdfMatchIndex(-1, 1, 0)).toBe(-1); }); + it("computes distinct fit-width and fit-page scales and keeps them in the toolbar display path", () => { + expect(computePdfDisplayScale({ + pageWidth: 600, + pageHeight: 900, + viewportWidth: 600, + viewportHeight: 400, + fit: "width", + scale: 1.4 + })).toBeCloseTo(1, 5); + expect(computePdfDisplayScale({ + pageWidth: 600, + pageHeight: 900, + viewportWidth: 600, + viewportHeight: 400, + fit: "page", + scale: 1.4 + })).toBeCloseTo(400 / 900, 5); + expect(computePdfDisplayScale({ + pageWidth: 600, + pageHeight: 900, + viewportWidth: 600, + viewportHeight: 400, + fit: null, + scale: 1.4 + })).toBe(1.4); + + const source = readFileSync(resolve(process.cwd(), "src/pages/file-preview/pdf-preview.tsx"), "utf8"); + expect(source).toContain("computePdfDisplayScale"); + expect(source).toContain("Math.round(displayScale * 100)"); + expect(source).toContain("GalleryVertical"); + expect(source).toContain("UnfoldHorizontal"); + expect(source).toContain("Scan"); + expect(source).toContain("programmaticScrollUntilRef"); + expect(source).not.toContain("PanelLeftClose"); + expect(source).not.toContain("ChevronsUpDown"); + }); + it("highlights exact PDF text matches and distinguishes the active occurrence", () => { const layer = document.createElement("div"); layer.innerHTML = "Alpha beta alphaalphabet"; @@ -34,5 +72,8 @@ describe("PDF preview navigation", () => { const source = readFileSync(resolve(process.cwd(), "src/pages/file-preview/pdf-preview.tsx"), "utf8"); expect(source).toContain("container.scrollTop +="); expect(source).not.toContain("scrollIntoView"); + expect(source).toContain("programmaticScrollUntilRef"); + // Persisted scrollTop must not be reapplied on every state sync — that fights wheel/trackpad momentum. + expect(source).toContain("intentionally only when document identity changes"); }); }); diff --git a/App/frontend/desktop/src/pages/tests/workspace-artifact-panel.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/workspace-artifact-panel.interaction.test.tsx index c6e7915bc..acaee923a 100644 --- a/App/frontend/desktop/src/pages/tests/workspace-artifact-panel.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/workspace-artifact-panel.interaction.test.tsx @@ -113,14 +113,19 @@ describe("WorkspaceArtifactPanel", () => { it("loads only the active real root until a folder is expanded", () => { expect(loadDirectory).toHaveBeenCalledWith({ kind: "session", key: SESSION_KEY }, ""); expect(loadDirectory).toHaveBeenCalledTimes(1); - expect(container.querySelector(".workspace-artifact-file-root")?.textContent).toBe("memmy-agent"); + expect(container.querySelector(".workspace-artifact-file-root")).toBeNull(); expect(folderButtons().map((button) => button.textContent)).toEqual(["downloads", "outputs", "notes"]); expect(fileButtonLabels()).toEqual([]); expect(activeTab()).toBeNull(); expect(container.querySelectorAll('[role="separator"]')).toHaveLength(2); const preview = container.querySelector(".workspace-artifact-preview-main")!; - const browser = container.querySelector(".workspace-artifact-file-browser")!; - expect(preview.compareDocumentPosition(browser) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + const browser = container.querySelector(".workspace-artifact-file-browser")!; + expect(browser.style.width).toBe("200px"); + expect(preview.compareDocumentPosition(browser) & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy(); + const toolbar = container.querySelector(".workspace-artifact-preview-toolbar")!; + const toggle = toolbar.querySelector(".workspace-artifact-file-browser__toggle")!; + const tabs = toolbar.querySelector(".workspace-artifact-file-tabs")!; + expect(toggle.compareDocumentPosition(tabs) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); it("collapses folders and toggles the whole file tree without losing the open tab", async () => { @@ -200,16 +205,29 @@ describe("WorkspaceArtifactPanel", () => { expect(activeTab()?.textContent).toContain("证据.pdf"); }); - it("shows the selected path below the tabs and toggles the file tree from that bar", async () => { + it("shows a compact path crumb above the preview and keeps the file toggle in the tab toolbar", async () => { await expandFolder("downloads"); const research = fileButtons().find((button) => button.textContent?.trim() === "研究资料.pdf")!; act(() => research.click()); - const bar = container.querySelector(".workspace-artifact-breadcrumb-bar")!; - expect(bar.textContent).toContain("memmy-agent"); - expect(bar.textContent).toContain("downloads"); - expect(bar.textContent).toContain("研究资料.pdf"); - expect(bar.querySelector(".workspace-artifact-file-browser__toggle")).not.toBeNull(); + const crumb = container.querySelector(".workspace-artifact-preview-crumb")!; + expect(crumb.textContent).toContain("memmy-agent"); + expect(crumb.textContent).toContain("研究资料.pdf"); + expect(crumb.textContent).toContain("›"); + expect(container.querySelector(".workspace-artifact-breadcrumb-bar")).toBeNull(); + expect(container.querySelector(".workspace-artifact-preview-toolbar .workspace-artifact-file-browser__toggle")).not.toBeNull(); + }); + + it("opens a workspace-relative path requested from outside the panel", async () => { + await renderPreview(0, false, { path: "outputs/综述.tex", nonce: 1 }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(loadDirectory).toHaveBeenCalledWith({ kind: "session", key: SESSION_KEY }, "outputs"); + expect(activeTab()?.textContent).toContain("综述.tex"); + expect(container.querySelector(".workspace-artifact-preview-crumb")?.textContent).toContain("综述.tex"); }); it("adds the session-relative file path to chat from the context menu", async () => { @@ -289,7 +307,35 @@ describe("WorkspaceArtifactPanel", () => { expect(container.textContent).not.toContain("README.md"); }); - async function renderPreview(refreshKey = 0, hidden = false) { + it("opens a staged media attachment as an external preview tab", async () => { + const load = vi.fn(async () => new Blob(["%PDF-1.7"], { type: "application/pdf" })); + await renderPreview(0, false, null, { + id: "/tmp/media/review.pdf", + name: "review.pdf", + nonce: 7, + load + }); + + expect(activeTab()?.textContent).toContain("review.pdf"); + expect(container.textContent).toContain("review.pdf"); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(load).toHaveBeenCalled(); + }); + + async function renderPreview( + refreshKey = 0, + hidden = false, + focusFile: { path: string; nonce: number } | null = null, + focusExternalFile: { + id: string; + name: string; + nonce: number; + load: (signal?: AbortSignal) => Promise; + } | null = null + ) { await act(async () => { root.render( @@ -301,6 +347,8 @@ describe("WorkspaceArtifactPanel", () => { onAddToChat={onAddToChat} refreshKey={refreshKey} hidden={hidden} + focusFile={focusFile} + focusExternalFile={focusExternalFile} /> ); diff --git a/App/frontend/desktop/src/pages/workspace-artifact-panel.tsx b/App/frontend/desktop/src/pages/workspace-artifact-panel.tsx index ed35c7868..673c6cc70 100644 --- a/App/frontend/desktop/src/pages/workspace-artifact-panel.tsx +++ b/App/frontend/desktop/src/pages/workspace-artifact-panel.tsx @@ -13,13 +13,9 @@ import { import { ChevronDown, ChevronRight, - Download, - ExternalLink, Folder, - FolderSearch, - PanelRightClose, - PanelRightOpen, - RotateCw, + PanelLeftClose, + PanelLeftOpen, X } from "lucide-react"; import type { @@ -37,11 +33,13 @@ import type { FilePreviewResource, FilePreviewViewState } from "./file-preview/f import { SidebarResizeHandle, useResizableSidebar } from "./sidebar-resize.js"; const WORKSPACE_ARTIFACT_WIDTH_STORAGE_KEY = "memmy.workspaceArtifact.previewWidth"; -const WORKSPACE_ARTIFACT_BROWSER_WIDTH_STORAGE_KEY = "memmy.workspaceArtifact.fileBrowserWidth"; +const WORKSPACE_ARTIFACT_BROWSER_WIDTH_STORAGE_KEY = "memmy.workspaceArtifact.fileBrowserWidth.v3"; const ROOT_DIRECTORY_KEY = ""; /** Marks a reducer entry that names an extra tab rather than a workspace file. */ const EXTRA_TAB_PREFIX = "extra:"; +/** Chat/media attachments that are not scope-relative still open as preview tabs. */ +const EXTERNAL_TAB_PREFIX = "external:"; interface PreviewTabsState { paths: string[]; @@ -121,8 +119,33 @@ export interface WorkspaceArtifactPanelProps { * drawn as one more tab, keyed by {@link ExtraPreviewTab.id}. */ extraTabs?: readonly ExtraPreviewTab[]; + /** + * Opens a workspace-relative file from outside the panel (e.g. a chat + * attachment card). `nonce` lets the same path be requested again. + */ + focusFile?: { path: string; nonce: number } | null; + /** + * Opens a non-workspace artifact (typically a staged media attachment) in the + * same preview tab strip. Used when chat cards resolve outside the session + * cwd but still have bytes we can render in-app. + */ + focusExternalFile?: { + id: string; + name: string; + nonce: number; + load: (signal?: AbortSignal) => Promise; + open?: () => Promise; + reveal?: () => Promise; + } | null; } +type ExternalPreviewFile = { + name: string; + load: (signal?: AbortSignal) => Promise; + open?: () => Promise; + reveal?: () => Promise; +}; + /** A non-file page in the preview panel's tab strip. */ export interface ExtraPreviewTab { id: string; @@ -163,10 +186,10 @@ export function WorkspaceArtifactPanel(props: WorkspaceArtifactPanelProps): Reac { paths: [], activePath: null } ); const [previewViewState, setPreviewViewState] = useState>({}); + const [externalFiles, setExternalFiles] = useState>({}); const openPreviewTabsRef = useRef([]); const [fileTreeOpen, setFileTreeOpen] = useState(true); const [collapsedPreviewFolders, setCollapsedPreviewFolders] = useState>({}); - const [internalRefreshKey, setInternalRefreshKey] = useState(0); const [fileContextMenu, setFileContextMenu] = useState<{ reference: ComposerContextReference; x: number; @@ -183,10 +206,10 @@ export function WorkspaceArtifactPanel(props: WorkspaceArtifactPanelProps): Reac }); const fileBrowserResize = useResizableSidebar({ storageKey: WORKSPACE_ARTIFACT_BROWSER_WIDTH_STORAGE_KEY, - defaultWidth: 180, - minWidth: 140, - maxWidth: 320, - resizeDirection: -1 + defaultWidth: 200, + minWidth: 160, + maxWidth: 360, + resizeDirection: 1 }); useEffect(() => { @@ -248,7 +271,7 @@ export function WorkspaceArtifactPanel(props: WorkspaceArtifactPanelProps): Reac requestGenerationRef.current += 1; } }; - }, [internalRefreshKey, props.refreshKey, props.scope.kind, props.scope.key, requestDirectory]); + }, [props.refreshKey, props.scope.kind, props.scope.key, requestDirectory]); useEffect(() => { if (!fileContextMenu) return; @@ -261,12 +284,72 @@ export function WorkspaceArtifactPanel(props: WorkspaceArtifactPanelProps): Reac }; }, [fileContextMenu]); + useEffect(() => { + const focusPath = props.focusFile?.path?.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+$/, ""); + if (!focusPath || props.focusFile?.nonce == null) return; + + setFileTreeOpen(true); + const parentDirs = focusPath.split("/").slice(0, -1).filter(Boolean); + if (parentDirs.length) { + setCollapsedPreviewFolders((state) => { + const next = { ...state }; + let accumulated = ""; + for (const part of parentDirs) { + accumulated = accumulated ? `${accumulated}/${part}` : part; + next[accumulated] = false; + } + return next; + }); + } + + const generation = requestGenerationRef.current; + let cancelled = false; + void (async () => { + let parent = ""; + for (const part of parentDirs) { + parent = parent ? `${parent}/${part}` : part; + if (cancelled || requestGenerationRef.current !== generation) return; + await requestDirectory(props.scope, parent, generation).catch(() => undefined); + } + if (cancelled || requestGenerationRef.current !== generation) return; + dispatchPreviewTabs({ type: "select", path: focusPath }); + })(); + + return () => { + cancelled = true; + }; + }, [props.focusFile?.nonce, props.focusFile?.path, props.scope.kind, props.scope.key, requestDirectory]); + + useEffect(() => { + const focus = props.focusExternalFile; + if (!focus || focus.nonce == null) return; + const path = `${EXTERNAL_TAB_PREFIX}${focus.id}`; + setExternalFiles((current) => ({ + ...current, + [path]: { + name: focus.name, + load: focus.load, + ...(focus.open ? { open: focus.open } : {}), + ...(focus.reveal ? { reveal: focus.reveal } : {}) + } + })); + dispatchPreviewTabs({ type: "select", path }); + }, [props.focusExternalFile?.id, props.focusExternalFile?.name, props.focusExternalFile?.nonce]); + function selectPreviewFile(path: string) { dispatchPreviewTabs({ type: "select", path }); } function closePreviewTab(path: string) { dispatchPreviewTabs({ type: "close", path }); + if (path.startsWith(EXTERNAL_TAB_PREFIX)) { + setExternalFiles((current) => { + if (!(path in current)) return current; + const next = { ...current }; + delete next[path]; + return next; + }); + } setPreviewViewState((current) => { if (!(path in current)) return current; const nextState = { ...current }; @@ -275,15 +358,6 @@ export function WorkspaceArtifactPanel(props: WorkspaceArtifactPanelProps): Reac }); } - function revealBreadcrumbDirectory(path: string) { - setFileTreeOpen(true); - if (!path) return; - setCollapsedPreviewFolders((state) => ({ ...state, [path]: false })); - if (listingsByDirectory[path] === undefined && !loadingDirectories[path]) { - void requestDirectory(props.scope, path, requestGenerationRef.current).catch(() => undefined); - } - } - function beginFileDrag(event: DragEvent, path: string, name: string) { writeComposerReferenceDrag(event.dataTransfer, fileReference(path, name)); } @@ -344,7 +418,7 @@ export function WorkspaceArtifactPanel(props: WorkspaceArtifactPanelProps): Reac aria-expanded={!collapsed} onClick={() => toggleDirectory(entry)} > - {collapsed ? : } + {collapsed ? : } {entry.name} {!collapsed ? ( @@ -371,22 +445,46 @@ export function WorkspaceArtifactPanel(props: WorkspaceArtifactPanelProps): Reac const resolvedRootLabel = rootListing?.root.label || props.rootLabel; const emptyLabel = props.emptyLabel ?? t("workspaceArtifact.noFiles"); const emptyDetail = props.emptyDetail ?? resolvedRootLabel; - const previewResource = useMemo(() => previewPath ? { - id: `${props.scope.kind}:${props.scope.key}:${previewPath}`, - name: fileNameFromPath(previewPath), - path: previewPath, - load: (signal) => props.loadFile(previewPath, signal), - open: props.openFile ? () => props.openFile!(previewPath) : undefined, - reveal: props.revealFile ? () => props.revealFile!(previewPath) : undefined, - download: async () => { - const blob = await props.loadFile(previewPath); - const objectUrl = URL.createObjectURL(blob); - startBrowserDownload(objectUrl, fileNameFromPath(previewPath)); - window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); - }, - openRelativePath: selectPreviewFile, - loadRelativePath: (path, signal) => props.loadFile(path, signal) - } : null, [ + const externalPreview = previewPath?.startsWith(EXTERNAL_TAB_PREFIX) + ? externalFiles[previewPath] ?? null + : null; + const previewResource = useMemo(() => { + if (!previewPath) return null; + if (previewPath.startsWith(EXTERNAL_TAB_PREFIX)) { + const external = externalFiles[previewPath]; + if (!external) return null; + return { + id: `${props.scope.kind}:${props.scope.key}:${previewPath}`, + name: external.name, + load: external.load, + open: external.open, + reveal: external.reveal, + download: async () => { + const blob = await external.load(); + const objectUrl = URL.createObjectURL(blob); + startBrowserDownload(objectUrl, external.name); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); + } + }; + } + return { + id: `${props.scope.kind}:${props.scope.key}:${previewPath}`, + name: fileNameFromPath(previewPath), + path: previewPath, + load: (signal) => props.loadFile(previewPath, signal), + open: props.openFile ? () => props.openFile!(previewPath) : undefined, + reveal: props.revealFile ? () => props.revealFile!(previewPath) : undefined, + download: async () => { + const blob = await props.loadFile(previewPath); + const objectUrl = URL.createObjectURL(blob); + startBrowserDownload(objectUrl, fileNameFromPath(previewPath)); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); + }, + openRelativePath: selectPreviewFile, + loadRelativePath: (path, signal) => props.loadFile(path, signal) + }; + }, [ + externalFiles, previewPath, props.loadFile, props.openFile, @@ -395,7 +493,6 @@ export function WorkspaceArtifactPanel(props: WorkspaceArtifactPanelProps): Reac props.scope.key ]); openPreviewTabsRef.current = openPreviewTabs; - const breadcrumbParts = previewPath?.replace(/\\/g, "/").split("/").filter(Boolean) ?? []; // An extra tab is opened by being offered: the top bar's toggle adds it and // removes it, so the panel follows the toggle rather than tracking its own @@ -437,11 +534,23 @@ export function WorkspaceArtifactPanel(props: WorkspaceArtifactPanelProps): Reac /> {fileContextMenu ? ( diff --git a/App/frontend/desktop/src/styles.css b/App/frontend/desktop/src/styles.css index 080250547..cf2046fb4 100644 --- a/App/frontend/desktop/src/styles.css +++ b/App/frontend/desktop/src/styles.css @@ -12656,58 +12656,15 @@ input[type="checkbox"].check-sky:checked::after { outline-offset: -2px; } -.workspace-artifact-breadcrumb-bar { - display: flex; - height: 30px; - min-height: 30px; - align-items: center; - gap: 6px; - border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 34%, transparent); - background: var(--color-background-paper); -} - -.workspace-artifact-breadcrumbs { - display: flex; - min-width: 0; - flex: 1; - align-items: center; - padding-left: 10px; - overflow: hidden; - color: color-mix(in srgb, var(--color-text-ink) 46%, transparent); - font-size: 11px; - white-space: nowrap; -} - -.workspace-artifact-breadcrumb { - display: inline-flex; - min-width: 0; - align-items: center; +.workspace-artifact-preview-main > .workspace-artifact-preview-crumb { + flex: none; + padding: 14px 16px 0; } -.workspace-artifact-breadcrumbs button, -.workspace-artifact-breadcrumbs strong { +.workspace-artifact-preview-main > :not(.workspace-artifact-preview-crumb) { + flex: 1 1 auto; min-width: 0; - padding: 2px 3px; - overflow: hidden; - border-radius: 4px; - color: inherit; - font-size: inherit; - font-weight: 500; - text-overflow: ellipsis; - white-space: nowrap; -} - -.workspace-artifact-breadcrumbs button:hover { - background: color-mix(in srgb, var(--color-canvas-oat) 62%, transparent); - color: color-mix(in srgb, var(--color-text-ink) 76%, transparent); -} - -.workspace-artifact-breadcrumb-bar > .workspace-artifact-file-browser__toggle { - margin-right: 6px; -} - -.agent-workspace-layout--preview-open > .workspace-artifact-preview-pane--workspace .workspace-artifact-preview-toolbar { - padding-right: 208px; + min-height: 0; } .workspace-artifact-preview-pane--plugin .workspace-artifact-preview-toolbar { @@ -12791,9 +12748,9 @@ input[type="checkbox"].check-sky:checked::after { display: flex; flex: none; flex-direction: column; - width: 180px; + width: 200px; min-height: 0; - border-left: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + border-right: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); background: color-mix(in srgb, var(--color-canvas-oat) 12%, var(--color-background-paper)); } @@ -12822,7 +12779,7 @@ input[type="checkbox"].check-sky:checked::after { flex: 1; width: auto; min-height: 0; - padding: 3px 5px 6px; + padding: 8px; overflow-y: auto; } @@ -12874,11 +12831,11 @@ input[type="checkbox"].check-sky:checked::after { .workspace-artifact-file-folder__toggle { display: flex; align-items: center; - gap: 3px; + gap: 6px; width: 100%; - min-height: 24px; - padding: 2px 4px; - border-radius: 4px; + min-height: 36px; + padding: 7px 6px; + border-radius: var(--radius-btn); color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); cursor: pointer; } @@ -12890,8 +12847,8 @@ input[type="checkbox"].check-sky:checked::after { .workspace-artifact-file-folder__toggle strong { min-width: 0; overflow: hidden; - font-size: 11px; - font-weight: 500; + font-size: 12px; + font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } @@ -12899,18 +12856,18 @@ input[type="checkbox"].check-sky:checked::after { .workspace-artifact-file-folder__children { display: flex; flex-direction: column; - margin-left: 8px; + margin-left: 14px; } .workspace-artifact-file-item { display: flex; align-items: center; - gap: 4px; + gap: 8px; width: 100%; - min-height: 24px; - padding: 2px 4px; - border-radius: 4px; - font-size: 11px; + min-height: 36px; + padding: 7px 8px; + border-radius: var(--radius-btn); + font-size: 12px; color: color-mix(in srgb, var(--color-text-ink) 60%, transparent); text-align: left; cursor: pointer; @@ -12921,7 +12878,7 @@ input[type="checkbox"].check-sky:checked::after { } .workspace-artifact-file-item--active { - background: color-mix(in srgb, var(--color-accent-neon-mint) 9%, transparent); + background: color-mix(in srgb, var(--color-accent-neon-mint) 14%, transparent); color: var(--color-action-sky-hover); font-weight: 500; } @@ -12934,6 +12891,40 @@ input[type="checkbox"].check-sky:checked::after { white-space: nowrap; } +.composer-file-context-menu { + position: fixed; + z-index: 10020; + min-width: 170px; + padding: 5px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 58%, transparent); + border-radius: 9px; + background: var(--color-background-paper); + box-shadow: 0 8px 24px rgba(17, 29, 28, 0.12); + -webkit-app-region: no-drag; +} + +.composer-file-context-menu > button { + display: flex; + width: 100%; + min-height: 32px; + align-items: center; + border: 0; + border-radius: 6px; + padding: 0 9px; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 76%, transparent); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.composer-file-context-menu > button:hover, +.composer-file-context-menu > button:focus-visible { + background: color-mix(in srgb, var(--color-canvas-oat) 72%, transparent); + color: var(--color-text-ink); + outline: none; +} + .workspace-artifact-preview-document { flex: 1; min-width: 0; @@ -13062,7 +13053,7 @@ input[type="checkbox"].check-sky:checked::after { } .workspace-artifact-file-browser { - width: min(140px, 42vw) !important; + width: min(200px, 42vw) !important; min-width: 0; } @@ -13610,7 +13601,6 @@ input[type="checkbox"].check-sky:checked::after { min-height: 0; padding: 18px 20px 44px; overflow: auto; - scroll-behavior: smooth; } .pdf-preview__page-shell { diff --git a/App/memmy-agent/src/integrations/channels/websocket.ts b/App/memmy-agent/src/integrations/channels/websocket.ts index c09aedd82..8fb2372e4 100644 --- a/App/memmy-agent/src/integrations/channels/websocket.ts +++ b/App/memmy-agent/src/integrations/channels/websocket.ts @@ -2275,11 +2275,16 @@ export class WebSocketChannel extends BaseChannel { if (!workspace) return httpError(404, "session not found"); const resolved = this.resolveOrStageArtifactPath(artifactRequest.path, workspace); if (!resolved) return httpError(404, "artifact not found"); + const workspaceRoot = realpathIfExists(workspace); + const relativePath = resolved.kind !== "directory" && isPathInside(resolved.path, workspaceRoot) + ? path.relative(workspaceRoot, resolved.path).split(path.sep).join("/") + : null; return httpJsonResponse({ ok: true, path: resolved.path, name: path.basename(expandHomePath(artifactRequest.path)) || path.basename(resolved.path), kind: resolved.kind, + ...(relativePath ? { relative_path: relativePath } : {}), ...(resolved.mediaUrl ? { media_url: resolved.mediaUrl } : {}), }); } diff --git a/App/memmy-agent/tests/integrations/channels/websocket-http-routes.test.ts b/App/memmy-agent/tests/integrations/channels/websocket-http-routes.test.ts index a17a5424e..b929003c9 100644 --- a/App/memmy-agent/tests/integrations/channels/websocket-http-routes.test.ts +++ b/App/memmy-agent/tests/integrations/channels/websocket-http-routes.test.ts @@ -1224,7 +1224,13 @@ describe("WebSocket HTTP route helpers", () => { body: JSON.stringify({ path: note, sessionKey }), }); expect(resolvedFile.status).toBe(200); - expect(await resolvedFile.json()).toMatchObject({ ok: true, path: resolvedNotePath, name: "result.md", kind: "file" }); + expect(await resolvedFile.json()).toMatchObject({ + ok: true, + path: resolvedNotePath, + name: "result.md", + kind: "file", + relative_path: "result.md" + }); const resolvedImage = await fetch(`http://127.0.0.1:${port}/api/webui/artifacts/resolve`, { method: "POST", @@ -1232,7 +1238,13 @@ describe("WebSocket HTTP route helpers", () => { body: JSON.stringify({ path: "diagram.png", sessionKey }), }); expect(resolvedImage.status).toBe(200); - expect(await resolvedImage.json()).toMatchObject({ ok: true, name: "diagram.png", kind: "image", media_url: expect.stringMatching(/^\/api\/media\//) }); + expect(await resolvedImage.json()).toMatchObject({ + ok: true, + name: "diagram.png", + kind: "image", + relative_path: "diagram.png", + media_url: expect.stringMatching(/^\/api\/media\//) + }); const stagedOutside = await fetch(`http://127.0.0.1:${port}/api/webui/artifacts/resolve`, { method: "POST", @@ -1240,7 +1252,9 @@ describe("WebSocket HTTP route helpers", () => { body: JSON.stringify({ path: outside, sessionKey }), }); expect(stagedOutside.status).toBe(200); - expect(await stagedOutside.json()).toMatchObject({ ok: true, name: "outside.md", kind: "file", media_url: expect.stringMatching(/^\/api\/media\//) }); + const stagedOutsideBody = await stagedOutside.json(); + expect(stagedOutsideBody).toMatchObject({ ok: true, name: "outside.md", kind: "file", media_url: expect.stringMatching(/^\/api\/media\//) }); + expect(stagedOutsideBody.relative_path).toBeUndefined(); const resolvedDirectory = await fetch(`http://127.0.0.1:${port}/api/webui/artifacts/resolve`, { method: "POST", From f70b5ee8814787b6e3a8902d95eab7a5246e1664 Mon Sep 17 00:00:00 2001 From: Wenqiang Wei <46308778+endxxxx@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:19:30 +0800 Subject: [PATCH 2/2] fix(desktop): refresh the preview file tree and drop the redundant PDF fit-page control External create/delete was invisible until a reload, and fit-page looked the same as fit-width in the side pane. Co-authored-by: Cursor --- App/frontend/desktop/src/i18n/messages.ts | 6 +-- .../pages/file-preview/file-preview-types.ts | 2 +- .../pages/file-preview/pdf-preview-state.ts | 13 +----- .../src/pages/file-preview/pdf-preview.tsx | 16 +++----- .../src/pages/tests/pdf-preview.test.ts | 13 ++---- ...kspace-artifact-panel.interaction.test.tsx | 17 ++++++++ .../src/pages/workspace-artifact-panel.tsx | 41 +++++++++++++++++++ App/frontend/desktop/src/styles.css | 30 ++++++++++++-- 8 files changed, 98 insertions(+), 40 deletions(-) diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index e051cb098..d55a249a9 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -171,8 +171,7 @@ export const zhCNMessages = { "filePreview.findInFile": "在文件中搜索", "filePreview.zoomOut": "缩小", "filePreview.zoomIn": "放大", - "filePreview.fitWidth": "适合宽度", - "filePreview.fitPage": "适合页面", + "filePreview.fitWidth": "适应宽度(按栏宽缩放,可上下滚动)", "filePreview.page": "页码", "filePreview.pageCount": "第 {page} 页,共 {count} 页", "filePreview.truncated": "文件内容已截断", @@ -1973,8 +1972,7 @@ export const enUSMessages: Record = { "filePreview.findInFile": "Find in file", "filePreview.zoomOut": "Zoom out", "filePreview.zoomIn": "Zoom in", - "filePreview.fitWidth": "Fit width", - "filePreview.fitPage": "Fit page", + "filePreview.fitWidth": "Fit width (fill the pane; scroll vertically if needed)", "filePreview.page": "Page", "filePreview.pageCount": "Page {page} of {count}", "filePreview.truncated": "File content was truncated", diff --git a/App/frontend/desktop/src/pages/file-preview/file-preview-types.ts b/App/frontend/desktop/src/pages/file-preview/file-preview-types.ts index e55401c00..16d91a8bb 100644 --- a/App/frontend/desktop/src/pages/file-preview/file-preview-types.ts +++ b/App/frontend/desktop/src/pages/file-preview/file-preview-types.ts @@ -15,7 +15,7 @@ export interface FilePreviewResource { export interface FilePreviewViewState { page?: number; scale?: number; - fit?: "width" | "page" | null; + fit?: "width" | null; scrollTop?: number; markdownMode?: "preview" | "source"; } diff --git a/App/frontend/desktop/src/pages/file-preview/pdf-preview-state.ts b/App/frontend/desktop/src/pages/file-preview/pdf-preview-state.ts index f374f213e..44524082f 100644 --- a/App/frontend/desktop/src/pages/file-preview/pdf-preview-state.ts +++ b/App/frontend/desktop/src/pages/file-preview/pdf-preview-state.ts @@ -3,27 +3,18 @@ export function nextPdfMatchIndex(current: number, direction: -1 | 1, count: num return (current + direction + count) % count; } -/** Computes the rendered scale for manual zoom or fit-to-width / fit-to-page modes. */ +/** Computes the rendered scale for manual zoom or fit-to-width mode. */ export function computePdfDisplayScale(input: { pageWidth: number; pageHeight: number; viewportWidth: number; viewportHeight: number; - fit: "width" | "page" | null; + fit: "width" | null; scale: number; }): number { if (input.fit === "width") { return Math.max(0.1, input.viewportWidth / input.pageWidth); } - if (input.fit === "page") { - return Math.max( - 0.1, - Math.min( - input.viewportWidth / input.pageWidth, - input.viewportHeight / input.pageHeight - ) - ); - } return input.scale; } diff --git a/App/frontend/desktop/src/pages/file-preview/pdf-preview.tsx b/App/frontend/desktop/src/pages/file-preview/pdf-preview.tsx index a2d8d61b1..966ab14e3 100644 --- a/App/frontend/desktop/src/pages/file-preview/pdf-preview.tsx +++ b/App/frontend/desktop/src/pages/file-preview/pdf-preview.tsx @@ -12,7 +12,6 @@ import { ChevronRight, Minus, Plus, - Scan, Search, UnfoldHorizontal, GalleryVertical, @@ -63,7 +62,9 @@ export function PdfPreview(props: PdfPreviewProps): ReactNode { const [page, setPage] = useState(props.initialState?.page ?? 1); const [pageDraft, setPageDraft] = useState(String(props.initialState?.page ?? 1)); const [scale, setScale] = useState(props.initialState?.scale ?? 1); - const [fit, setFit] = useState<"width" | "page" | null>(props.initialState?.fit ?? "width"); + const [fit, setFit] = useState<"width" | null>( + props.initialState?.fit === null ? null : "width" + ); const [searchOpen, setSearchOpen] = useState(false); const [query, setQuery] = useState(""); const [matches, setMatches] = useState([]); @@ -252,7 +253,6 @@ export function PdfPreview(props: PdfPreviewProps): ReactNode { {Math.round(displayScale * 100)}% -
@@ -308,7 +308,6 @@ export function PdfPreview(props: PdfPreviewProps): ReactNode { document={document} pageNumber={number} targetWidth={viewportSize.width} - targetHeight={viewportSize.height} scale={effectiveScale} fit={fit} searchQuery={query} @@ -330,9 +329,8 @@ function PdfCanvasPage(props: { document: PDFDocumentProxy; pageNumber: number; targetWidth: number; - targetHeight?: number; scale?: number; - fit?: "width" | "page" | null; + fit?: "width" | null; thumbnail?: boolean; searchQuery?: string; activeSearchOccurrence?: number | null; @@ -376,15 +374,13 @@ function PdfCanvasPage(props: { ? props.targetWidth / natural.width : props.fit === "width" ? props.targetWidth / natural.width - : props.fit === "page" - ? Math.min(props.targetWidth / natural.width, (props.targetHeight ?? natural.height) / natural.height) - : props.scale ?? 1; + : props.scale ?? 1; return page.getViewport({ scale: props.thumbnail || props.fit ? Math.max(0.1, chosenScale) : clamp(chosenScale) }); - }, [page, props.fit, props.scale, props.targetHeight, props.targetWidth, props.thumbnail]); + }, [page, props.fit, props.scale, props.targetWidth, props.thumbnail]); useEffect(() => { const canvas = canvasRef.current; diff --git a/App/frontend/desktop/src/pages/tests/pdf-preview.test.ts b/App/frontend/desktop/src/pages/tests/pdf-preview.test.ts index 41101211b..2c2dbb429 100644 --- a/App/frontend/desktop/src/pages/tests/pdf-preview.test.ts +++ b/App/frontend/desktop/src/pages/tests/pdf-preview.test.ts @@ -17,7 +17,7 @@ describe("PDF preview navigation", () => { expect(nextPdfMatchIndex(-1, 1, 0)).toBe(-1); }); - it("computes distinct fit-width and fit-page scales and keeps them in the toolbar display path", () => { + it("computes fit-width scale for the toolbar display path", () => { expect(computePdfDisplayScale({ pageWidth: 600, pageHeight: 900, @@ -26,14 +26,6 @@ describe("PDF preview navigation", () => { fit: "width", scale: 1.4 })).toBeCloseTo(1, 5); - expect(computePdfDisplayScale({ - pageWidth: 600, - pageHeight: 900, - viewportWidth: 600, - viewportHeight: 400, - fit: "page", - scale: 1.4 - })).toBeCloseTo(400 / 900, 5); expect(computePdfDisplayScale({ pageWidth: 600, pageHeight: 900, @@ -48,8 +40,9 @@ describe("PDF preview navigation", () => { expect(source).toContain("Math.round(displayScale * 100)"); expect(source).toContain("GalleryVertical"); expect(source).toContain("UnfoldHorizontal"); - expect(source).toContain("Scan"); expect(source).toContain("programmaticScrollUntilRef"); + expect(source).not.toContain("Scan"); + expect(source).not.toContain("fitPage"); expect(source).not.toContain("PanelLeftClose"); expect(source).not.toContain("ChevronsUpDown"); }); diff --git a/App/frontend/desktop/src/pages/tests/workspace-artifact-panel.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/workspace-artifact-panel.interaction.test.tsx index acaee923a..5959e92f7 100644 --- a/App/frontend/desktop/src/pages/tests/workspace-artifact-panel.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/workspace-artifact-panel.interaction.test.tsx @@ -218,6 +218,23 @@ describe("WorkspaceArtifactPanel", () => { expect(container.querySelector(".workspace-artifact-preview-toolbar .workspace-artifact-file-browser__toggle")).not.toBeNull(); }); + it("reloads cached directories when the refresh button is pressed", async () => { + await expandFolder("downloads"); + expect(loadDirectory).toHaveBeenCalledWith({ kind: "session", key: SESSION_KEY }, "downloads"); + const before = loadDirectory.mock.calls.length; + const refresh = container.querySelector( + '.workspace-artifact-preview-toolbar__actions button[title="刷新文件树"]' + )!; + await act(async () => { + refresh.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(loadDirectory.mock.calls.length).toBeGreaterThan(before); + expect(loadDirectory).toHaveBeenCalledWith({ kind: "session", key: SESSION_KEY }, ""); + expect(loadDirectory).toHaveBeenCalledWith({ kind: "session", key: SESSION_KEY }, "downloads"); + }); + it("opens a workspace-relative path requested from outside the panel", async () => { await renderPreview(0, false, { path: "outputs/综述.tex", nonce: 1 }); await act(async () => { diff --git a/App/frontend/desktop/src/pages/workspace-artifact-panel.tsx b/App/frontend/desktop/src/pages/workspace-artifact-panel.tsx index 673c6cc70..ac5511a47 100644 --- a/App/frontend/desktop/src/pages/workspace-artifact-panel.tsx +++ b/App/frontend/desktop/src/pages/workspace-artifact-panel.tsx @@ -16,6 +16,7 @@ import { Folder, PanelLeftClose, PanelLeftOpen, + RefreshCw, X } from "lucide-react"; import type { @@ -179,8 +180,11 @@ export function WorkspaceArtifactPanel(props: WorkspaceArtifactPanelProps): Reac const [listingsByDirectory, setListingsByDirectory] = useState< Record >({}); + const listingsByDirectoryRef = useRef(listingsByDirectory); + listingsByDirectoryRef.current = listingsByDirectory; const [loadingDirectories, setLoadingDirectories] = useState>({}); const [treeLoadFailed, setTreeLoadFailed] = useState(false); + const [manualRefreshing, setManualRefreshing] = useState(false); const [{ paths: openPreviewTabs, activePath: previewPath }, dispatchPreviewTabs] = useReducer( previewTabsReducer, { paths: [], activePath: null } @@ -336,6 +340,32 @@ export function WorkspaceArtifactPanel(props: WorkspaceArtifactPanelProps): Reac dispatchPreviewTabs({ type: "select", path }); }, [props.focusExternalFile?.id, props.focusExternalFile?.name, props.focusExternalFile?.nonce]); + function refreshFileTree() { + if (manualRefreshing) return; + const generation = requestGenerationRef.current + 1; + requestGenerationRef.current = generation; + setTreeLoadFailed(false); + setManualRefreshing(true); + const cachedPaths = Object.keys(listingsByDirectoryRef.current); + const paths = cachedPaths.length > 0 ? cachedPaths : [ROOT_DIRECTORY_KEY]; + void (async () => { + try { + await Promise.all(paths.map((path) => ( + requestDirectory(props.scope, path, generation).catch(() => { + if (path === ROOT_DIRECTORY_KEY && requestGenerationRef.current === generation) { + setTreeLoadFailed(true); + } + return null; + }) + ))); + } finally { + // Always clear — a newer refreshKey/scope generation must not leave the + // button stuck disabled with `manualRefreshing === true`. + setManualRefreshing(false); + } + })(); + } + function selectPreviewFile(path: string) { dispatchPreviewTabs({ type: "select", path }); } @@ -586,6 +616,17 @@ export function WorkspaceArtifactPanel(props: WorkspaceArtifactPanelProps): Reac })}
+ {!activeExtra ? ( + + ) : null} {props.toolbarEnd}
diff --git a/App/frontend/desktop/src/styles.css b/App/frontend/desktop/src/styles.css index cf2046fb4..bdcf39b85 100644 --- a/App/frontend/desktop/src/styles.css +++ b/App/frontend/desktop/src/styles.css @@ -2100,7 +2100,8 @@ body.memmy-platform-windows:not(.memmy-window-fullscreen) .app-frame-main--windo } .agent-workspace-layout--preview-open > .workspace-artifact-preview-pane .workspace-artifact-preview-toolbar { - padding-right: 44px; + /* Refresh + panel toggle share the trailing action strip. */ + padding-right: 76px; } /* @@ -12619,7 +12620,7 @@ input[type="checkbox"].check-sky:checked::after { flex: none; height: var(--codex-toolbar-height); min-height: var(--codex-toolbar-height); - padding: 0 44px 0 8px; + padding: 0 76px 0 8px; overflow: hidden; border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); background: var(--color-background-paper); @@ -12630,10 +12631,13 @@ input[type="checkbox"].check-sky:checked::after { position: absolute; top: 50%; right: 8px; - z-index: 4; + z-index: 5; display: flex; align-items: center; + gap: 2px; transform: translateY(-50%); + -webkit-app-region: no-drag; + pointer-events: auto; } .workspace-artifact-preview-toolbar__actions > button { @@ -12642,13 +12646,17 @@ input[type="checkbox"].check-sky:checked::after { height: var(--codex-toolbar-button-size); align-items: center; justify-content: center; + border: 0; border-radius: var(--radius-btn); + background: transparent; color: color-mix(in srgb, var(--color-text-ink) 52%, transparent); + cursor: pointer; } .workspace-artifact-preview-toolbar__actions > button:hover { - background: color-mix(in srgb, var(--color-canvas-oat) 65%, transparent); + background: color-mix(in srgb, var(--color-canvas-oat) 80%, transparent); color: var(--color-text-ink); + box-shadow: 0 1px 3px color-mix(in srgb, var(--color-text-ink) 12%, transparent); } .workspace-artifact-preview-toolbar__actions > button:focus-visible { @@ -12656,6 +12664,20 @@ input[type="checkbox"].check-sky:checked::after { outline-offset: -2px; } +.workspace-artifact-preview-toolbar__actions > button:disabled { + opacity: 0.55; + cursor: progress; + box-shadow: none; +} + +@keyframes workspace-artifact-refresh-spin { + to { transform: rotate(360deg); } +} + +.workspace-artifact-refresh--spin { + animation: workspace-artifact-refresh-spin 0.8s linear infinite; +} + .workspace-artifact-preview-main > .workspace-artifact-preview-crumb { flex: none; padding: 14px 16px 0;