diff --git a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts index 5fdaba0b3..a7ead31a8 100644 --- a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts +++ b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts @@ -4,7 +4,7 @@ import type { ThemeService } from '../../../platform/themes/ThemeService' import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' import type { ExtensionTaskAction, ExtensionTaskActionInput, ExtensionTaskActionResult } from '../../../shared/extensions/extensionActionApi' import type { ExtensionAgentCatalog, ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../../shared/extensions/extensionAgent' -import type { ExtensionReview, ExtensionReviewResult, ExtensionWorkbenchEvent } from '../../../shared/extensions/extensionApi' +import type { ExtensionReview, ExtensionReviewResult, ExtensionWorkbenchEvent, ExtensionWorkbenchResult } from '../../../shared/extensions/extensionApi' import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring' import type { ExtensionConditionRuntime } from '../../../shared/extensions/extensionConditionContext' import type { TaskFileTarget } from '../../../shared/files/taskFileApi' @@ -20,6 +20,7 @@ import { seedBundledThemes } from '../../../platform/themes/bundledThemes' import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' import { EXTENSION_IPC, extensionError, extensionManagementSchema } from '../../../shared/extensions/extensionApi' import { ExtensionCompatibilityError } from '../../../shared/extensions/extensionCompatibility' +import { extensionReferenceCaptureSchema } from '../../../shared/extensions/extensionReferences' import { assertTrustedSender } from '../ipc' import { compileExtension } from './compileExtension' import { createExtensionClipboard } from './ExtensionClipboard' @@ -62,7 +63,7 @@ export function registerExtensionIpc(options: { const protocol = new ExtensionProtocol(store) const stopProtocol = protocol.install(session.defaultSession, 'view') const hosts = new Set() - const replies = new Map void>() + const replies = new Map void }>() const bound = new Set() let resourcePickerOpen = false const guardNavigation = (event: Electron.Event) => { @@ -157,8 +158,8 @@ export function registerExtensionIpc(options: { current.webContents.send(EXTENSION_IPC.workbench, { kind: 'cancel', requestId: event.requestId } satisfies ExtensionWorkbenchEvent) finish(null) } - const timer = event.kind === 'reference' ? undefined : setTimeout(cancel, 10000) - function finish(value: string | null) { + const timer = event.kind === 'reference' || event.kind === 'reference-commit' ? undefined : setTimeout(cancel, 10000) + function finish(value: ExtensionWorkbenchResult) { if (settled) return settled = true @@ -167,7 +168,7 @@ export function registerExtensionIpc(options: { replies.delete(event.requestId) resolve(value) } - replies.set(event.requestId, finish) + replies.set(event.requestId, { kind: event.kind, finish }) signal.addEventListener('abort', cancel, { once: true }) current.webContents.send(EXTENSION_IPC.workbench, event) }), @@ -213,7 +214,7 @@ export function registerExtensionIpc(options: { current.webContents.on('render-process-gone', resetHosts) current.webContents.once('destroyed', () => { bound.delete(current) - for (const reply of replies.values()) reply(null) + for (const reply of replies.values()) reply.finish(null) resetHosts() }) } @@ -301,9 +302,15 @@ export function registerExtensionIpc(options: { const current = window() if (!current || current.isDestroyed() || event.sender !== current.webContents || event.senderFrame !== current.webContents.mainFrame) return - const input = z.object({ requestId: z.string().uuid(), viewId: z.string().uuid().nullable() }).strict().safeParse(raw) - if (input.success) - replies.get(input.data.requestId)?.(input.data.viewId) + const input = z.object({ requestId: z.string().uuid(), result: z.unknown() }).strict().safeParse(raw) + if (!input.success) + return + const reply = replies.get(input.data.requestId) + if (!reply) + return + const result = (reply.kind === 'reference-capture' ? extensionReferenceCaptureSchema : z.string().uuid()).nullable().safeParse(input.data.result) + if (result.success) + reply.finish(result.data) } ipcMain.on(EXTENSION_IPC.workbenchReply, onWorkbenchReply) ipcMain.handle(EXTENSION_IPC.hostRequest, async (event, method: unknown, params: unknown) => { @@ -327,7 +334,7 @@ export function registerExtensionIpc(options: { } finally { for (const subscription of subscriptions) subscription.dispose() - for (const reply of replies.values()) reply(null) + for (const reply of replies.values()) reply.finish(null) for (const current of bound) { if (current.isDestroyed()) continue diff --git a/apps/buddy/electron/main/extensions/runtime/view.js b/apps/buddy/electron/main/extensions/runtime/view.js index c5936a004..55b845646 100644 --- a/apps/buddy/electron/main/extensions/runtime/view.js +++ b/apps/buddy/electron/main/extensions/runtime/view.js @@ -28,7 +28,7 @@ function request(method, params = null) { if (pending.size >= 64) return reject(new Error('EXTENSION_REQUEST_LIMIT')) const id = crypto.randomUUID() - const longRunning = waitsForExtensionIo(method) || ['commands.execute', 'references.add'].includes(method) + const longRunning = waitsForExtensionIo(method) || ['commands.execute', 'references.add', 'references.commit'].includes(method) const timer = longRunning ? undefined : setTimeout(() => { @@ -162,6 +162,9 @@ async function initialize() { setPresentation: presentation => request('view.setPresentation', presentation), references: Object.freeze({ add: input => request('references.add', input.kind === 'resource' ? { ...input, resource: { id: input.resource.id } } : input), + capture: input => request('references.capture', input.kind === 'resource' ? { ...input, resource: { id: input.resource.id } } : input), + commit: (id, choice) => request('references.commit', { id, choice }), + release: id => request('references.release', { id }), onReveal: handler => references.onReveal(handler), }), resources: { diff --git a/apps/buddy/electron/preload/extensions.ts b/apps/buddy/electron/preload/extensions.ts index 865571f34..a48186fb1 100644 --- a/apps/buddy/electron/preload/extensions.ts +++ b/apps/buddy/electron/preload/extensions.ts @@ -45,6 +45,6 @@ export function createExtensionApi(): ExtensionApi { onChanged: listener => subscribe(EXTENSION_IPC.changed, listener), onReview: listener => subscribe(EXTENSION_IPC.review, listener), onWorkbench: listener => subscribe(EXTENSION_IPC.workbench, listener), - replyWorkbench: (requestId, viewId) => ipcRenderer.send(EXTENSION_IPC.workbenchReply, { requestId, viewId }), + replyWorkbench: (requestId, result) => ipcRenderer.send(EXTENSION_IPC.workbenchReply, { requestId, result }), } satisfies ExtensionApi) } diff --git a/apps/buddy/platform/extensions/ExtensionService.ts b/apps/buddy/platform/extensions/ExtensionService.ts index 537626624..06d16c0c3 100644 --- a/apps/buddy/platform/extensions/ExtensionService.ts +++ b/apps/buddy/platform/extensions/ExtensionService.ts @@ -1,6 +1,6 @@ import type { ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../shared/extensions/extensionAgent' import type { ExtensionAgentResourceRequest, ExtensionAgentResourceResponse } from '../../shared/extensions/extensionAgentResources' -import type { ExtensionFileOpeningInput, ExtensionFileOpeningSource, ExtensionMenuInvocation, ExtensionPreparedFileOpening, ExtensionResource, ExtensionStatus, ExtensionViewInput, ExtensionViewSession, ExtensionWorkbenchEvent } from '../../shared/extensions/extensionApi' +import type { ExtensionFileOpeningInput, ExtensionFileOpeningSource, ExtensionMenuInvocation, ExtensionPreparedFileOpening, ExtensionResource, ExtensionStatus, ExtensionViewInput, ExtensionViewSession, ExtensionWorkbenchEvent, ExtensionWorkbenchResult } from '../../shared/extensions/extensionApi' import type { ExtensionInspection } from '../../shared/extensions/extensionAuthoring' import type { ExtensionClipboardData } from '../../shared/extensions/extensionClipboard' import type { ExtensionConditionRuntime } from '../../shared/extensions/extensionConditionContext' @@ -43,7 +43,7 @@ import { extensionDiagnostic, ExtensionRequestError } from '../../shared/extensi import { extensionDocumentInfoSchema } from '../../shared/extensions/extensionDocumentInfo' import { extensionFileHandlerBindings } from '../../shared/extensions/extensionFileOpening' import { extensionCompatible, extensionManifestSchema, extensionPathSchema } from '../../shared/extensions/extensionManifest' -import { extensionReferenceInputSchema, extensionReferenceOpeningSchema, extensionReferenceTargetSchema } from '../../shared/extensions/extensionReferences' +import { extensionReferenceCaptureSchema, extensionReferenceCommitSchema, extensionReferenceInputSchema, extensionReferenceOpeningSchema, extensionReferenceTargetSchema } from '../../shared/extensions/extensionReferences' import { extensionDirectoryScanSchema, extensionResourceCreateSchema, extensionResourceSelectionSchema } from '../../shared/extensions/extensionResources' import { extensionNotificationSchema, extensionScheduleIdSchema, extensionScheduleInputSchema } from '../../shared/extensions/extensionSchedule' import { extensionConfigurationAppliedSchema, resolveExtensionConfiguration, validateExtensionSetting } from '../../shared/extensions/extensionSettings' @@ -79,7 +79,7 @@ export interface ExtensionServicePorts { themes?: ThemeService createHost: (pkg: ExtensionPackage, broker: (method: string, params: unknown) => Promise, failed: (failure?: ExtensionHostFailure) => void) => ExtensionHost createView: (pkg: ExtensionPackage, readSelected?: (id: string, request: Request, signal: AbortSignal) => Promise) => { token: string, url: string, dispose: () => void } - workbench: (event: ExtensionWorkbenchEvent, signal: AbortSignal) => Promise + workbench: (event: ExtensionWorkbenchEvent, signal: AbortSignal) => Promise readText: (target: SpaceFileTarget, signal: AbortSignal) => Promise openExternal?: (url: string) => Promise readResource?: (target: SpaceFileTarget, signal: AbortSignal, request?: Request) => Promise @@ -112,6 +112,7 @@ interface RunningExtension { panesPending?: boolean } interface RunningView { + referenceCapture?: string input: ExtensionViewInput session: ExtensionViewSession running: RunningExtension @@ -1194,18 +1195,31 @@ export class ExtensionService { throw new Error('EXTENSION_VIEW_EXPIRED') }, view.abort.signal, view.input.resource?.id ?? null) } - else if (method === 'references.add') { + else if (['references.add', 'references.capture', 'references.commit', 'references.release'].includes(method)) { if (view.running.package.manifest.apiVersion < 4 || !view.running.package.manifest.permissions.references) throw new Error('EXTENSION_REFERENCES_DENIED') if (!['context', 'page'].includes(contribution.location) || (placement && placement.kind !== 'view')) throw new Error('EXTENSION_METHOD_DENIED') - const input = extensionReferenceInputSchema.parse(params) - const material = await this.#referenceMaterial(view, input) - this.#assertCurrent(view.running) - if (view.abort.signal.aborted || this.#views.get(id) !== view) - throw new Error('EXTENSION_VIEW_EXPIRED') - const accepted = await this.#ports.workbench({ kind: 'reference', requestId: randomUUID(), viewId: id, generation, token, material }, AbortSignal.any([view.abort.signal, view.running.abort.signal])) - result = accepted === id + const binding = { requestId: randomUUID(), viewId: id, generation, token } + const signal = AbortSignal.any([view.abort.signal, view.running.abort.signal]) + if (method === 'references.commit' || method === 'references.release') { + const event: ExtensionWorkbenchEvent = method === 'references.commit' + ? { ...binding, kind: 'reference-commit', ...extensionReferenceCommitSchema.parse(params) } + : { ...binding, kind: 'reference-release', ...z.object({ id: z.string().uuid() }).strict().parse(params) } + result = await this.#ports.workbench(event, signal) === id + } + else { + if (method === 'references.capture') + view.referenceCapture = binding.requestId + const material = await this.#referenceMaterial(view, extensionReferenceInputSchema.parse(params)) + if (method === 'references.capture' && view.referenceCapture !== binding.requestId) + return null + this.#assertCurrent(view.running) + if (view.abort.signal.aborted || this.#views.get(id) !== view) + throw new Error('EXTENSION_VIEW_EXPIRED') + const accepted = await this.#ports.workbench({ ...binding, kind: method === 'references.capture' ? 'reference-capture' : 'reference', material }, signal) + result = method === 'references.capture' ? (accepted === null ? null : extensionReferenceCaptureSchema.parse(accepted)) : accepted === id + } } else if (method === 'view.setActive') { if (placement?.kind !== 'slot' && placement?.kind !== 'control') diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts b/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts index 70f3a0530..e68705e8d 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts +++ b/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts @@ -282,6 +282,13 @@ export interface ReferenceLocator { readonly version: number readonly data: ReadonlyJson } +export type ReferenceChoice = { kind: 'view', id: string } | { kind: 'new-task' | 'clipboard' } +export interface ReferenceCapture { + readonly id: string + readonly targets: readonly { readonly id: string, readonly label: string }[] + readonly defaultId: string | null + readonly clipboard: boolean +} export interface ReferenceReveal { readonly referenceId: string readonly text: string @@ -332,6 +339,9 @@ export interface ViewContext { openExternal: (url: string) => Promise readonly references: { add: (input: ReferenceInput) => Promise + capture: (input: ReferenceInput) => Promise + commit: (id: string, choice: ReferenceChoice) => Promise + release: (id: string) => Promise onReveal: (handler: (input: ReferenceReveal, signal: AbortSignal) => boolean | Promise) => Disposable } readonly resources: LocalResourceApi diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md index 5f1dbbbbe..eeb9051df 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md @@ -46,7 +46,9 @@ ## 引用到对话 -API 4 中 `location: "context"` 的交互资源视图可声明 `permissions.references: true`,通过 `context.references.add(input)` 选择任务草稿。该视图作为独立导航入口显示或没有可用任务时,内容引用改为复制到系统剪贴板;粘贴回当前 Lexora 窗口可恢复引用卡片与定位信息,重启或换窗口后仅保留文字。`location: "page"` 的视图、装饰、插槽与替换控件不支持此能力。成功加入草稿或复制引用返回 `true`,取消返回 `false`;授权或资源失效会抛错。等待用户选择没有固定的十秒期限;关闭视图或停用插件会取消请求。此操作不会发送消息或调用模型。 +API 4 的 `context` 和 `page` 视图可声明 `permissions.references: true`,通过 `context.references.add(input)` 选择任务草稿、新建任务或复制引用。装饰、插槽与替换控件不支持此能力。成功加入草稿或复制引用返回 `true`,取消返回 `false`;授权或资源失效会抛错。等待用户选择没有固定的十秒期限;关闭视图或停用插件会取消请求。此操作不会保存源文档、发送消息或调用模型。 + +自有内容菜单使用 `references.capture(input)` 冻结材料和当时可接收的任务草稿,返回 `{ id, targets: [{ id, label }], defaultId, clipboard }`;视图不可用时返回 `null`。唯一目标的 `defaultId` 可作为主动作,多个目标由插件在子菜单呈现,同时提供新任务及允许时的复制入口。点击后调用 `references.commit(id, { kind: 'view', id: targetId })`、`{ kind: 'new-task' }` 或 `{ kind: 'clipboard' }`。提交会等待草稿持久化,不设固定十秒期限;成功返回 `true`,失效返回 `false`,不会悄悄选择后来激活的任务。菜单关闭时调用 `references.release(id)`。每个视图只保留最近一次捕获,隐藏、关闭、替换会话或已消费后失效;插件不能持久化或跨视图复用捕获 ID。捕获不保存源文档,也不修改草稿。该交互需要 Lexora 0.17.0 或更新版本。 ```ts await context.references.add({ @@ -95,7 +97,6 @@ context.references.onReveal(async ({ locator, text }, signal) => { 这两项增量 API 应先检查 `typeof context.setDocumentInfo === 'function'` 和 `context.clipboard`,缺失时提示升级宿主;清单兼容范围使用实际支持此能力的宿主版本。 - ## 文件关联与独立入口 使用下列新增声明时,`apiVersion` 保持 `4`,`engines.lexora` 必须以首次提供这些能力的应用版本为下限;不能仅凭旧宿主也支持 API 4 就声明兼容。应用版本和 API 版本分别约束能力可用性与协议兼容性。 @@ -357,5 +358,4 @@ API 3 的 `contributes.iconThemes` 可贡献独立的文件与目录图标主题 复杂效果仍可创建元素、设置样式,或引入包内 CSS:`link.href = new URL('./style.css', import.meta.url).href`。效果应柔和短暂,不遮挡正文或确认操作。CSS 只作用于插件自己的隔离页面。 - 临时覆盖层、分屏布局订阅与局部点击区域见 [交互会话](interactions.md)。 diff --git a/apps/buddy/shared/extensions/extensionApi.ts b/apps/buddy/shared/extensions/extensionApi.ts index 75e9805dd..5f7730def 100644 --- a/apps/buddy/shared/extensions/extensionApi.ts +++ b/apps/buddy/shared/extensions/extensionApi.ts @@ -11,7 +11,7 @@ import type { ExtensionDiagnostic } from './extensionDiagnostic' import type { ExtensionDocumentInfo } from './extensionDocumentInfo' import type { ExtensionInstallation } from './extensionInstallation' import type { ExtensionManifest } from './extensionManifest' -import type { ExtensionReferenceMaterial, ExtensionReferenceOpening } from './extensionReferences' +import type { ExtensionReferenceCapture, ExtensionReferenceChoice, ExtensionReferenceMaterial, ExtensionReferenceOpening } from './extensionReferences' import type { ExtensionConfiguration, ExtensionConfigurationSnapshot } from './extensionSettings' import type { ExtensionWindowStyle } from './extensionWindowStyles' import { z } from 'zod' @@ -44,7 +44,7 @@ const referenceTransportSchema = z.json().refine(value => new TextEncoder().enco export function parseExtensionRequestParams(method: string, params: unknown): JsonValue { if (method === 'clipboard.write') return extensionClipboardWriteSchema.parse(params) - if (method === 'references.add') + if (method === 'references.add' || method === 'references.capture') return referenceTransportSchema.parse(extensionReferenceInputSchema.parse(params)) return (method === 'themes.request' ? themeTransportSchema : extensionJsonSchema).parse(params) } @@ -129,6 +129,7 @@ export interface ExtensionReview { } export type ExtensionReviewResult = ExtensionReview | ExtensionIncompatibleReview export type ExtensionUnavailableInstallation = ExtensionIncompatibleReview & { pending: boolean } +export type ExtensionWorkbenchResult = string | ExtensionReferenceCapture | null export type ExtensionWorkbenchEvent = | { kind: 'cancel', requestId: string } | { kind: 'clear-data', requestId: string, extensionId: string } @@ -136,6 +137,9 @@ export type ExtensionWorkbenchEvent | { kind: 'state', requestId: string, viewId: string, generation: string, token: string, state: JsonValue, stateVersion: number } | { kind: 'document-info', requestId: string, viewId: string, generation: string, token: string, info: ExtensionDocumentInfo } | { kind: 'reference', requestId: string, viewId: string, generation: string, token: string, material: ExtensionReferenceMaterial } + | { kind: 'reference-capture', requestId: string, viewId: string, generation: string, token: string, material: ExtensionReferenceMaterial } + | { kind: 'reference-commit', requestId: string, viewId: string, generation: string, token: string, id: string, choice: ExtensionReferenceChoice } + | { kind: 'reference-release', requestId: string, viewId: string, generation: string, token: string, id: string } | { kind: 'interaction', requestId: string, extensionId: string, generation: string, interactionId: string, title: string | null } | { kind: 'message', requestId: string, extensionId: string, generation: string, message: JsonValue } | { kind: 'regions', requestId: string, viewId: string, generation: string, token: string, regions: WorkbenchHitRegion[] } @@ -222,7 +226,7 @@ export interface ExtensionApi { onChanged: (listener: () => void) => () => void onReview: (listener: (review: ExtensionReviewResult) => void) => () => void onWorkbench: (listener: (event: ExtensionWorkbenchEvent) => void) => () => void - replyWorkbench: (requestId: string, viewId: string | null) => void + replyWorkbench: (requestId: string, result: ExtensionWorkbenchResult) => void } const extensionModelFailureSchema = z.object({ diff --git a/apps/buddy/shared/extensions/extensionReferences.ts b/apps/buddy/shared/extensions/extensionReferences.ts index 1cae806bf..1a14216c7 100644 --- a/apps/buddy/shared/extensions/extensionReferences.ts +++ b/apps/buddy/shared/extensions/extensionReferences.ts @@ -44,3 +44,17 @@ export type ExtensionReferenceMaterial = | { kind: 'quote', quote: BuddyViewQuote } | { kind: 'source', source: BuddyComposerSource } | { kind: 'file', name: string, mimeType: string, base64: string } + +export const extensionReferenceChoiceSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('view'), id: z.string().uuid() }).strict(), + z.object({ kind: z.enum(['new-task', 'clipboard']) }).strict(), +]) +export const extensionReferenceCommitSchema = z.object({ id: z.string().uuid(), choice: extensionReferenceChoiceSchema }).strict() +export const extensionReferenceCaptureSchema = z.object({ + id: z.string().uuid(), + targets: z.array(z.object({ id: z.string().uuid(), label: z.string().max(512) }).strict()).max(64), + defaultId: z.string().uuid().nullable(), + clipboard: z.boolean(), +}).strict() +export type ExtensionReferenceChoice = z.infer +export type ExtensionReferenceCapture = z.infer diff --git a/apps/buddy/src/app/workbench/__tests__/useExtensionContributions.spec.ts b/apps/buddy/src/app/workbench/__tests__/useExtensionContributions.spec.ts index b7f9ecf4c..6083b711a 100644 --- a/apps/buddy/src/app/workbench/__tests__/useExtensionContributions.spec.ts +++ b/apps/buddy/src/app/workbench/__tests__/useExtensionContributions.spec.ts @@ -27,7 +27,10 @@ function setup() { const api: Partial = { endInteraction: async () => {}, onWorkbench: (listener) => { receive = listener return () => {} - }, replyWorkbench: (id, view) => replies.set(id, view) } + }, replyWorkbench: (id, view) => { + if (typeof view === 'string' || view === null) + replies.set(id, view) + } } const views = { surfaces: new Map(), proposeControl: () => false, retryControl: () => {} } as unknown as ExtensionViews const checkpoints: { views: typeof controller.layout.views, configuration: ReturnType }[] = [] const persistence = { flush: async () => {}, checkpoint: async () => { diff --git a/apps/buddy/src/app/workbench/__tests__/useExtensionReferences.spec.ts b/apps/buddy/src/app/workbench/__tests__/useExtensionReferences.spec.ts index e34dd76e2..3d6469958 100644 --- a/apps/buddy/src/app/workbench/__tests__/useExtensionReferences.spec.ts +++ b/apps/buddy/src/app/workbench/__tests__/useExtensionReferences.spec.ts @@ -14,15 +14,16 @@ import { useExtensionReferences } from '../useExtensionReferences' const cleanups: (() => void)[] = [] afterEach(() => cleanups.splice(0).forEach(cleanup => cleanup())) -function fixture(withTask = true) { +function fixture(withTask = true, withSecondTask = false) { const id = crypto.randomUUID() const quote = { id: crypto.randomUUID(), text: 'Selected snapshot', source: { kind: 'view' as const, title: 'Office', provider: 'extension:tests.office', target: { extensionId: 'tests.office', viewId: id, viewType: 'tests.office.editor', source: null } }, locator: { version: 1, data: { documentId: 'opaque-document' } } } const surface = shallowReactive({ ready: true, visible: true, eligible: true, error: null, session: { id, generation: 'generation', token: 'token' }, input: { extensionId: 'tests.office', viewType: 'tests.office.editor', resource: null } }) let identity = 'first-draft' let draft = createBuddyUserContent('Keep this draft') let targetAvailable = withTask - const references = new WorkbenchSelectionReferences({ targets: () => targetAvailable ? [{ id: 'task-view', identity, scope: 'draft', label: 'Task', read: () => draft, write: value => draft = value }] : [] }) - const controller = { layout: { views: { 'task-view': { id: 'task-view', resource: { scheme: 'draft' } } } } } as unknown as WorkbenchController + let secondDraft = createBuddyUserContent('Second draft') + const references = new WorkbenchSelectionReferences({ targets: () => [...(targetAvailable ? [{ id: 'task-view', identity, scope: 'draft', label: '分屏 1 · Task', pane: { index: 1, title: 'Task' }, read: () => draft, write: (value: typeof draft) => draft = value }] : []), ...(withSecondTask ? [{ id: 'second-view', identity: 'second-input', scope: 'second', label: '分屏 2 · Second task', pane: { index: 2, title: 'Second task' }, read: () => secondDraft, write: (value: typeof secondDraft) => secondDraft = value }] : [])] }) + const controller = { layout: { views: { 'task-view': { id: 'task-view', resource: { scheme: 'draft' } }, 'second-view': { id: 'second-view', resource: { scheme: 'draft' } } } } } as unknown as WorkbenchController const views = { surfaces: new Map([[id, surface]]) } as unknown as ExtensionViews const flushDrafts = vi.fn(async () => true) const pool = { peek: () => ({ flushDrafts }) } as unknown as TaskWorkspacePool @@ -38,7 +39,7 @@ function fixture(withTask = true) { cleanups.push(() => scope.stop()) const receiver = scope.run(() => useExtensionReferences({ references, controller, views, pool, writeClipboard, chooseTarget, prepareNewTaskInput, showTarget, notifySuccess, showPage: vi.fn(async () => false), installed: shallowRef([]), api: {} as ExtensionApi, persistence: {} as WorkbenchPersistence }))! const event: Extract = { kind: 'reference', requestId: crypto.randomUUID(), viewId: id, generation: 'generation', token: 'token', material: { kind: 'quote', quote } } - return { receiver, event, quote, surface, writeClipboard, chooseTarget, showTarget, notifySuccess, prepareNewTaskInput, flushDrafts, draft: () => draft, type: (text: string) => draft = createBuddyUserContent(text), switchTask: () => { + return { receiver, event, quote, surface, writeClipboard, chooseTarget, showTarget, notifySuccess, prepareNewTaskInput, flushDrafts, secondDraft: () => secondDraft, draft: () => draft, type: (text: string) => draft = createBuddyUserContent(text), switchTask: () => { identity = 'second-draft' } } } @@ -78,14 +79,23 @@ it('rejects a draft that changed identity while the target picker was open', asy expect(f.showTarget).not.toHaveBeenCalled() }) -it('waits for draft persistence before navigating and keeps the operation pending meanwhile', async () => { +it.each(['reference', 'reference-commit'] as const)('waits for %s draft persistence before navigating and keeps the operation pending meanwhile', async (kind) => { const f = fixture() + const signal = new AbortController().signal + let event: Extract = f.event + if (kind === 'reference-commit') { + const capture = await f.receiver.receive({ ...f.event, kind: 'reference-capture' }, signal) + if (!capture || typeof capture !== 'object') + throw new Error('capture unavailable') + event = { ...f.event, kind, id: capture.id, choice: { kind: 'view', id: 'task-view' } } + } const saving = deferred() f.flushDrafts.mockReturnValueOnce(saving.promise) - const receiving = f.receiver.receive(f.event, new AbortController().signal) + const receiving = f.receiver.receive(event, signal) await vi.waitFor(() => expect(f.flushDrafts).toHaveBeenCalledOnce()) expect(f.showTarget).not.toHaveBeenCalled() expect(await f.receiver.receive(f.event, new AbortController().signal)).toBe(false) + expect(await f.receiver.receive({ ...f.event, kind: 'reference-capture' }, new AbortController().signal)).toBeNull() expect(f.notifySuccess).not.toHaveBeenCalled() saving.resolve(true) expect(await receiving).toBe(true) @@ -131,3 +141,66 @@ it('does not acknowledge a source revoked while copying', async () => { expect(f.draft()).toEqual(createBuddyUserContent('Keep this draft')) expect(f.notifySuccess).not.toHaveBeenCalled() }) + +it('captures a target once and commits without a picker, preserving newer draft typing', async () => { + const f = fixture() + const signal = new AbortController().signal + const capture = await f.receiver.receive({ ...f.event, kind: 'reference-capture' }, signal) + expect(capture).toMatchObject({ defaultId: 'task-view', clipboard: true, targets: [{ id: 'task-view', label: '分屏 1 · Task' }] }) + if (!capture || typeof capture !== 'object') + throw new Error('capture unavailable') + f.type('Typed while choosing') + const commit = { ...f.event, kind: 'reference-commit' as const, id: capture.id, choice: { kind: 'view' as const, id: 'task-view' } } + expect(await f.receiver.receive(commit, signal)).toBe(true) + expect(f.draft().resourceQuotes).toEqual([f.quote]) + expect(f.draft().body[0]!.content).toContainEqual({ type: 'text', text: 'Typed while choosing' }) + expect(f.chooseTarget).not.toHaveBeenCalled() + expect(await f.receiver.receive(commit, signal)).toBe(false) +}) + +it.each(['draft', 'hidden', 'released', 'replaced'] as const)('invalidates captured reference on %s without changing another draft', async (reason) => { + const f = fixture() + const signal = new AbortController().signal + const capture = await f.receiver.receive({ ...f.event, kind: 'reference-capture' }, signal) + if (!capture || typeof capture !== 'object') + throw new Error('capture unavailable') + if (reason === 'draft') + f.switchTask() + if (reason === 'hidden') { + f.surface.visible = false + f.surface.visible = true + } + if (reason === 'released') + expect(await f.receiver.receive({ ...f.event, kind: 'reference-release', id: capture.id }, signal)).toBe(true) + if (reason === 'replaced') + await f.receiver.receive({ ...f.event, kind: 'reference-capture' }, signal) + expect(await f.receiver.receive({ ...f.event, kind: 'reference-commit', id: capture.id, choice: { kind: 'view', id: 'task-view' } }, signal)).toBe(false) + expect(f.draft()).toEqual(createBuddyUserContent('Keep this draft')) + expect(f.notifySuccess).not.toHaveBeenCalled() +}) + +it.each(['new-task', 'clipboard'] as const)('allows %s from a capture with no existing task', async (kind) => { + const f = fixture(false) + const signal = new AbortController().signal + const capture = await f.receiver.receive({ ...f.event, kind: 'reference-capture' }, signal) + expect(capture).toMatchObject({ defaultId: null, targets: [], clipboard: true }) + if (!capture || typeof capture !== 'object') + throw new Error('capture unavailable') + expect(await f.receiver.receive({ ...f.event, kind: 'reference-commit', id: capture.id, choice: { kind } }, signal)).toBe(true) + expect(f.notifySuccess).toHaveBeenCalledExactlyOnceWith({ kind }) + expect(f.chooseTarget).not.toHaveBeenCalled() +}) + +it('requires an explicit choice with two tasks and adds only to the captured target', async () => { + const f = fixture(true, true) + const signal = new AbortController().signal + const capture = await f.receiver.receive({ ...f.event, kind: 'reference-capture' }, signal) + expect(capture).toMatchObject({ defaultId: null, targets: [{ id: 'task-view', label: '分屏 1 · Task' }, { id: 'second-view', label: '分屏 2 · Second task' }] }) + if (!capture || typeof capture !== 'object') + throw new Error('capture unavailable') + f.type('Keep newer first draft') + expect(await f.receiver.receive({ ...f.event, kind: 'reference-commit', id: capture.id, choice: { kind: 'view', id: 'second-view' } }, signal)).toBe(true) + expect(f.draft()).toEqual(createBuddyUserContent('Keep newer first draft')) + expect(f.secondDraft().resourceQuotes).toEqual([f.quote]) + expect(f.secondDraft().body[0]!.content).toContainEqual({ type: 'text', text: 'Second draft' }) +}) diff --git a/apps/buddy/src/app/workbench/useExtensionContributions.ts b/apps/buddy/src/app/workbench/useExtensionContributions.ts index 77b6899aa..4e3714bc7 100644 --- a/apps/buddy/src/app/workbench/useExtensionContributions.ts +++ b/apps/buddy/src/app/workbench/useExtensionContributions.ts @@ -1,4 +1,4 @@ -import type { ExtensionApi, ExtensionStatus, ExtensionWorkbenchEvent } from '@buddy-shared/extensions/extensionApi' +import type { ExtensionApi, ExtensionStatus, ExtensionWorkbenchEvent, ExtensionWorkbenchResult } from '@buddy-shared/extensions/extensionApi' import type { Ref } from 'vue' import type { ExtensionUiContributions, ExtensionViews } from '@/modules/extensions' import type { ViewRendererRegistry } from '@/workbench/browser/ViewRendererRegistry' @@ -15,7 +15,7 @@ import { parseWorkbenchUiSelection, workbenchUiSelectionKey, workbenchUiTargetCa import { nextTick, onScopeDispose, watch } from 'vue' import { DesktopExtensionView } from '@/modules/extensions/ui' -export function useExtensionContributions(options: { controller: WorkbenchController, renderers: ViewRendererRegistry, persistence: WorkbenchPersistence, installed: Readonly>, api: ExtensionApi, views: ExtensionViews, ui: ExtensionUiContributions, ready: () => boolean, receiveReference?: (event: Extract, signal: AbortSignal) => Promise }) { +export function useExtensionContributions(options: { controller: WorkbenchController, renderers: ViewRendererRegistry, persistence: WorkbenchPersistence, installed: Readonly>, api: ExtensionApi, views: ExtensionViews, ui: ExtensionUiContributions, ready: () => boolean, receiveReference?: (event: Extract, signal: AbortSignal) => Promise> }) { const { controller, renderers, persistence, installed, api, views, ui } = options onScopeDispose(renderers.register('extensions.view', DesktopExtensionView)) const owners = new Map void }>() @@ -146,7 +146,7 @@ export function useExtensionContributions(options: { controller: WorkbenchContro drain() }, { flush: 'sync' }) async function handle(event: RequestEvent, signal: AbortSignal) { - let viewId: string | null = null + let result: ExtensionWorkbenchResult = null try { if (signal.aborted) return @@ -167,11 +167,11 @@ export function useExtensionContributions(options: { controller: WorkbenchContro } controller.configuration.restore(configuration) await persistence.checkpoint() - viewId = event.requestId + result = event.requestId } - else if (event.kind === 'reference') { - if (await options.receiveReference?.(event, signal)) - viewId = event.viewId + else if (event.kind === 'reference' || event.kind === 'reference-capture' || event.kind === 'reference-commit' || event.kind === 'reference-release') { + const received = await options.receiveReference?.(event, signal) + result = typeof received === 'object' ? received : received ? event.viewId : null } else if (event.kind === 'interaction') { if (event.title === null) { @@ -183,7 +183,7 @@ export function useExtensionContributions(options: { controller: WorkbenchContro void api.endInteraction(event.interactionId).catch(() => {}) }) } - viewId = event.interactionId + result = event.interactionId } else if (event.kind === 'message') { views.broadcast(event.extensionId, event.generation, event.message) @@ -203,20 +203,20 @@ export function useExtensionContributions(options: { controller: WorkbenchContro } if (signal.aborted || surface.session !== session) return - viewId = event.viewId + result = event.viewId } else if (event.kind === 'regions') { if (views.setRegions(event.viewId, event.generation, event.token, event.regions)) - viewId = event.viewId + result = event.viewId } else if (event.kind === 'control') { if (views.proposeControl(event.viewId, event.generation, event.token, event.proposal)) - viewId = event.viewId + result = event.viewId } else if (event.kind === 'activity') { if (views.setActive(event.viewId, event.generation, event.token, event.active)) { await nextTick() - viewId = event.viewId + result = event.viewId } } else if (event.kind === 'placement') { @@ -224,12 +224,12 @@ export function useExtensionContributions(options: { controller: WorkbenchContro const placement = plugin?.manifest.contributes.placements.find(placement => placement.id === event.placementId) const descriptor = plugin?.manifest.contributes.views.find(view => view.id === placement?.view) if ((placement?.kind === 'slot' || placement?.kind === 'control') && !event.instanceId && !event.interactionId) { - const result = ui.setEnabled(event.extensionId, event.generation, placement.id, event.visible) - if (result) { - if (result.changed && event.visible && placement.kind === 'control') + const updated = ui.setEnabled(event.extensionId, event.generation, placement.id, event.visible) + if (updated) { + if (updated.changed && event.visible && placement.kind === 'control') views.retryControl(placement.id) await nextTick() - viewId = event.requestId + result = event.requestId } return } @@ -240,10 +240,10 @@ export function useExtensionContributions(options: { controller: WorkbenchContro return const existing = Object.values(controller.layout.views).find(view => view.resource.scheme === 'extension' && view.resource.data.extensionId === event.extensionId && view.resource.data.placementId === placement.id && view.mountInstanceId === instanceId && view.interactionId === event.interactionId) if (event.visible) { - viewId = await controller.open({ scheme: 'extension', id: placement.id, data: { extensionId: event.extensionId, viewType: placement.view, resource: null, placementId: placement.id } }, descriptor.title, { signal, placement: placement.id, mountInstanceId: instanceId, interactionId: event.interactionId, viewType: placement.view, location: 'mount', focus: false, state: { version: descriptor.stateVersion, value: {} } }) + result = await controller.open({ scheme: 'extension', id: placement.id, data: { extensionId: event.extensionId, viewType: placement.view, resource: null, placementId: placement.id } }, descriptor.title, { signal, placement: placement.id, mountInstanceId: instanceId, interactionId: event.interactionId, viewType: placement.view, location: 'mount', focus: false, state: { version: descriptor.stateVersion, value: {} } }) } else if (existing && (await controller.close(existing.id, signal)).committed) { - viewId = existing.id + result = existing.id } await persistence.flush() } @@ -251,8 +251,8 @@ export function useExtensionContributions(options: { controller: WorkbenchContro const descriptor = installed.value.find(item => item.manifest.id === event.extensionId)?.manifest.contributes.views.find(view => view.id === event.viewType) if (!descriptor) return - viewId = await controller.open({ scheme: 'extension', id: `${event.extensionId}:${event.viewType}:${event.resource?.id ?? crypto.randomUUID()}`, data: { extensionId: event.extensionId, viewType: event.viewType, resource: event.resource } }, event.resource?.name ?? descriptor.title, { signal, viewType: event.viewType, duplicate: true, state: { version: event.stateVersion, value: event.state } }) - if (viewId) + result = await controller.open({ scheme: 'extension', id: `${event.extensionId}:${event.viewType}:${event.resource?.id ?? crypto.randomUUID()}`, data: { extensionId: event.extensionId, viewType: event.viewType, resource: event.resource } }, event.resource?.name ?? descriptor.title, { signal, viewType: event.viewType, duplicate: true, state: { version: event.stateVersion, value: event.state } }) + if (result) await persistence.flush() } else { @@ -272,16 +272,16 @@ export function useExtensionContributions(options: { controller: WorkbenchContro controller.updateView(view.id, { state: { ...view.state, version: event.stateVersion, value: event.state } }) } await persistence.flush() - viewId = view.id + result = view.id } } catch { - viewId = null + result = null } finally { requests.delete(event.requestId) if (!signal.aborted) - api.replyWorkbench(event.requestId, viewId) + api.replyWorkbench(event.requestId, result) } } onScopeDispose(() => { diff --git a/apps/buddy/src/app/workbench/useExtensionReferences.ts b/apps/buddy/src/app/workbench/useExtensionReferences.ts index 841a64640..c34b453c4 100644 --- a/apps/buddy/src/app/workbench/useExtensionReferences.ts +++ b/apps/buddy/src/app/workbench/useExtensionReferences.ts @@ -1,4 +1,5 @@ import type { ExtensionApi, ExtensionStatus, ExtensionWorkbenchEvent } from '@buddy-shared/extensions/extensionApi' +import type { ExtensionReferenceCapture, ExtensionReferenceMaterial } from '@buddy-shared/extensions/extensionReferences' import type { Ref } from 'vue' import type { TaskWorkspacePool } from './TaskWorkspacePool' import type { ExtensionViews } from '@/modules/extensions' @@ -29,8 +30,19 @@ export function useExtensionReferences(options: { }) { const { references, controller, views, api } = options const pending = new Set() + const captures = new Map + material: ExtensionReferenceMaterial + current: () => boolean + dispose: () => void + }>() const lifetime = new AbortController() - onScopeDispose(() => lifetime.abort()) + onScopeDispose(() => { + lifetime.abort() + for (const capture of captures.values()) capture.dispose() + captures.clear() + }) onScopeDispose(references.registerProvider('extensions', { captureSource(viewId) { const surface = views.surfaces.get(viewId) @@ -141,26 +153,57 @@ export function useExtensionReferences(options: { } return { - async receive(event: Extract, signal: AbortSignal): Promise { + async receive(event: Extract, signal: AbortSignal): Promise { if (pending.has(event.viewId)) - return false - const current = () => { + return event.kind === 'reference-capture' ? null : false + const sourceCurrent = () => { const surface = views.surfaces.get(event.viewId) - return !signal.aborted && !lifetime.signal.aborted && !!surface?.ready && surface.visible && surface.eligible && !surface.error + return !lifetime.signal.aborted && !!surface?.ready && surface.visible && surface.eligible && !surface.error && surface.session?.generation === event.generation && surface.session.token === event.token } + const current = () => !signal.aborted && sourceCurrent() + const previous = captures.get(event.viewId) + if (event.kind === 'reference-release') { + if (previous?.id !== event.id || !previous.current() || !current()) + return false + previous.dispose() + return true + } if (!current()) + return event.kind === 'reference-capture' ? null : false + const captured = event.kind === 'reference-commit' ? previous : undefined + if (event.kind === 'reference-commit' && (!captured || captured.id !== event.id || !captured.current())) return false - const scope = references.captureScope(event.viewId) + const scope = captured?.scope ?? references.captureScope(event.viewId) if (!scope) - return false + return event.kind === 'reference-capture' ? null : false + const material = 'material' in event ? event.material : captured!.material + if (event.kind === 'reference-capture') { + previous?.dispose() + const id = crypto.randomUUID() + const valid = () => sourceCurrent() && references.captureSource(scope.viewId)?.identity === scope.sourceIdentity + let stop = () => {} + const dispose = () => { + stop() + if (captures.get(event.viewId)?.id === id) + captures.delete(event.viewId) + } + stop = watch(valid, valid => !valid && dispose(), { flush: 'sync' }) + captures.set(event.viewId, { id, scope, material, current: valid, dispose }) + return { + id, + targets: scope.targets.slice(0, 64).map(target => ({ id: target.id, label: target.label.slice(0, 512) })), + defaultId: scope.targets.length === 1 ? scope.targets[0]!.id : null, + clipboard: material.kind === 'quote' && !!options.writeClipboard, + } + } + captured?.dispose() pending.add(event.viewId) const abort = new AbortController() const stop = watch(current, valid => !valid && abort.abort(), { flush: 'sync' }) try { - const material = event.material const title = material.kind === 'quote' ? material.quote.source.title : material.kind === 'file' ? material.name : views.surfaces.get(event.viewId)?.input.resource?.name ?? '' - const choice = await options.chooseTarget(scope, title, AbortSignal.any([signal, lifetime.signal, abort.signal]), material.kind === 'quote' && !!options.writeClipboard) + const choice = event.kind === 'reference-commit' ? event.choice : await options.chooseTarget(scope, title, AbortSignal.any([signal, lifetime.signal, abort.signal]), material.kind === 'quote' && !!options.writeClipboard) if (!choice || !current()) return false if (choice.kind === 'clipboard') { diff --git a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionFrameHost.vue b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionFrameHost.vue index 6247bc8b5..8a4f162ca 100644 --- a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionFrameHost.vue +++ b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionFrameHost.vue @@ -195,7 +195,7 @@ async function receive(event: MessageEvent) { try { if (data.method === 'clipboard.read' || data.method === 'clipboard.write') assertClipboardFocus(frame) - if (['resources.pickFiles', 'resources.pickDirectory', 'resources.beginSave', 'references.add'].includes(data.method) && (!frame.visible || document.visibilityState !== 'visible' || !document.hasFocus())) + if (['resources.pickFiles', 'resources.pickDirectory', 'resources.beginSave', 'references.add', 'references.capture', 'references.commit'].includes(data.method) && (!frame.visible || document.visibilityState !== 'visible' || !document.hasFocus())) throw new Error('EXTENSION_RESOURCE_PICKER_UNAVAILABLE') if (data.method === 'events.snapshot') { projections.refresh(frame)