-
diff --git a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionUiSettings.vue b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionUiSettings.vue
deleted file mode 100644
index 965e33fb..00000000
--- a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionUiSettings.vue
+++ /dev/null
@@ -1,55 +0,0 @@
-
-
-
-
- {{ language === 'en-US' ? 'Interface appearance' : '界面呈现' }}
-
-
-
{{ entry.title[language === 'en-US' ? 'en-US' : 'zh-CN'] }}
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/buddy/src/modules/extensions/widgets/__tests__/DesktopExtensionControl.spec.ts b/apps/buddy/src/modules/extensions/widgets/__tests__/DesktopExtensionControl.spec.ts
index 071c9cab..e200cb9d 100644
--- a/apps/buddy/src/modules/extensions/widgets/__tests__/DesktopExtensionControl.spec.ts
+++ b/apps/buddy/src/modules/extensions/widgets/__tests__/DesktopExtensionControl.spec.ts
@@ -14,7 +14,6 @@ import { WorkbenchController } from '@/workbench/services/WorkbenchController'
import { useProvideExtensionContext } from '../../extensionContext'
import { useExtensionUiContributions } from '../../state/useExtensionUiContributions'
import DesktopExtensionControl from '../DesktopExtensionControl.vue'
-import DesktopExtensionUiSettings from '../DesktopExtensionUiSettings.vue'
import { useExtensionViews } from '../useExtensionViews'
const disposables: (() => void)[] = []
@@ -49,7 +48,7 @@ async function setup() {
const api = {
openView: async (input: ExtensionViewInput) => {
opened.push(input)
- return { id: input.viewId, extensionId: input.extensionId, generation: installed.value[0]!.generation!, token: crypto.randomUUID(), url: 'about:blank' }
+ return { id: input.viewId, extensionId: input.extensionId, generation: installed.value.find(item => item.manifest.id === input.extensionId)!.generation!, token: crypto.randomUUID(), url: 'about:blank' }
},
closeView: async () => {},
viewRequest: async () => null,
@@ -65,7 +64,6 @@ async function setup() {
views = useExtensionViews(api, installed, context)
useProvideExtensionContext({ endInteraction: () => {}, views, workbench: shallowRef({ values: context.value, pages: [] }), state: { installed, api } as ExtensionContext['state'], anchors: new SemanticAnchorRegistry(), ui: useExtensionUiContributions(installed, controller.configuration), language: shallowRef('en-US'), isDark: shallowRef(false), focusView: () => {}, authoring: { author: shallowRef(''), save: async () => true }, startCreation: async () => {} })
return () => h('div', [
- h(DesktopExtensionUiSettings, { language: 'en-US' }),
mounted.value
? h(DesktopExtensionControl, {
target: 'model.reasoning',
@@ -89,17 +87,10 @@ async function setup() {
surface().ready = true
await nextTick()
}
- const choose = async (id: string) => {
- const select = element.querySelector('select')!
- select.value = id
- select.dispatchEvent(new Event('change', { bubbles: true }))
- await nextTick()
- await nextTick()
- }
await nextTick()
await nextTick()
await ready()
- return { context, views, surface, ready, choose, value, mounted, opened, installed, status, visibility, element }
+ return { context, views, surface, ready, value, mounted, opened, installed, status, visibility, element }
}
it('rejects current proposals while the document is hidden even when the control has layout', async () => {
@@ -136,7 +127,7 @@ it('falls back outside its page scope and invalidates proposals made before leav
expect(f.value.value).toBe('high')
})
-it('keeps the native fallback across popover remounts until the user explicitly reselects the control', async () => {
+it('keeps the native fallback across popover remounts until explicitly retried', async () => {
const fixture = await setup()
fixture.views.fail(fixture.surface(), 'EXTENSION_VIEW_TIMEOUT')
fixture.mounted.value = false
@@ -150,8 +141,9 @@ it('keeps the native fallback across popover remounts until the user explicitly
expect(fixture.surface().session).toBeNull()
expect(fixture.opened).toHaveLength(1)
expect(fixture.element.querySelector('[data-native-control]')?.textContent).toBe('low')
- await fixture.choose('')
- await fixture.choose('tests.control.reasoning')
+ fixture.views.retryControl('tests.control.reasoning')
+ await nextTick()
+ await nextTick()
await fixture.ready()
expect(fixture.surface().session).not.toBeNull()
expect(fixture.surface().error).toBe('')
@@ -174,3 +166,44 @@ it('recovers a failed control after the host restarts while the popover is close
expect(fixture.surface().error).toBe('')
expect(fixture.element.querySelector('[data-native-control]')).toBeNull()
})
+
+it('arbitrates competing controls and invalidates a proposal after a provider yields its surface', async () => {
+ const f = await setup()
+ const primary = f.surface()
+ const primarySession = primary.session!
+ const oldProposal = { revision: primary.control!.snapshot().revision, value: 'high' }
+ const secondary: ExtensionStatus = {
+ ...f.status,
+ generation: crypto.randomUUID(),
+ manifest: extensionManifestSchema.parse({
+ ...f.status.manifest,
+ id: 'tests.secondary',
+ contributes: {
+ views: [{ id: 'tests.secondary.slider', title: 'Other slider', entry: 'slider.js', resource: 'none' }],
+ placements: [{ id: 'tests.secondary.control', view: 'tests.secondary.slider', kind: 'control', target: 'model.reasoning' }],
+ },
+ }),
+ }
+ f.installed.value = [secondary, f.status]
+ await nextTick()
+ await nextTick()
+ const other = [...f.views.surfaces.values()].find(surface => surface.input.extensionId === secondary.manifest.id)!
+ other.ready = true
+ await nextTick()
+ expect(primary.visible).toBe(true)
+ expect(other.visible).toBe(false)
+ const session = other.session!
+ expect(f.views.proposeControl(session.id, session.generation, session.token, oldProposal)).toBe(false)
+ expect(f.views.setActive(primarySession.id, primarySession.generation, primarySession.token, false)).toBe(true)
+ await nextTick()
+ expect(primary.visible).toBe(false)
+ expect(other.visible).toBe(true)
+ expect(f.element.querySelector('[data-native-control]')).toBeNull()
+ expect(f.views.proposeControl(session.id, session.generation, session.token, oldProposal)).toBe(false)
+ expect(f.views.setActive(primarySession.id, primarySession.generation, primarySession.token, true)).toBe(true)
+ await nextTick()
+ expect(f.views.proposeControl(primarySession.id, primarySession.generation, primarySession.token, oldProposal)).toBe(false)
+ const current = { revision: primary.control!.snapshot().revision, value: 'high' }
+ expect(f.views.proposeControl(primarySession.id, primarySession.generation, primarySession.token, current)).toBe(true)
+ expect(f.value.value).toBe('high')
+})
diff --git a/apps/buddy/src/modules/extensions/widgets/__tests__/DesktopExtensionSlot.spec.ts b/apps/buddy/src/modules/extensions/widgets/__tests__/DesktopExtensionSlot.spec.ts
index 02dfae48..8f403500 100644
--- a/apps/buddy/src/modules/extensions/widgets/__tests__/DesktopExtensionSlot.spec.ts
+++ b/apps/buddy/src/modules/extensions/widgets/__tests__/DesktopExtensionSlot.spec.ts
@@ -14,13 +14,12 @@ import { WorkbenchController } from '@/workbench/services/WorkbenchController'
import { useProvideExtensionContext } from '../../extensionContext'
import { useExtensionUiContributions } from '../../state/useExtensionUiContributions'
import DesktopExtensionSlot from '../DesktopExtensionSlot.vue'
-import DesktopExtensionUiSettings from '../DesktopExtensionUiSettings.vue'
import { useExtensionViews } from '../useExtensionViews'
const cleanups: (() => void)[] = []
afterEach(() => cleanups.splice(0).forEach(cleanup => cleanup()))
-it('keeps the built-in footer until a selected slot view is ready and restores it when unavailable', async () => {
+it('keeps the built-in footer until a declared slot view is ready and restores it when unavailable', async () => {
const manifest = extensionManifestSchema.parse({
schemaVersion: 1,
id: 'tests.footer',
@@ -55,7 +54,6 @@ it('keeps the built-in footer until a selected slot view is ready and restores i
views = useExtensionViews(api, installed, context)
useProvideExtensionContext({ endInteraction: () => {}, workbench: shallowRef({ values: context.value, pages: [] }), anchors: new SemanticAnchorRegistry(), ui: useExtensionUiContributions(installed, controller.configuration), state: { installed, api } as ExtensionContext['state'], views, language: shallowRef('en-US'), isDark: shallowRef(false), focusView: () => {}, authoring: { author: shallowRef(''), save: async () => true }, startCreation: async () => {} })
return () => h('div', [
- h(DesktopExtensionUiSettings, { language: 'en-US' }),
...[0, 1].map(index => h(DesktopExtensionSlot, { target: 'composer.footer' }, { default: () => h('p', `Verify results ${index}`) })),
])
},
@@ -67,10 +65,6 @@ it('keeps the built-in footer until a selected slot view is ready and restores i
})
expect(element.textContent).toContain('Verify results 0')
- expect(opened).toHaveLength(0)
- const select = element.querySelector('select')!
- select.value = 'tests.footer.placement'
- select.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
await nextTick()
expect(opened).toHaveLength(2)
@@ -81,9 +75,22 @@ it('keeps the built-in footer until a selected slot view is ready and restores i
await nextTick()
expect(element.textContent).not.toContain('Verify results')
+ const first = [...views.surfaces.values()][0]!
+ const session = first.session!
+ expect(views.setActive(session.id, session.generation, crypto.randomUUID(), false)).toBe(false)
+ expect(views.setActive(session.id, session.generation, session.token, false)).toBe(true)
+ await nextTick()
+ expect(element.textContent).toContain('Verify results 0')
+ expect(element.textContent).not.toContain('Verify results 1')
+ expect(first.session).toEqual(session)
+ expect(views.setActive(session.id, session.generation, session.token, true)).toBe(true)
+ await nextTick()
+ expect(element.textContent).not.toContain('Verify results')
+ expect(opened).toHaveLength(2)
+
installed.value = [{ ...status, enabled: false, state: 'disabled' }]
await nextTick()
expect(element.textContent).toContain('Verify results 0')
expect(element.textContent).toContain('Verify results 1')
- expect(controller.configuration.get('workbench.slots.composer.footer')).toBe('tests.footer.placement')
+ expect(controller.configuration.get('workbench.slots.composer.footer')).toBe('')
})
diff --git a/apps/buddy/src/modules/extensions/widgets/useExtensionUiSurfaces.ts b/apps/buddy/src/modules/extensions/widgets/useExtensionUiSurfaces.ts
new file mode 100644
index 00000000..93e201a0
--- /dev/null
+++ b/apps/buddy/src/modules/extensions/widgets/useExtensionUiSurfaces.ts
@@ -0,0 +1,30 @@
+import type { ExtensionViewInput } from '@buddy-shared/extensions/extensionApi'
+import type { WorkbenchUiSelectionTarget } from '@buddy-shared/workbench/workbenchUi'
+import { workbenchUiTargetCatalog } from '@buddy-shared/workbench/workbenchUi'
+import { computed, shallowRef, watch } from 'vue'
+import { useWorkbenchUiScope } from '@/shared/ui/contributions/workbenchUiContext'
+import { useExtensionContext } from '../extensionContext'
+
+export function useExtensionUiSurfaces(target: () => WorkbenchUiSelectionTarget) {
+ const { ui, views } = useExtensionContext()
+ const scope = useWorkbenchUiScope()
+ const providers = computed(() => ui.providers(target()))
+ const inputs = shallowRef(new Map
())
+ watch(() => providers.value.map(({ plugin, placement }) => `${placement.id}:${plugin.revision}:${scope?.instanceId() ?? ''}`), (keys) => {
+ inputs.value = new Map(keys.map((key, index) => {
+ const { plugin, placement } = providers.value[index]!
+ return [key, inputs.value.get(key) ?? { viewId: crypto.randomUUID(), extensionId: plugin.manifest.id, viewType: placement.view, placementId: placement.id, instanceId: scope?.instanceId(), resource: null, state: null, stateVersion: 0 }]
+ }))
+ }, { immediate: true, flush: 'sync' })
+ const surfaces = computed(() => [...inputs.value.values()].map((input) => {
+ const entry = views.surfaces.get(input.viewId)
+ const provider = providers.value.find(provider => provider.placement.id === input.placementId)!
+ return { input, entry, height: provider.placement.height, available: provider.enabled && !!entry?.active && entry.ready && entry.eligible && !entry.error && !['failed', 'blocked'].includes(provider.plugin.state) }
+ }))
+ const selected = computed(() => {
+ const current = target()
+ const multiple = workbenchUiTargetCatalog.find(item => item.kind === current.kind && item.target === current.target)!.selection === 'multiple'
+ return surfaces.value.filter(surface => surface.available).slice(0, multiple ? 8 : 1)
+ })
+ return { surfaces, selected }
+}
diff --git a/apps/buddy/src/modules/extensions/widgets/useExtensionViews.ts b/apps/buddy/src/modules/extensions/widgets/useExtensionViews.ts
index dcccce77..7e424169 100644
--- a/apps/buddy/src/modules/extensions/widgets/useExtensionViews.ts
+++ b/apps/buddy/src/modules/extensions/widgets/useExtensionViews.ts
@@ -18,6 +18,7 @@ export interface ExtensionSurface {
input: ExtensionViewInput
element: HTMLElement
visible: boolean
+ active: boolean
eligible: boolean
session: ExtensionViewSession | null
opening: boolean
@@ -59,6 +60,7 @@ export function useExtensionViews(api: ExtensionApi, installed: Readonly[({ input, element, visible, eligible: true, session: null, opening: false, ready: false, mount: binding?.mount ?? null, anchor: binding?.anchor ?? null, control: binding?.control ?? null, error: '', regions: [], interactionMode: null })
+ const surface = shallowReactive({ input, element, visible, active: true, eligible: true, session: null, opening: false, ready: false, mount: binding?.mount ?? null, anchor: binding?.anchor ?? null, control: binding?.control ?? null, error: '', regions: [], interactionMode: null })
lifecycles.set(surface, { revision: null, generation: null, request: 0 })
surfaces.set(input.viewId, surface)
}
@@ -186,7 +188,16 @@ export function useExtensionViews(api: ExtensionApi, installed: Readonly][ item.manifest.id === surface?.input.extensionId)?.manifest.contributes.placements.find(item => item.id === surface?.input.placementId)
+ if (!surface || surface.session?.generation !== generation || surface.session.token !== token || (placement?.kind !== 'slot' && placement?.kind !== 'control'))
+ return false
+ surface.active = active
+ layout()
+ return true
}
onScopeDispose(() => {
for (const id of surfaces.keys()) hide(id)
@@ -205,7 +216,7 @@ export function useExtensionViews(api: ExtensionApi, installed: Readonly][ messageListeners.delete(listener)
}, broadcast: (extensionId: string, generation: string, message: JsonValue) => {
for (const listener of messageListeners) listener(extensionId, generation, message)
- }, surfaces, show, hide, fail, retryControl, proposeControl, layout: () => layout(), setLayout: (callback: () => void) => {
+ }, surfaces, show, hide, fail, retryControl, proposeControl, setActive, layout: () => layout(), setLayout: (callback: () => void) => {
layout = callback
} }
}
]