mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
feat(stage-tamagotchi): widget with pin (#1993)
This commit is contained in:
@@ -279,6 +279,7 @@ function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = {
|
||||
id: payload.id ?? Math.random().toString(36).slice(2, 10),
|
||||
componentName: payload.componentName,
|
||||
componentProps: payload.componentProps ?? {},
|
||||
alwaysOnTop: payload.alwaysOnTop ?? false,
|
||||
size: payload.size ?? 'm',
|
||||
windowSize: payload.windowSize,
|
||||
ttlMs: payload.ttlMs ?? 0,
|
||||
@@ -296,6 +297,7 @@ function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = {
|
||||
widgetSnapshots.set(payload.id, {
|
||||
...existing,
|
||||
componentProps: payload.componentProps ?? existing.componentProps,
|
||||
alwaysOnTop: payload.alwaysOnTop ?? existing.alwaysOnTop,
|
||||
size: payload.size ?? existing.size,
|
||||
windowSize: payload.windowSize ?? existing.windowSize,
|
||||
ttlMs: payload.ttlMs ?? existing.ttlMs,
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('widget invoke validation', () => {
|
||||
id: ' widget-1 ',
|
||||
componentName: ' weather ',
|
||||
componentProps: { city: 'Tokyo' },
|
||||
alwaysOnTop: true,
|
||||
ttlMs: 2500.9,
|
||||
windowSize: {
|
||||
width: 620.8,
|
||||
@@ -24,6 +25,7 @@ describe('widget invoke validation', () => {
|
||||
id: 'widget-1',
|
||||
componentName: 'weather',
|
||||
componentProps: { city: 'Tokyo' },
|
||||
alwaysOnTop: true,
|
||||
ttlMs: 2500,
|
||||
windowSize: {
|
||||
width: 620,
|
||||
@@ -52,6 +54,11 @@ describe('widget invoke validation', () => {
|
||||
componentName: 'weather',
|
||||
windowSize: { width: 0, height: 320 },
|
||||
} as any)).toThrow('windowSize must contain a positive finite width and height.')
|
||||
|
||||
expect(() => validateWidgetsAddPayload({
|
||||
componentName: 'weather',
|
||||
alwaysOnTop: 'yes' as any,
|
||||
})).toThrow('alwaysOnTop must be a boolean when provided.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,10 +67,12 @@ describe('widget invoke validation', () => {
|
||||
expect(validateWidgetsUpdatePayload({
|
||||
id: ' widget-1 ',
|
||||
componentProps: { city: 'Taipei' },
|
||||
alwaysOnTop: false,
|
||||
ttlMs: 1500.4,
|
||||
})).toEqual({
|
||||
id: 'widget-1',
|
||||
componentProps: { city: 'Taipei' },
|
||||
alwaysOnTop: false,
|
||||
ttlMs: 1500,
|
||||
windowSize: undefined,
|
||||
})
|
||||
@@ -83,6 +92,11 @@ describe('widget invoke validation', () => {
|
||||
id: 'widget-1',
|
||||
windowSize: { width: Number.NaN, height: 400 },
|
||||
} as any)).toThrow('windowSize must contain a positive finite width and height.')
|
||||
|
||||
expect(() => validateWidgetsUpdatePayload({
|
||||
id: 'widget-1',
|
||||
alwaysOnTop: 'yes' as any,
|
||||
})).toThrow('alwaysOnTop must be a boolean when provided.')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -35,6 +35,16 @@ function normalizeComponentProps(componentProps?: Record<string, unknown>): Reco
|
||||
return componentProps
|
||||
}
|
||||
|
||||
function normalizeOptionalBoolean(value: boolean | undefined, fieldName: string): boolean | undefined {
|
||||
if (value === undefined)
|
||||
return undefined
|
||||
|
||||
if (typeof value !== 'boolean')
|
||||
throw new Error(`${fieldName} must be a boolean when provided.`)
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and normalizes widget spawn payloads at the Electron invoke boundary.
|
||||
*
|
||||
@@ -44,6 +54,7 @@ function normalizeComponentProps(componentProps?: Record<string, unknown>): Reco
|
||||
* Expects:
|
||||
* - `componentName` is a non-empty string
|
||||
* - `componentProps`, when provided, is a plain object
|
||||
* - `alwaysOnTop`, when provided, is a boolean
|
||||
* - `ttlMs`, when provided, is a non-negative finite number
|
||||
*
|
||||
* Returns:
|
||||
@@ -69,6 +80,7 @@ export function validateWidgetsAddPayload(payload?: WidgetsAddPayload): WidgetsA
|
||||
id: normalizeWidgetId(payload.id),
|
||||
componentName,
|
||||
componentProps: normalizeComponentProps(payload.componentProps),
|
||||
alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'),
|
||||
ttlMs: normalizeTtlMs(payload.ttlMs),
|
||||
windowSize: normalizedWindowSize,
|
||||
}
|
||||
@@ -83,6 +95,7 @@ export function validateWidgetsAddPayload(payload?: WidgetsAddPayload): WidgetsA
|
||||
* Expects:
|
||||
* - `id` is a non-empty string after trimming
|
||||
* - `componentProps`, when provided, is a plain object
|
||||
* - `alwaysOnTop`, when provided, is a boolean
|
||||
*
|
||||
* Returns:
|
||||
* - A normalized payload safe to pass into the widgets manager
|
||||
@@ -108,6 +121,7 @@ export function validateWidgetsUpdatePayload(payload?: WidgetsUpdatePayload): Wi
|
||||
componentProps: payload.componentProps === undefined
|
||||
? undefined
|
||||
: normalizeComponentProps(payload.componentProps),
|
||||
alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'),
|
||||
ttlMs: payload.ttlMs === undefined
|
||||
? undefined
|
||||
: normalizeTtlMs(payload.ttlMs),
|
||||
|
||||
@@ -204,8 +204,6 @@ function createWidgetsWindow() {
|
||||
...spotlightLikeWindowConfig(),
|
||||
})
|
||||
|
||||
// Keep on top like caption/main overlays
|
||||
window.setAlwaysOnTop(true, 'screen-saver', 1)
|
||||
window.setFullScreenable(false)
|
||||
window.setVisibleOnAllWorkspaces(true)
|
||||
if (isMacOS)
|
||||
@@ -220,6 +218,15 @@ function createWidgetsWindow() {
|
||||
return window
|
||||
}
|
||||
|
||||
function applyAlwaysOnTop(window: BrowserWindow, enabled: boolean) {
|
||||
if (enabled) {
|
||||
window.setAlwaysOnTop(true, 'screen-saver', 1)
|
||||
return
|
||||
}
|
||||
|
||||
window.setAlwaysOnTop(false)
|
||||
}
|
||||
|
||||
interface WidgetRecord extends WidgetSnapshot {
|
||||
timer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
@@ -462,11 +469,12 @@ export function setupWidgetsWindowManager(params: {
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function showWindowWithRoute(route: string, context?: WidgetWindowContext, snapshot?: Pick<WidgetSnapshot, 'windowSize'>) {
|
||||
async function showWindowWithRoute(route: string, context?: WidgetWindowContext, snapshot?: Pick<WidgetSnapshot, 'alwaysOnTop' | 'windowSize'>) {
|
||||
pendingRoute = route
|
||||
const window = await getWindowFromContext(context)
|
||||
pendingRoute = undefined
|
||||
applyWindowLayout(window, snapshot)
|
||||
applyAlwaysOnTop(window, snapshot?.alwaysOnTop ?? false)
|
||||
if (currentRoute !== route)
|
||||
await loadWithRoute(window, route)
|
||||
window.show()
|
||||
@@ -529,6 +537,7 @@ export function setupWidgetsWindowManager(params: {
|
||||
id,
|
||||
componentName: payload.componentName,
|
||||
componentProps: payload.componentProps ?? {},
|
||||
alwaysOnTop: payload.alwaysOnTop ?? false,
|
||||
size: payload.size ?? 'm',
|
||||
windowSize: resolveWindowSizeFromPayload(payload),
|
||||
ttlMs: payload.ttlMs ?? 0,
|
||||
@@ -564,6 +573,7 @@ export function setupWidgetsWindowManager(params: {
|
||||
const nextSnapshot: WidgetSnapshot = {
|
||||
...toSnapshot(existing),
|
||||
componentProps: payload.componentProps ?? existing.componentProps,
|
||||
alwaysOnTop: payload.alwaysOnTop ?? existing.alwaysOnTop,
|
||||
size: payload.size ?? existing.size,
|
||||
windowSize: normalizeWidgetWindowSize(payload.windowSize) ?? existing.windowSize,
|
||||
ttlMs: payload.ttlMs ?? existing.ttlMs,
|
||||
@@ -573,12 +583,15 @@ export function setupWidgetsWindowManager(params: {
|
||||
|
||||
const context = windowContexts.get(payload.id)
|
||||
const window = context?.window
|
||||
if (window && !window.isDestroyed())
|
||||
if (window && !window.isDestroyed()) {
|
||||
applyWindowLayout(window, nextSnapshot)
|
||||
applyAlwaysOnTop(window, nextSnapshot.alwaysOnTop)
|
||||
}
|
||||
|
||||
eventaContext?.emit(widgetsUpdateEvent, {
|
||||
id: nextSnapshot.id,
|
||||
componentProps: nextSnapshot.componentProps,
|
||||
alwaysOnTop: nextSnapshot.alwaysOnTop,
|
||||
size: nextSnapshot.size,
|
||||
windowSize: nextSnapshot.windowSize,
|
||||
ttlMs: nextSnapshot.ttlMs,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { Button, FieldCombobox, FieldInput, FieldTextArea } from '@proj-airi/ui'
|
||||
import { Button, FieldCheckbox, FieldCombobox, FieldInput, FieldTextArea } from '@proj-airi/ui'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import { widgetsAdd, widgetsClear, widgetsOpenWindow, widgetsPrepareWindow, widgetsRemove, widgetsUpdate } from '../../../shared/eventa'
|
||||
@@ -13,6 +13,7 @@ interface FormState {
|
||||
sizePreset: SizePreset
|
||||
customCols: string
|
||||
customRows: string
|
||||
alwaysOnTop: boolean
|
||||
ttlSeconds: string
|
||||
componentProps: string
|
||||
}
|
||||
@@ -68,6 +69,7 @@ const form = reactive<FormState>({
|
||||
sizePreset: 'm',
|
||||
customCols: '2',
|
||||
customRows: '1',
|
||||
alwaysOnTop: false,
|
||||
ttlSeconds: '',
|
||||
componentProps: JSON.stringify(defaultWeatherProps, null, 2),
|
||||
})
|
||||
@@ -146,7 +148,14 @@ async function handleAdd() {
|
||||
const ttlMs = parseTtl()
|
||||
const desiredId = form.id || undefined
|
||||
const preparedId = await prepareAndOpenWindow(desiredId)
|
||||
const createdId = await addWidget({ id: preparedId, componentName: form.componentName.trim(), componentProps, size: resolvedSize.value, ttlMs })
|
||||
const createdId = await addWidget({
|
||||
id: preparedId,
|
||||
componentName: form.componentName.trim(),
|
||||
componentProps,
|
||||
alwaysOnTop: form.alwaysOnTop,
|
||||
size: resolvedSize.value,
|
||||
ttlMs,
|
||||
})
|
||||
|
||||
const resolvedId = createdId || preparedId
|
||||
if (!form.id && resolvedId)
|
||||
@@ -176,6 +185,7 @@ async function handleUpdate() {
|
||||
await updateWidget({
|
||||
id: form.id,
|
||||
componentProps,
|
||||
alwaysOnTop: form.alwaysOnTop,
|
||||
})
|
||||
lastAction.value = `Updated widget (${form.id}).`
|
||||
}
|
||||
@@ -230,6 +240,7 @@ function applyWeatherPreset() {
|
||||
form.customCols = '2'
|
||||
form.customRows = '1'
|
||||
form.componentProps = JSON.stringify(defaultWeatherProps, null, 2)
|
||||
form.alwaysOnTop = false
|
||||
form.ttlSeconds = ''
|
||||
resetFeedback()
|
||||
}
|
||||
@@ -240,6 +251,7 @@ function applyMapPreset() {
|
||||
form.customCols = '3'
|
||||
form.customRows = '2'
|
||||
form.componentProps = JSON.stringify(defaultMapProps, null, 2)
|
||||
form.alwaysOnTop = false
|
||||
form.ttlSeconds = ''
|
||||
resetFeedback()
|
||||
}
|
||||
@@ -250,6 +262,7 @@ function applyExtensionUiPreset() {
|
||||
form.customCols = '4'
|
||||
form.customRows = '3'
|
||||
form.componentProps = JSON.stringify({}, null, 2)
|
||||
form.alwaysOnTop = false
|
||||
form.ttlSeconds = ''
|
||||
resetFeedback()
|
||||
}
|
||||
@@ -375,6 +388,12 @@ function applyExtensionUiPreset() {
|
||||
:required="false"
|
||||
/>
|
||||
|
||||
<FieldCheckbox
|
||||
v-model="form.alwaysOnTop"
|
||||
label="Pin on top"
|
||||
description="Keep this widget window above other windows after spawning or updating."
|
||||
/>
|
||||
|
||||
<FieldTextArea
|
||||
v-model="form.componentProps"
|
||||
label="Component Props (JSON)"
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import type { WidgetSnapshot, WidgetWindowSize } from '../../shared/eventa'
|
||||
|
||||
import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { computed, defineAsyncComponent, defineComponent, h, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, defineAsyncComponent, defineComponent, h, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { widgetsClearEvent, widgetsFetch, widgetsRemove, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdateEvent } from '../../shared/eventa'
|
||||
import { widgetsClearEvent, widgetsFetch, widgetsRemove, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdate, widgetsUpdateEvent } from '../../shared/eventa'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -16,6 +16,7 @@ interface WidgetItem {
|
||||
id: string
|
||||
componentName: string
|
||||
componentProps: Record<string, any>
|
||||
alwaysOnTop: boolean
|
||||
size: SizePreset
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlMs: number
|
||||
@@ -38,6 +39,8 @@ const loading = ref(false)
|
||||
const context = useElectronEventaContext()
|
||||
const removeWidgetInvoke = useElectronEventaInvoke(widgetsRemove)
|
||||
const fetchWidget = useElectronEventaInvoke(widgetsFetch)
|
||||
const updateWidgetInvoke = useElectronEventaInvoke(widgetsUpdate)
|
||||
const pinUpdating = shallowRef(false)
|
||||
|
||||
let ttlTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
@@ -64,6 +67,7 @@ function applySnapshot(snapshot: WidgetSnapshot) {
|
||||
id: snapshot.id,
|
||||
componentName: snapshot.componentName,
|
||||
componentProps: snapshot.componentProps ?? {},
|
||||
alwaysOnTop: snapshot.alwaysOnTop ?? false,
|
||||
size: snapshot.size ?? 'm',
|
||||
windowSize: snapshot.windowSize,
|
||||
ttlMs: snapshot.ttlMs ?? 0,
|
||||
@@ -128,6 +132,7 @@ onMounted(() => {
|
||||
applySnapshot({
|
||||
...widget.value,
|
||||
componentProps: body.componentProps ?? widget.value.componentProps,
|
||||
alwaysOnTop: body.alwaysOnTop ?? widget.value.alwaysOnTop,
|
||||
size: body.size ?? widget.value.size,
|
||||
windowSize: body.windowSize ?? widget.value.windowSize,
|
||||
ttlMs: body.ttlMs ?? widget.value.ttlMs,
|
||||
@@ -203,21 +208,71 @@ function handleClose() {
|
||||
clearTtl()
|
||||
window.close()
|
||||
}
|
||||
|
||||
async function toggleAlwaysOnTop() {
|
||||
if (!widget.value || pinUpdating.value)
|
||||
return
|
||||
|
||||
const previous = widget.value.alwaysOnTop
|
||||
const next = !previous
|
||||
widget.value = {
|
||||
...widget.value,
|
||||
alwaysOnTop: next,
|
||||
}
|
||||
pinUpdating.value = true
|
||||
|
||||
try {
|
||||
await updateWidgetInvoke({ id: widget.value.id, alwaysOnTop: next })
|
||||
}
|
||||
catch (error) {
|
||||
widget.value = widget.value
|
||||
? {
|
||||
...widget.value,
|
||||
alwaysOnTop: previous,
|
||||
}
|
||||
: widget.value
|
||||
console.warn('Failed to update widget pin state', error)
|
||||
}
|
||||
finally {
|
||||
pinUpdating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['relative h-full w-full']">
|
||||
<button
|
||||
:class="[
|
||||
'absolute right-2 top-2 z-10 size-7 rounded-full text-xs text-white transition',
|
||||
'bg-black/40 hover:bg-black/60',
|
||||
]"
|
||||
:title="t('tamagotchi.stage.widgets.close')"
|
||||
:aria-label="t('tamagotchi.stage.widgets.close')"
|
||||
@click="handleClose"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div :class="['absolute right-2 top-2 z-10 flex items-center gap-1']">
|
||||
<button
|
||||
v-if="widget"
|
||||
:class="[
|
||||
'size-7 flex items-center justify-center rounded-full text-white transition',
|
||||
widget.alwaysOnTop ? 'bg-primary-500/70 hover:bg-primary-500/85' : 'bg-black/40 hover:bg-black/60',
|
||||
pinUpdating ? 'cursor-wait opacity-70' : '',
|
||||
]"
|
||||
:title="widget.alwaysOnTop ? t('tamagotchi.stage.controls-island.unpin-from-top') : t('tamagotchi.stage.controls-island.pin-on-top')"
|
||||
:aria-label="widget.alwaysOnTop ? t('tamagotchi.stage.controls-island.unpin-from-top') : t('tamagotchi.stage.controls-island.pin-on-top')"
|
||||
:disabled="pinUpdating"
|
||||
@click="toggleAlwaysOnTop"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'size-4',
|
||||
widget.alwaysOnTop ? 'i-solar:pin-bold' : 'i-solar:pin-linear opacity-80',
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'size-7 rounded-full text-xs text-white transition',
|
||||
'bg-black/40 hover:bg-black/60',
|
||||
]"
|
||||
:title="t('tamagotchi.stage.widgets.close')"
|
||||
:aria-label="t('tamagotchi.stage.widgets.close')"
|
||||
@click="handleClose"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!widgetId" :class="['h-full flex items-center justify-center p-6']">
|
||||
<div
|
||||
:class="[
|
||||
|
||||
@@ -363,6 +363,30 @@ describe('widgets tool helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards always-on-top preference when spawning a widget', async () => {
|
||||
const invokers = makeInvokers()
|
||||
vi.mocked(invokers.addWidget).mockResolvedValue('pinned-widget')
|
||||
|
||||
await executeWidgetAction({
|
||||
action: 'spawn',
|
||||
id: ' pinned-widget ',
|
||||
componentName: 'weather',
|
||||
componentProps: '{"city":"Tokyo"}',
|
||||
size: 'm',
|
||||
ttlSeconds: 0,
|
||||
alwaysOnTop: true,
|
||||
}, { invokers })
|
||||
|
||||
expect(invokers.addWidget).toHaveBeenCalledWith({
|
||||
id: 'pinned-widget',
|
||||
componentName: 'weather',
|
||||
componentProps: { city: 'Tokyo' },
|
||||
size: 'm',
|
||||
alwaysOnTop: true,
|
||||
ttlMs: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards custom window sizing when spawning a widget', async () => {
|
||||
const invokers = makeInvokers()
|
||||
vi.mocked(invokers.addWidget).mockResolvedValue('sized-widget')
|
||||
@@ -492,10 +516,11 @@ describe('widgets tool helpers', () => {
|
||||
componentName: '',
|
||||
componentProps: '{"foo":1}',
|
||||
size: 'm',
|
||||
alwaysOnTop: false,
|
||||
ttlSeconds: 0,
|
||||
}, { invokers })
|
||||
|
||||
expect(invokers.updateWidget).toHaveBeenCalledWith({ id: 'xyz', componentProps: { foo: 1 } })
|
||||
expect(invokers.updateWidget).toHaveBeenCalledWith({ id: 'xyz', componentProps: { foo: 1 }, alwaysOnTop: false })
|
||||
})
|
||||
|
||||
it('removes when id provided', async () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ type WidgetActionInput
|
||||
id: string
|
||||
componentName: string
|
||||
componentProps: string | Record<string, any>
|
||||
alwaysOnTop?: boolean
|
||||
size: SizePreset
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlSeconds: number
|
||||
@@ -30,6 +31,7 @@ type WidgetActionInput
|
||||
id: string
|
||||
componentProps: string | Record<string, any>
|
||||
componentName?: string
|
||||
alwaysOnTop?: boolean
|
||||
size?: SizePreset
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlSeconds?: number
|
||||
@@ -39,6 +41,7 @@ type WidgetActionInput
|
||||
id: string
|
||||
componentName?: string
|
||||
componentProps?: string | Record<string, any>
|
||||
alwaysOnTop?: boolean
|
||||
size?: SizePreset
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlSeconds?: number
|
||||
@@ -48,6 +51,7 @@ type WidgetActionInput
|
||||
id: string
|
||||
componentName?: string
|
||||
componentProps?: string | Record<string, any>
|
||||
alwaysOnTop?: boolean
|
||||
size?: SizePreset
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlSeconds?: number
|
||||
@@ -57,6 +61,7 @@ type WidgetActionInput
|
||||
id: string
|
||||
componentName?: string
|
||||
componentProps?: string | Record<string, any>
|
||||
alwaysOnTop?: boolean
|
||||
size?: SizePreset
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlSeconds?: number
|
||||
@@ -106,6 +111,7 @@ const widgetParams = z.object({
|
||||
id: z.string().describe('Widget id; required for update/remove, optional for spawn/open'),
|
||||
componentName: z.string().describe('Widget component to render, e.g. weather (required for spawn)'),
|
||||
componentProps: z.string().describe('Widget props as JSON string (e.g. {"city":"Tokyo"})'),
|
||||
alwaysOnTop: z.boolean().describe('Whether the widget window should stay above other windows. Defaults to false when omitted by internal callers.'),
|
||||
size: z.enum(['s', 'm', 'l']),
|
||||
windowSize: z.union([widgetWindowSizeParams, z.null()]).describe('Optional pixel window size and constraints, e.g. {"width":620,"height":760,"minWidth":480}'),
|
||||
ttlSeconds: z.number().int().nonnegative().describe('Auto-close timer in seconds (spawn only)'),
|
||||
@@ -257,7 +263,8 @@ export async function executeWidgetAction(input: WidgetActionInput, deps?: { inv
|
||||
componentName: input.componentName,
|
||||
componentProps: sanitizedComponentProps,
|
||||
size: input.size ?? 'm',
|
||||
windowSize,
|
||||
...(input.alwaysOnTop === undefined ? {} : { alwaysOnTop: input.alwaysOnTop }),
|
||||
...(windowSize === undefined ? {} : { windowSize }),
|
||||
ttlMs,
|
||||
})
|
||||
|
||||
@@ -273,7 +280,8 @@ export async function executeWidgetAction(input: WidgetActionInput, deps?: { inv
|
||||
await invokers.updateWidget({
|
||||
id: normalizedId,
|
||||
componentProps: sanitizedComponentProps,
|
||||
windowSize,
|
||||
...(input.alwaysOnTop === undefined ? {} : { alwaysOnTop: input.alwaysOnTop }),
|
||||
...(windowSize === undefined ? {} : { windowSize }),
|
||||
})
|
||||
|
||||
return `Updated widget (${normalizedId}).`
|
||||
|
||||
@@ -117,6 +117,7 @@ export interface WidgetsAddPayload {
|
||||
id?: string
|
||||
componentName: string
|
||||
componentProps?: Record<string, any>
|
||||
alwaysOnTop?: boolean
|
||||
// size presets or explicit spans; renderer decides mapping
|
||||
size?: WidgetGridSize
|
||||
windowSize?: WidgetWindowSize | Record<string, unknown>
|
||||
@@ -127,6 +128,7 @@ export interface WidgetsAddPayload {
|
||||
export interface WidgetsUpdatePayload {
|
||||
id: string
|
||||
componentProps?: Record<string, any>
|
||||
alwaysOnTop?: boolean
|
||||
size?: WidgetGridSize
|
||||
windowSize?: WidgetWindowSize | Record<string, unknown>
|
||||
ttlMs?: number
|
||||
@@ -136,6 +138,7 @@ export interface WidgetSnapshot {
|
||||
id: string
|
||||
componentName: string
|
||||
componentProps: Record<string, any>
|
||||
alwaysOnTop: boolean
|
||||
size: WidgetGridSize
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlMs: number
|
||||
|
||||
Reference in New Issue
Block a user