From 4113dc797613213bdd933b177d2b5efa418cc596 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 24 Oct 2026 12:13:49 +0800 Subject: [PATCH 1/2] fix(ui): preserve inert and tooltip refs across React versions --- .../packages/ui/src/components/Dialog/Dialog.tsx | 3 ++- .../ui/src/components/Disclosure/Disclosure.tsx | 5 +++-- .../packages/ui/src/components/Menu/MenuPopover.tsx | 3 ++- .../ui/src/components/SplitView/SplitView.tsx | 3 ++- .../packages/ui/src/components/Tooltip/Tooltip.tsx | 12 +++++++++++- .../src/flow-chat/conversation/FlowChatCollapse.tsx | 3 ++- .../ui/src/flow-chat/conversation/ThinkingBlock.tsx | 5 +++-- .../ui/src/flow-chat/tool-cards/FlowChatToolCard.tsx | 3 ++- design-system/packages/ui/src/internal/inert.ts | 12 ++++++++++++ .../ui/src/mobile/MobileSheet/MobileSheet.tsx | 3 ++- design-system/packages/ui/src/overlay/Portal.tsx | 3 ++- 11 files changed, 43 insertions(+), 12 deletions(-) create mode 100644 design-system/packages/ui/src/internal/inert.ts diff --git a/design-system/packages/ui/src/components/Dialog/Dialog.tsx b/design-system/packages/ui/src/components/Dialog/Dialog.tsx index 89285e0199..8dab7eeb3d 100644 --- a/design-system/packages/ui/src/components/Dialog/Dialog.tsx +++ b/design-system/packages/ui/src/components/Dialog/Dialog.tsx @@ -23,6 +23,7 @@ import { useDismissibleLayer } from "../../overlay/useDismissibleLayer"; import { useFocusScope } from "../../overlay/useFocusScope"; import { usePresence } from "../../overlay/usePresence"; import { IconButton, type IconButtonProps } from "../IconButton"; +import { inertProps } from "../../internal/inert"; import styles from "./Dialog.module.css"; const EXIT_DURATION_MS = 180; @@ -183,7 +184,7 @@ const OverlaySurface = forwardRef(function
, @@ -46,7 +47,7 @@ interface NativeDisclosureProps export type DisclosureProps = CustomDisclosureProps | NativeDisclosureProps; -type InertContentAttributes = HTMLAttributes & { inert?: "" }; +type InertContentAttributes = HTMLAttributes & InertProps; const CustomDisclosure = forwardRef( function Disclosure({ @@ -73,7 +74,7 @@ const CustomDisclosure = forwardRef( const contentId = `openbitfun-disclosure-${generatedId}-content`; const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const resolvedOpen = open ?? uncontrolledOpen; - const inertContentAttributes: InertContentAttributes = resolvedOpen ? {} : { inert: "" }; + const inertContentAttributes: InertContentAttributes = inertProps(!resolvedOpen); const [retained, setRetained] = useState(resolvedOpen); useEffect(() => { diff --git a/design-system/packages/ui/src/components/Menu/MenuPopover.tsx b/design-system/packages/ui/src/components/Menu/MenuPopover.tsx index 593d65a8c5..b50ced3712 100644 --- a/design-system/packages/ui/src/components/Menu/MenuPopover.tsx +++ b/design-system/packages/ui/src/components/Menu/MenuPopover.tsx @@ -9,6 +9,7 @@ import { Portal } from "../../overlay/Portal"; import { useDismissibleLayer } from "../../overlay/useDismissibleLayer"; import { useFocusScope } from "../../overlay/useFocusScope"; import { usePresence } from "../../overlay/usePresence"; +import { inertProps } from "../../internal/inert"; import styles from "./MenuPopover.module.css"; export interface MenuEntry { @@ -204,7 +205,7 @@ function MenuLevel({ items, open, phase, treeId, onClose, onBack, anchorRef, pos return <> { (menuRef as { current: HTMLDivElement | null }).current = node; }} className={classNames(styles.popup, className)} inlineSize={inlineSize} autoFocusFirstItem={open && autoFocusFirstItem && Boolean(layout)} tabIndex={-1} style={{ ...layout?.style, ...style, visibility: layout ? undefined : "hidden" }} data-openbitfun-native-webview-occlusion data-openbitfun-menu-tree={treeId} data-placement={layout?.placement ?? placement} data-state={phase} - aria-hidden={!open || undefined} {...(!open ? { inert: "" } : {})} onContextMenu={event => event.preventDefault()}> + aria-hidden={!open || undefined} {...inertProps(!open)} onContextMenu={event => event.preventDefault()}> {items.map(item => item.separator ? : {item.icon} : undefined} shortcut={item.shortcut ? {item.shortcut} : undefined} tone={item.tone} role={item.role} checked={item.checked} disabled={item.disabled} aria-disabled={item.disabled || undefined} aria-haspopup={item.submenu?.length ? "menu" : undefined} aria-expanded={item.submenu?.length ? activeEntry?.id === item.id : undefined} aria-controls={activeEntry?.id === item.id ? submenuId : undefined} metadata={item.submenu?.length ? : undefined} diff --git a/design-system/packages/ui/src/components/SplitView/SplitView.tsx b/design-system/packages/ui/src/components/SplitView/SplitView.tsx index 2b458922ab..630f46c8b3 100644 --- a/design-system/packages/ui/src/components/SplitView/SplitView.tsx +++ b/design-system/packages/ui/src/components/SplitView/SplitView.tsx @@ -3,6 +3,7 @@ import { type CSSProperties, type HTMLAttributes, type PointerEvent, type ReactNode, } from 'react'; import { classNames } from '../../internal/classNames'; +import { inertProps } from '../../internal/inert'; import styles from './SplitView.module.css'; export type SplitViewMode = 'split' | 'primary' | 'secondary'; @@ -16,7 +17,7 @@ export function SplitViewSurface({ pane, active = true, className, children, ... return ; diff --git a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx index 326cba2194..c864dc561c 100644 --- a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx +++ b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx @@ -6,6 +6,7 @@ import { useId, useRef, useState, + version as reactVersion, type FocusEvent as ReactFocusEvent, type MouseEvent as ReactMouseEvent, type ReactElement, @@ -73,6 +74,15 @@ function assignRef(ref: Ref | undefined, value: T | null): void { (ref as { current: T | null }).current = value; } +// React 19 passes `ref` as a regular prop and warns when `element.ref` is read. +const REF_IS_PROP = Number.parseInt(reactVersion, 10) >= 19; + +function readElementRef(element: ReactElement | undefined): Ref | undefined { + if (!element) return undefined; + if (REF_IS_PROP) return (element.props as { ref?: Ref }).ref; + return (element as ReactElement & { ref?: Ref }).ref; +} + const OPPOSITE_PLACEMENT: Record = { top: "bottom", bottom: "top", @@ -475,7 +485,7 @@ export function Tooltip({ }, [stopWatchingScroll, triggerRef, visible]); const childProps = (children?.props ?? {}) as Record; - const childRef = (children as (ReactElement & { ref?: Ref }) | undefined)?.ref; + const childRef = readElementRef(children as ReactElement | undefined); const handleTriggerRef = useCallback((node: HTMLElement | null) => { internalTriggerRef.current = node; diff --git a/design-system/packages/ui/src/flow-chat/conversation/FlowChatCollapse.tsx b/design-system/packages/ui/src/flow-chat/conversation/FlowChatCollapse.tsx index 809f69d6b7..685648532d 100644 --- a/design-system/packages/ui/src/flow-chat/conversation/FlowChatCollapse.tsx +++ b/design-system/packages/ui/src/flow-chat/conversation/FlowChatCollapse.tsx @@ -1,4 +1,5 @@ import { useLayoutEffect, useRef, useState, type HTMLAttributes, type ReactNode } from 'react'; +import { inertProps } from '../../internal/inert'; import './ConversationBlocks.css'; export const FLOWCHAT_COLLAPSE_DURATION_MS = 300; export const FLOWCHAT_COLLAPSE_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)'; @@ -129,7 +130,7 @@ export const FlowChatCollapse = ({ transitionTimingFunction: `${FLOWCHAT_COLLAPSE_EASING}, ${FLOWCHAT_COLLAPSE_EASING}, ${FLOWCHAT_COLLAPSE_EASING}`, }} aria-hidden={!isOpen} - {...{ inert: !isOpen ? '' : undefined }} + {...inertProps(!isOpen)} > {shouldRender && (
diff --git a/design-system/packages/ui/src/flow-chat/conversation/ThinkingBlock.tsx b/design-system/packages/ui/src/flow-chat/conversation/ThinkingBlock.tsx index 3ad3ddd51a..7d99c75990 100644 --- a/design-system/packages/ui/src/flow-chat/conversation/ThinkingBlock.tsx +++ b/design-system/packages/ui/src/flow-chat/conversation/ThinkingBlock.tsx @@ -8,6 +8,7 @@ import { useThinkingAnnotation } from './useThinkingAnnotation'; import type { ThinkingContinuationResolver } from './useThinkingAnnotation'; import { useThinkingHandoff } from './useThinkingHandoff'; import { useThinkingSuccessor } from './useThinkingSuccessor'; +import { inertProps } from '../../internal/inert'; import './ConversationBlocks.css'; const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; @@ -145,7 +146,7 @@ export const ThinkingBlock = forwardRef(func
-
@@ -153,7 +154,7 @@ export const ThinkingBlock = forwardRef(func data-openbitfun-component="model-thinking-display" data-openbitfun-part="contentWrapper">
{children}
diff --git a/design-system/packages/ui/src/flow-chat/tool-cards/FlowChatToolCard.tsx b/design-system/packages/ui/src/flow-chat/tool-cards/FlowChatToolCard.tsx index 7020c2a7cf..57903190c7 100644 --- a/design-system/packages/ui/src/flow-chat/tool-cards/FlowChatToolCard.tsx +++ b/design-system/packages/ui/src/flow-chat/tool-cards/FlowChatToolCard.tsx @@ -22,6 +22,7 @@ import { ToolCapsulePresentationProvider, useToolCapsulePresentation } from './T import { TOOL_CAPSULE_COLLAPSE_DURATION_MS, useToolCapsuleMotion } from '../motion/capsuleMotion'; import { classNames } from "../../internal/classNames"; import { OverflowText } from "../../primitives/OverflowText"; +import { inertProps } from "../../internal/inert"; import styles from "./FlowChatToolCard.module.css"; export type FlowChatToolStatus = @@ -223,7 +224,7 @@ function CollapsibleRegion({ ref={regionRef} id={id} aria-hidden={!open} - {...{ inert: !open ? '' : undefined }} + {...inertProps(!open)} className={styles.collapse} data-animate={disableAnimation ? "false" : "true"} data-openbitfun-component="flow-chat-tool-card" diff --git a/design-system/packages/ui/src/internal/inert.ts b/design-system/packages/ui/src/internal/inert.ts new file mode 100644 index 0000000000..fa0f6c9870 --- /dev/null +++ b/design-system/packages/ui/src/internal/inert.ts @@ -0,0 +1,12 @@ +import { version } from "react"; + +// React 19 treats `inert` as a boolean prop and ignores an empty string; +// React 18 only forwards the attribute when it is given a string. +const INERT_IS_BOOLEAN = Number.parseInt(version, 10) >= 19; + +export type InertProps = { inert?: "" }; + +export function inertProps(isInert: boolean): InertProps { + if (!isInert) return {}; + return { inert: (INERT_IS_BOOLEAN ? true : "") as "" }; +} diff --git a/design-system/packages/ui/src/mobile/MobileSheet/MobileSheet.tsx b/design-system/packages/ui/src/mobile/MobileSheet/MobileSheet.tsx index 36dee60bb4..6d5d66d6c2 100644 --- a/design-system/packages/ui/src/mobile/MobileSheet/MobileSheet.tsx +++ b/design-system/packages/ui/src/mobile/MobileSheet/MobileSheet.tsx @@ -14,6 +14,7 @@ import { useDismissibleLayer } from "../../overlay/useDismissibleLayer"; import { useFocusScope } from "../../overlay/useFocusScope"; import { usePresence } from "../../overlay/usePresence"; import type { OverlayDismissReason } from "../../overlay/types"; +import { inertProps } from "../../internal/inert"; import styles from "./MobileSheet.module.css"; const EXIT_DURATION_MS = 180; @@ -109,7 +110,7 @@ export const MobileSheet = forwardRef( >
(null); @@ -118,7 +119,7 @@ function LayerMount({
+ data-openbitfun-overlay-modal={modal || undefined} {...inertProps(!open)}> {children}
From 8f8fa0e48c5ea74a84c2def95c27f44803fe9189 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 24 Oct 2026 12:13:49 +0800 Subject: [PATCH 2/2] feat(desktop-cards): add themed chart templates and presets --- .../capabilities.json | 10 +- .../capabilities/feature.desktop-cards.md | 4 +- .../technical/product-control-open-audit.json | 2 +- .../technical/tauri-command-map.json | 2 +- .../create-agent/references/tool-catalog.md | 2 +- .../src/agentic/tools/desktop_card_host.rs | 453 +++++++++++++++++- .../implementations/desktop_card_tool.rs | 349 +++++++++++++- .../generated/product-control-catalog.json | 10 +- .../interactive-capabilities/catalog.json | 7 +- .../DesktopCardChartTemplate.test.tsx | 151 ++++++ .../DesktopCard/DesktopCardTemplates.tsx | 238 +++++++++ .../DesktopCardWindow.appearance.ts | 2 +- .../DesktopCard/DesktopCardWindow.scss | 278 ++++++++++- .../DesktopCard/DesktopCardWindow.test.tsx | 1 + .../DesktopCard/DesktopCardWindow.tsx | 2 + .../DesktopCard/desktopCardIcons.ts | 7 + .../DesktopCard/desktopCardModel.test.ts | 135 +++++- .../DesktopCard/desktopCardModel.ts | 194 +++++++- .../generated/interactive-capabilities.json | 10 +- .../tool-cards/builtinDesktopCardModel.ts | 1 + .../tool-cards/builtinToolCardModel.test.ts | 6 + .../api/generated/productControl.ts | 2 +- .../api/service-api/DesktopCardAPI.ts | 33 ++ src/web-ui/src/locales/en-US/components.json | 8 + src/web-ui/src/locales/en-US/flow-chat.json | 1 + .../locales/en-US/scenes/capabilities.json | 2 +- src/web-ui/src/locales/zh-CN/components.json | 8 + src/web-ui/src/locales/zh-CN/flow-chat.json | 1 + .../locales/zh-CN/scenes/capabilities.json | 2 +- src/web-ui/src/locales/zh-TW/components.json | 8 + src/web-ui/src/locales/zh-TW/flow-chat.json | 1 + .../locales/zh-TW/scenes/capabilities.json | 2 +- 32 files changed, 1879 insertions(+), 53 deletions(-) create mode 100644 src/web-ui/src/app/components/DesktopCard/DesktopCardChartTemplate.test.tsx diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index 66aeb64143..99af228476 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "c7c38fecfad3e83e4a9f6f451a37edbf7d42ef399409238253f9a0fd124f879b", + "digest": "da8a1c7e721910916da62280a534391c89f6457098aadf9200e85e797145a12a", "ownerDigest": "103fc8646df7b35e9fac1c52149a35b86e9c6115b462b0aa36240a4600903ce4", "searchAcceptance": [ { @@ -25469,8 +25469,8 @@ "items": [ { "id": "agent-cards", - "titleZh": "按需求或在任务过程中创建倒计时、清单、便签、进度、指标、定时任务和自定义卡片,可定时从 HTTP 接口或本机命令刷新数据", - "titleEn": "Create countdown, checklist, note, progress, metrics, scheduled-job, and custom cards on request or during a task, optionally refreshed from an HTTP endpoint or a local command", + "titleZh": "按需求或在任务过程中创建倒计时、清单、便签、进度、指标、图表、定时任务和自定义卡片,可定时从 HTTP 接口或本机命令刷新数据", + "titleEn": "Create countdown, checklist, note, progress, metrics, chart, scheduled-job, and custom cards on request or during a task, optionally refreshed from an HTTP endpoint or a local command", "control": { "kind": "delegate", "tools": [ @@ -25602,8 +25602,8 @@ "Agents create and update cards when useful", "View, show, hide, or delete cards from Home", "Drag cards; positions are restored after restart", - "按需求或在任务过程中创建倒计时、清单、便签、进度、指标、定时任务和自定义卡片,可定时从 HTTP 接口或本机命令刷新数据", - "Create countdown, checklist, note, progress, metrics, scheduled-job, and custom cards on request or during a task, optionally refreshed from an HTTP endpoint or a local command", + "按需求或在任务过程中创建倒计时、清单、便签、进度、指标、图表、定时任务和自定义卡片,可定时从 HTTP 接口或本机命令刷新数据", + "Create countdown, checklist, note, progress, metrics, chart, scheduled-job, and custom cards on request or during a task, optionally refreshed from an HTTP endpoint or a local command", "在首页“小卡片”区查看全部卡片,并显示、隐藏或删除单张卡片", "See every card in the Home Desktop cards section, and show, hide, or delete individual cards", "在卡片上刷新数据、勾选清单项、切换置顶、打开链接或回到来源会话", diff --git a/docs/interactive-capabilities/capabilities/feature.desktop-cards.md b/docs/interactive-capabilities/capabilities/feature.desktop-cards.md index bccd806565..49ee99840b 100644 --- a/docs/interactive-capabilities/capabilities/feature.desktop-cards.md +++ b/docs/interactive-capabilities/capabilities/feature.desktop-cards.md @@ -17,8 +17,8 @@ Let agents place draggable cards outside the main window for glanceable informat ## 完整功能清单 / Everything included -- **由专用智能体工具控制 / Delegated Agent tool** · `DesktopCard` · 按需求或在任务过程中创建倒计时、清单、便签、进度、指标、定时任务和自定义卡片,可定时从 HTTP 接口或本机命令刷新数据 - - Create countdown, checklist, note, progress, metrics, scheduled-job, and custom cards on request or during a task, optionally refreshed from an HTTP endpoint or a local command +- **由专用智能体工具控制 / Delegated Agent tool** · `DesktopCard` · 按需求或在任务过程中创建倒计时、清单、便签、进度、指标、图表、定时任务和自定义卡片,可定时从 HTTP 接口或本机命令刷新数据 + - Create countdown, checklist, note, progress, metrics, chart, scheduled-job, and custom cards on request or during a task, optionally refreshed from an HTTP endpoint or a local command - **由专用智能体工具控制 / Delegated Agent tool** · `DesktopCard` · 在首页“小卡片”区查看全部卡片,并显示、隐藏或删除单张卡片 - See every card in the Home Desktop cards section, and show, hide, or delete individual cards - **由专用智能体工具控制 / Delegated Agent tool** · `DesktopCard` · 在卡片上刷新数据、勾选清单项、切换置顶、打开链接或回到来源会话 diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index a0fb2dcb4a..ff011f8b44 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "c7c38fecfad3e83e4a9f6f451a37edbf7d42ef399409238253f9a0fd124f879b", + "catalogDigest": "da8a1c7e721910916da62280a534391c89f6457098aadf9200e85e797145a12a", "count": 216, "reasonCounts": { "externalAuth": 4, diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 20eda4f945..f83100f17c 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "c7c38fecfad3e83e4a9f6f451a37edbf7d42ef399409238253f9a0fd124f879b", + "catalogDigest": "da8a1c7e721910916da62280a534391c89f6457098aadf9200e85e797145a12a", "commandCount": 701, "coverage": { "commandCount": 701, diff --git a/src/crates/assembly/core/builtin_skills/create-agent/references/tool-catalog.md b/src/crates/assembly/core/builtin_skills/create-agent/references/tool-catalog.md index 3faf424b43..d5e608763f 100644 --- a/src/crates/assembly/core/builtin_skills/create-agent/references/tool-catalog.md +++ b/src/crates/assembly/core/builtin_skills/create-agent/references/tool-catalog.md @@ -102,7 +102,7 @@ Use these tables to select tools for the user's tasks, then put their exact name | --- | --- | | `TodoWrite` | Create and update the session's todo list. | | `Cron` | Manage scheduled jobs. | -| `DesktopCard` | Show glanceable cards (countdowns, checklists, metrics, status) on the user's desktop outside the main window; desktop host only. | +| `DesktopCard` | Show glanceable cards (countdowns, checklists, metrics, charts, status) on the user's desktop outside the main window; desktop host only. | | `Worktree` | List worktrees and manage isolated sessions, branches, and worktree removal. | | `GetFileDiff` | Show a file's diff against its baseline snapshot or Git HEAD. | | `ReviewPlatform` | Inspect and operate on hosted pull requests or merge requests. | diff --git a/src/crates/assembly/core/src/agentic/tools/desktop_card_host.rs b/src/crates/assembly/core/src/agentic/tools/desktop_card_host.rs index 47adbef04a..426a5da5bc 100644 --- a/src/crates/assembly/core/src/agentic/tools/desktop_card_host.rs +++ b/src/crates/assembly/core/src/agentic/tools/desktop_card_host.rs @@ -30,6 +30,8 @@ const MAX_NOTE_CHARS: usize = 4_000; const MAX_CHECKLIST_ITEMS: usize = 50; const MAX_CHECKLIST_ITEM_CHARS: usize = 300; const MAX_METRIC_ITEMS: usize = 12; +const MAX_CHART_CAPTION_CHARS: usize = 300; +const MAX_CHART_KEY_CHARS: usize = 64; const MAX_CUSTOM_HTML_BYTES: usize = 96 * 1024; const MAX_DATA_BYTES: usize = 64 * 1024; const MAX_URL_CHARS: usize = 2_048; @@ -41,6 +43,7 @@ pub const DESKTOP_CARD_ICONS: &[&str] = &[ "alarm", "bell", "calendar", + "chart", "check", "clock", "cloud", @@ -49,10 +52,12 @@ pub const DESKTOP_CARD_ICONS: &[&str] = &[ "flag", "git-pull-request", "globe", + "hard-drive", "info", "list", "note", "package", + "pie-chart", "rocket", "server", "star", @@ -75,6 +80,19 @@ const METRIC_FORMATS: &[&str] = &[ "relativeTime", ]; +/// Chart styles with the most static items each accepts. Bound data arrays +/// are trimmed to the same limits when the card renders. +pub const DESKTOP_CARD_CHART_STYLES: &[(&str, usize)] = &[ + ("bar", 12), + ("column", 24), + ("line", 120), + ("donut", 6), + ("stacked", 6), +]; + +const CHART_VALUE_FORMATS: &[&str] = &["number", "percent", "currency"]; +const CHART_LABEL_FORMATS: &[&str] = &["text", "date", "time", "weekday", "datetime"]; + fn default_true() -> bool { true } @@ -146,6 +164,65 @@ pub struct DesktopCardMetricItem { pub emphasis: bool, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopCardChartItem { + pub label: String, + pub value: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tone: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hint: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopCardChart { + /// Style such as `bar` or `donut`; see [`DESKTOP_CARD_CHART_STYLES`]. Stored + /// records may carry a style from a newer build, which the renderer reports + /// as unsupported. + pub chart: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec, + /// JSON Pointer to an array in the card data: numbers, `[label, value]` + /// pairs, or objects read through `labelKey` / `valueKey` / `hintKey`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub items_path: Option, + /// JSON Pointer to labels parallel to a numeric `itemsPath` array. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hint_key: Option, + /// Whole that the items are part of; the uncovered rest is drawn as the remainder. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remainder_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub digits: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub currency: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub suffix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label_format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub caption: Option, + /// Chart fields written by newer builds survive a rewrite by this build. + #[serde(flatten)] + pub extra: Map, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum DesktopCardTemplateKind { @@ -167,7 +244,9 @@ pub enum DesktopCardTemplateKind { items: Vec, }, #[serde(rename_all = "camelCase")] - Note { text: String }, + Note { + text: String, + }, #[serde(rename_all = "camelCase")] Progress { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -190,10 +269,15 @@ pub enum DesktopCardTemplateKind { #[serde(default, skip_serializing_if = "Option::is_none")] layout: Option, }, + Chart(Box), #[serde(rename_all = "camelCase")] - CronJob { job_id: String }, + CronJob { + job_id: String, + }, #[serde(rename_all = "camelCase")] - Custom { html: String }, + Custom { + html: String, + }, } impl DesktopCardTemplateKind { @@ -204,6 +288,7 @@ impl DesktopCardTemplateKind { Self::Note { .. } => "note", Self::Progress { .. } => "progress", Self::Metrics { .. } => "metrics", + Self::Chart(_) => "chart", Self::CronJob { .. } => "cronJob", Self::Custom { .. } => "custom", } @@ -216,6 +301,12 @@ impl DesktopCardTemplateKind { Self::Note { .. } => (280.0, 200.0), Self::Progress { .. } => (280.0, 150.0), Self::Metrics { .. } => (300.0, 210.0), + Self::Chart(chart) => match chart.chart.as_str() { + "bar" => (320.0, 280.0), + "donut" => (320.0, 220.0), + "stacked" => (320.0, 240.0), + _ => (320.0, 210.0), + }, Self::CronJob { .. } => (280.0, 170.0), Self::Custom { .. } => (320.0, 240.0), }; @@ -580,6 +671,17 @@ pub fn desktop_card_template_paths(template: &DesktopCardTemplateKind) -> Vec<(S } } } + DesktopCardTemplateKind::Chart(chart) => { + for (field, path) in [ + ("itemsPath", &chart.items_path), + ("labelsPath", &chart.labels_path), + ("totalPath", &chart.total_path), + ] { + if let Some(path) = path { + paths.push((field.to_string(), path.clone())); + } + } + } _ => {} } paths @@ -845,6 +947,9 @@ pub fn validate_template( layout, }) } + DesktopCardTemplateKind::Chart(chart) => Ok(DesktopCardTemplateKind::Chart(Box::new( + validate_chart(*chart)?, + ))), DesktopCardTemplateKind::CronJob { job_id } => Ok(DesktopCardTemplateKind::CronJob { job_id: required_text("cronJob jobId", &job_id, 200)?, }), @@ -863,6 +968,157 @@ pub fn validate_template( } } +fn validate_chart_key(field: &str, key: Option<&str>) -> Result, String> { + match key.map(str::trim) { + None => Ok(None), + Some("") => Err(format!("chart {field} must not be empty")), + Some(key) if key.starts_with('/') => Err(format!( + "chart {field} `{key}` is a property name inside each array element, not a JSON Pointer; drop the leading /" + )), + Some(key) if char_len(key) > MAX_CHART_KEY_CHARS => Err(format!( + "chart {field} must be at most {MAX_CHART_KEY_CHARS} characters" + )), + Some(key) => Ok(Some(key.to_string())), + } +} + +fn validate_chart(chart: DesktopCardChart) -> Result { + let style = chart.chart.trim().to_string(); + let Some((_, max_items)) = DESKTOP_CARD_CHART_STYLES + .iter() + .find(|(candidate, _)| *candidate == style) + else { + return Err(format!( + "chart `{style}` is not supported; template.chart must be one of: {}", + DESKTOP_CARD_CHART_STYLES + .iter() + .map(|(style, _)| *style) + .collect::>() + .join(", ") + )); + }; + let max_items = *max_items; + let is_line = style == "line"; + let part_of_whole = matches!(style.as_str(), "donut" | "stacked"); + + match (chart.items.is_empty(), chart.items_path.is_some()) { + (true, false) => { + return Err(format!( + "chart requires template.items (static [{{label, value}}]) or template.itemsPath (JSON Pointer to an array in the card data), for example {{\"kind\":\"chart\",\"chart\":\"{style}\",\"items\":[{{\"label\":\"Cache\",\"value\":4.4}}]}}" + )) + } + (false, true) => { + return Err( + "chart takes either template.items or template.itemsPath, not both".to_string(), + ) + } + _ => {} + } + if chart.items.len() > max_items { + let advice = if part_of_whole { + " Merge the smallest parts into one \"Other\" item, or set total so the uncovered rest is drawn automatically." + } else { + " Keep the largest items, or use another chart style." + }; + return Err(format!( + "{style} charts take at most {max_items} items.{advice}" + )); + } + if is_line && chart.items.len() == 1 { + return Err( + "line charts need at least 2 points; use metrics for a single value".to_string(), + ); + } + if chart.labels_path.is_some() && chart.items_path.is_none() { + return Err("chart labelsPath requires itemsPath".to_string()); + } + + let mut items = Vec::with_capacity(chart.items.len()); + for item in chart.items { + let label = required_text("chart item label", &item.label, MAX_TITLE_CHARS)?; + if !item.value.is_finite() { + return Err(format!( + "chart item `{label}` value must be a finite number" + )); + } + if item.value < 0.0 && !is_line { + return Err(format!( + "chart item `{label}` value must not be negative for {style} charts; only line charts plot negative values" + )); + } + items.push(DesktopCardChartItem { + tone: validate_tone("chart item tone", item.tone.as_deref())?, + hint: optional_text("chart item hint", item.hint.as_deref(), MAX_SUBTITLE_CHARS)?, + label, + value: item.value, + }); + } + + if chart + .total + .is_some_and(|total| !total.is_finite() || total <= 0.0) + { + return Err("chart total must be a positive number".to_string()); + } + if chart.total.is_some() && chart.total_path.is_some() { + return Err("chart takes either total or totalPath, not both".to_string()); + } + if (chart.total.is_some() || chart.total_path.is_some()) && is_line { + return Err( + "line charts do not use total; it applies to bar, column, donut, and stacked charts" + .to_string(), + ); + } + if let Some(format) = chart.format.as_deref() { + if !CHART_VALUE_FORMATS.contains(&format) { + return Err(format!( + "chart format `{format}` is not supported; use one of: {}", + CHART_VALUE_FORMATS.join(", ") + )); + } + } + if let Some(format) = chart.label_format.as_deref() { + if !CHART_LABEL_FORMATS.contains(&format) { + return Err(format!( + "chart labelFormat `{format}` is not supported; use one of: {}", + CHART_LABEL_FORMATS.join(", ") + )); + } + } + if chart.digits.is_some_and(|digits| digits > 8) { + return Err("chart digits must be at most 8".to_string()); + } + + Ok(DesktopCardChart { + chart: style, + items, + items_path: validate_pointer("chart itemsPath", chart.items_path.as_deref())?, + labels_path: validate_pointer("chart labelsPath", chart.labels_path.as_deref())?, + label_key: validate_chart_key("labelKey", chart.label_key.as_deref())?, + value_key: validate_chart_key("valueKey", chart.value_key.as_deref())?, + hint_key: validate_chart_key("hintKey", chart.hint_key.as_deref())?, + total: chart.total, + total_path: validate_pointer("chart totalPath", chart.total_path.as_deref())?, + remainder_label: optional_text( + "chart remainderLabel", + chart.remainder_label.as_deref(), + MAX_TITLE_CHARS, + )?, + format: chart.format, + digits: chart.digits, + currency: optional_text("chart currency", chart.currency.as_deref(), 8)?, + prefix: chart.prefix, + suffix: chart.suffix, + label_format: chart.label_format, + caption: optional_text( + "chart caption", + chart.caption.as_deref(), + MAX_CHART_CAPTION_CHARS, + )?, + extra: Map::new(), + }) +} + pub fn validate_data_source( source: DesktopCardDataSourceKind, ) -> Result { @@ -1134,6 +1390,197 @@ mod tests { .is_empty()); } + fn chart(value: Value) -> Result { + serde_json::from_value::(value) + .map_err(|error| error.to_string()) + .and_then(validate_template) + } + + #[test] + fn chart_templates_validate_and_normalize() { + let template = chart(json!({ + "kind": "chart", + "chart": " stacked ", + "total": 906, + "remainderLabel": " Free ", + "suffix": " GB", + "items": [ + { "label": " WeChat ", "value": 62.7, "tone": "warning", "hint": " Clean inside WeChat " }, + { "label": "Other", "value": 249.3 } + ], + "caption": " Nothing was deleted. " + })) + .unwrap(); + let DesktopCardTemplateKind::Chart(chart_template) = &template else { + panic!("chart expected"); + }; + assert_eq!(chart_template.chart, "stacked"); + assert_eq!(chart_template.items[0].label, "WeChat"); + assert_eq!( + chart_template.items[0].hint.as_deref(), + Some("Clean inside WeChat") + ); + assert_eq!(chart_template.remainder_label.as_deref(), Some("Free")); + assert_eq!(chart_template.suffix.as_deref(), Some(" GB")); + assert_eq!( + chart_template.caption.as_deref(), + Some("Nothing was deleted.") + ); + assert_eq!(template.kind(), "chart"); + assert_eq!( + template.default_size(), + DesktopCardSize { + width: 320.0, + height: 240.0 + } + ); + + let bound = chart(json!({ + "kind": "chart", + "chart": "line", + "itemsPath": "/close", + "labelsPath": "/timestamp", + "labelFormat": "date", + "format": "currency", + "currency": "USD" + })) + .unwrap(); + let paths = desktop_card_template_paths(&bound) + .into_iter() + .map(|(field, path)| format!("{field}={path}")) + .collect::>(); + assert_eq!(paths, ["itemsPath=/close", "labelsPath=/timestamp"]); + assert!(chart(json!({ + "kind": "chart", + "chart": "line", + "items": [{ "label": "Mon", "value": -2.5 }, { "label": "Tue", "value": 1 }] + })) + .is_ok()); + } + + #[test] + fn chart_templates_reject_unusable_shapes() { + let item = json!({ "label": "a", "value": 1 }); + let cases = [ + ( + json!({ "kind": "chart", "chart": "radar", "items": [item] }), + "must be one of: bar, column, line, donut, stacked", + ), + ( + json!({ "kind": "chart", "chart": "bar" }), + "requires template.items", + ), + ( + json!({ "kind": "chart", "chart": "bar", "items": [item], "itemsPath": "/rows" }), + "not both", + ), + ( + json!({ "kind": "chart", "chart": "donut", "items": [{ "label": "a", "value": -1 }] }), + "must not be negative", + ), + ( + json!({ "kind": "chart", "chart": "stacked", "items": vec![item.clone(); 7] }), + "Merge the smallest parts", + ), + ( + json!({ "kind": "chart", "chart": "bar", "items": vec![item.clone(); 13] }), + "at most 12 items", + ), + ( + json!({ "kind": "chart", "chart": "line", "items": [item] }), + "at least 2 points", + ), + ( + json!({ "kind": "chart", "chart": "bar", "items": [item], "total": 0 }), + "total must be a positive number", + ), + ( + json!({ "kind": "chart", "chart": "bar", "items": [item], "total": 5, "totalPath": "/t" }), + "either total or totalPath", + ), + ( + json!({ "kind": "chart", "chart": "line", "itemsPath": "/v", "total": 5 }), + "line charts do not use total", + ), + ( + json!({ "kind": "chart", "chart": "bar", "itemsPath": "/rows", "labelKey": "/name" }), + "drop the leading /", + ), + ( + json!({ "kind": "chart", "chart": "bar", "items": [item], "labelsPath": "/l" }), + "labelsPath requires itemsPath", + ), + ( + json!({ "kind": "chart", "chart": "bar", "itemsPath": "rows" }), + "must be a JSON Pointer", + ), + ( + json!({ "kind": "chart", "chart": "bar", "items": [item], "format": "datetime" }), + "chart format `datetime` is not supported", + ), + ( + json!({ "kind": "chart", "chart": "bar", "items": [item], "labelFormat": "month" }), + "chart labelFormat `month` is not supported", + ), + ( + json!({ "kind": "chart", "chart": "bar", "items": [{ "label": "a", "value": 1, "tone": "pink" }] }), + "chart item tone `pink`", + ), + ( + json!({ "kind": "chart", "chart": "bar", "items": [{ "label": " ", "value": 1 }] }), + "chart item label must not be empty", + ), + ]; + for (template, expected) in cases { + let error = chart(template.clone()).unwrap_err(); + assert!(error.contains(expected), "{template}: {error}"); + } + } + + #[test] + fn chart_records_round_trip_and_tolerate_newer_styles() { + let mut value = record_json(); + value["template"] = json!({ + "kind": "chart", + "chart": "bar", + "items": [{ "label": "npm", "value": 4.12, "hint": "npm cache clean --force" }], + "suffix": " GB" + }); + let record: DesktopCardRecord = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(record.template.kind(), "chart"); + assert!(record.template.known().is_some()); + assert_eq!( + serde_json::to_value(&record).unwrap()["template"], + value["template"] + ); + + value["template"] = json!({ + "kind": "chart", + "chart": "radar", + "axes": [{ "label": "speed", "max": 10 }], + "items": [{ "label": "a", "value": 3.5 }] + }); + let record: DesktopCardRecord = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(record.template.kind(), "chart"); + assert_eq!( + serde_json::to_value(&record).unwrap()["template"], + value["template"] + ); + assert!(validate_template(record.template.known().unwrap().clone()).is_err()); + + let template = chart(json!({ + "kind": "chart", + "chart": "bar", + "items": [{ "label": "a", "value": 1 }], + "sort": "desc" + })) + .unwrap(); + assert!(serde_json::to_value(&template) + .unwrap() + .get("sort") + .is_none()); + } + #[test] fn checklist_ids_are_made_unique() { let template = validate_template(DesktopCardTemplateKind::Checklist { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/desktop_card_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/desktop_card_tool.rs index b6f3bbdda6..a32dc322da 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/desktop_card_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/desktop_card_tool.rs @@ -5,7 +5,8 @@ use crate::agentic::tools::desktop_card_host::{ invoke_desktop_card_host, validate_data, validate_data_source, validate_icon, validate_size, validate_subtitle, validate_template, validate_title, validate_tone, validate_web_url, DesktopCardDataSourceKind, DesktopCardDraft, DesktopCardHostRequest, DesktopCardOrigin, - DesktopCardPatch, DesktopCardSize, DesktopCardTemplateKind, DESKTOP_CARD_ICONS, + DesktopCardPatch, DesktopCardSize, DesktopCardTemplateKind, DESKTOP_CARD_CHART_STYLES, + DESKTOP_CARD_ICONS, }; use crate::agentic::tools::framework::{PermissionIntent, Tool, ToolResult, ToolUseContext}; use crate::agentic::tools::implementations::ExecCommandTool; @@ -38,6 +39,10 @@ const TEMPLATE_SHAPES: &[(&str, &str)] = &[ "metrics", r#"{"kind":"metrics","layout":"grid","items":[{"label":"Stars","path":"/stars","format":"number"}]}"#, ), + ( + "chart", + r#"{"kind":"chart","chart":"bar","suffix":" GB","items":[{"label":"npm cache","value":4.12,"hint":"npm cache clean --force"}]} (chart: bar|column|line|donut|stacked; or itemsPath into the card data instead of items)"#, + ), ( "cronJob", r#"{"kind":"cronJob","jobId":""}"#, @@ -60,6 +65,12 @@ const MISPLACEABLE_TEMPLATE_FIELDS: &[&str] = &[ "valuePath", "maxPath", "statusPath", + "chart", + "itemsPath", + "labelsPath", + "totalPath", + "remainderLabel", + "caption", "jobId", "html", ]; @@ -124,6 +135,18 @@ fn pull_request_status_command() -> &'static str { } } +/// For example `bar up to 12 items | line up to 120 points`. +fn chart_style_summary() -> String { + DESKTOP_CARD_CHART_STYLES + .iter() + .map(|(style, max)| { + let unit = if *style == "line" { "points" } else { "items" }; + format!("{style} up to {max} {unit}") + }) + .collect::>() + .join(" | ") +} + fn template_shape(kind: &str) -> Option<&'static str> { TEMPLATE_SHAPES .iter() @@ -485,6 +508,76 @@ fn presets() -> Value { }, "notes": "When it reaches zero the card highlights and a system notification is sent. Use targetAt with an ISO-8601 offset for wall-clock times." }, + { + "id": "usage-breakdown", + "useWhen": "You measured how a whole splits into parts and the user benefits from seeing the proportions: disk or quota usage after a scan, a budget, time spent per activity.", + "call": { + "action": "create", + "card": { + "title": "C: drive usage", + "icon": "hard-drive", + "template": { + "kind": "chart", + "chart": "stacked", + "suffix": " GB", + "total": 906, + "remainderLabel": "Free", + "items": [ + { "label": "WeChat data", "value": 62.7, "tone": "warning", "hint": "Clean up inside WeChat; do not delete the folder" }, + { "label": "BlueStacks", "value": 20.5, "hint": "Remove unused instances in its multi-instance manager" }, + { "label": "Caches and temp files", "value": 10.25, "hint": "Temp 4.44 + npm 4.12 + uv/pip 1.69" }, + { "label": "Other used", "value": 218.55 } + ], + "caption": "Nothing was deleted. Review the highlighted items first." + } + } + }, + "notes": "Set total to the capacity and list only the used parts: the uncovered rest is drawn as remainderLabel and the header shows used of total. At most 6 items, so merge small parts into one item and keep their detail in its hint. Write labels, hints, and the caption in the user's language." + }, + { + "id": "ranked-breakdown", + "useWhen": "Several comparable amounts the user will compare or act on: largest folders or caches, cleanup candidates, slowest tests, cost per service, errors per endpoint.", + "call": { + "action": "create", + "card": { + "title": "Cleanup candidates", + "icon": "chart", + "template": { + "kind": "chart", + "chart": "bar", + "suffix": " GB", + "items": [ + { "label": "Temp files", "value": 4.44, "hint": "Safe to clear with Disk Cleanup" }, + { "label": "npm cache", "value": 4.12, "hint": "npm cache clean --force" }, + { "label": "uv / pip cache", "value": 1.69, "hint": "uv cache clean; pip cache purge" } + ], + "caption": "Review each item before deleting anything." + } + } + }, + "notes": "Order items from largest to smallest. Put one short action per item in hint and one sentence that applies to all in caption; this replaces a note full of numbers. Set total to draw each bar as a share of a whole, such as the disk capacity." + }, + { + "id": "result-split", + "useWhen": "A count splits into a few outcomes or categories: test results, issue states, traffic by source, votes.", + "call": { + "action": "create", + "card": { + "title": "Test run", + "icon": "pie-chart", + "template": { + "kind": "chart", + "chart": "donut", + "items": [ + { "label": "Passed", "value": 182, "tone": "success" }, + { "label": "Failed", "value": 3, "tone": "danger" }, + { "label": "Skipped", "value": 12, "tone": "neutral" } + ] + } + } + }, + "notes": "Donut and stacked charts take at most 6 items. Use tone only where it carries meaning (success, warning, danger); without tone the parts use shades of the card accent." + }, { "id": "scheduled-job", "useWhen": "You created a Cron job and the user benefits from seeing the next run and last result at a glance.", @@ -581,7 +674,35 @@ fn presets() -> Value { ] } } - } + }, + "notes": "For the coming days or hours, use the weather-week chart preset instead of listing each value." + }, + { + "id": "weather-week", + "useWhen": "The user plans around the weather over the next days, or another value per day or period should be compared side by side.", + "call": { + "action": "create", + "card": { + "title": "Shanghai highs this week", + "icon": "sun", + "dataSource": { + "kind": "http", + "url": "https://api.open-meteo.com/v1/forecast?latitude=31.23&longitude=121.47&daily=temperature_2m_max&timezone=auto&forecast_days=7", + "intervalSeconds": 3600 + }, + "template": { + "kind": "chart", + "chart": "column", + "itemsPath": "/daily/temperature_2m_max", + "labelsPath": "/daily/time", + "labelFormat": "weekday", + "format": "number", + "digits": 0, + "suffix": "°" + } + } + }, + "notes": "itemsPath may point at an array of numbers (with labelsPath at a parallel array of labels), an array of [label, value] pairs, or an array of objects read with labelKey, valueKey, and hintKey (default label, value, hint)." }, { "id": "stock-quote", @@ -609,7 +730,35 @@ fn presets() -> Value { } } }, - "notes": "For derived values such as percent change, use a command source that prints the computed JSON, then bind with signTone: true." + "notes": "For derived values such as percent change, use a command source that prints the computed JSON, then bind with signTone: true. For the price history, use the stock-trend chart preset." + }, + { + "id": "stock-trend", + "useWhen": "The user follows how a value moves over time: a price history, response times, build durations, daily signups.", + "call": { + "action": "create", + "card": { + "title": "AAPL · 1 month", + "icon": "trending-up", + "link": "https://finance.yahoo.com/quote/AAPL", + "dataSource": { + "kind": "http", + "url": "https://query1.finance.yahoo.com/v8/finance/chart/AAPL?interval=1d&range=1mo", + "headers": { "User-Agent": "Mozilla/5.0" }, + "intervalSeconds": 900 + }, + "template": { + "kind": "chart", + "chart": "line", + "itemsPath": "/chart/result/0/indicators/quote/0/close", + "labelsPath": "/chart/result/0/timestamp", + "labelFormat": "date", + "format": "currency", + "currency": "USD" + } + } + }, + "notes": "A line chart shows the latest value, the change since the first point, and the range. Points whose value is null are skipped; Unix timestamps and ISO-8601 strings both work with labelFormat." }, { "id": "task-progress", @@ -644,11 +793,12 @@ fn presets() -> Value { }, { "id": "note", - "useWhen": "Short reference info the user will need again soon (credentials location, port, command to run).", + "useWhen": "Short non-numeric reference info the user will need again soon (credentials location, URL, command to run).", "call": { "action": "create", "card": { "title": "Local dev URLs", "icon": "note", "template": { "kind": "note", "text": "Web: http://localhost:5173\nAPI: http://localhost:8080" } } - } + }, + "notes": "Not for summaries of measured amounts: when the text would list sizes, counts, shares, or prices, use a chart (breakdowns, proportions, trends) or metrics (a few separate values) instead." }, { "id": "pull-request-list", @@ -698,10 +848,16 @@ impl Tool for DesktopCardTool { Ok(format!( r#"Show small persistent cards on the user's desktop, outside the OpenBitFun window (like OS widgets). Cards survive app restarts, keep their position, and can be dragged, hidden, or deleted by the user. -Use a card whenever the user would otherwise have to come back and check on something: a reminder after some time, a scheduled job, a pull request's review/CI state, a deployment or long task's progress, weather, a stock price, a checklist of manual steps, or reference info needed again soon. Create one when the user asks, and also proactively when a task naturally produces something worth glancing at later. Prefer updating an existing card (list first) over creating near-duplicates, and delete cards that are no longer useful. +Use a card whenever the user would otherwise have to come back and check on something: a reminder after some time, a scheduled job, a pull request's review/CI state, a deployment or long task's progress, weather, a stock price, a checklist of manual steps, the result of a scan or analysis (disk usage, cleanup candidates, test results, costs), or reference info needed again soon. Create one when the user asks, and also proactively when a task naturally produces something worth glancing at later. Prefer updating an existing card (list first) over creating near-duplicates, and delete cards that are no longer useful. + +Choosing a template. A card is read in a second, so let the shape of the information pick the template and show numbers visually instead of as prose: +- Several amounts that compare or add up (sizes, counts, durations, costs, shares, scores): chart. bar ranks a breakdown; stacked or donut shows parts of a whole (set total to the capacity to show used against free); column compares a few periods or categories; line shows a trend over time. +- A few separate values read individually (state, price, temperature, counts of different things): metrics. One value moving toward a goal: progress. +- note only for text without measured amounts (a URL, a command, where something is). Never write a summary of numbers into a note. Put the numbers in a chart or metrics, and keep advice to one short hint per item or one caption sentence. +- When a result has both a whole and its parts (for example disk usage and the largest folders), one stacked chart with total usually covers both. Actions: -- presets: return ready-to-use recipes (countdown, scheduled job, PR status, PR list, repository stats, weather, stock, progress, checklist, note, custom HTML). Call it when unsure how to build a card; preset commands are already written for the shell on this computer. +- presets: return ready-to-use recipes (countdown, usage breakdown, ranked breakdown, result split, scheduled job, PR status, PR list, repository stats, weather, weekly forecast chart, stock, price trend, progress, checklist, note, custom HTML). Call it when unsure how to build a card; preset commands are already written for the shell on this computer. - create: requires card. Shows the card and returns its cardId. With a dataSource, the result already contains the first fetch: the value, any fetch error, stderr output, and template paths that resolved to nothing. Read it instead of calling get or refresh again. - update: requires cardId and patch. Only provided fields change; null clears optional fields. patch.dataMerge shallow-merges into data (null removes a key). A changed dataSource is fetched before the result returns. - list / get: inspect cards, including the latest fetched data or fetch error and checklist state. @@ -714,11 +870,12 @@ Card fields: title (required), subtitle, icon ({icons}), accent (neutral|accent| Templates (template.kind). Every template field, including items and layout, goes inside the template object; data and dataSource only supply the values that paths read: - countdown: durationSeconds | targetAt (ISO-8601 with offset) | targetAtMs, optional message, notify (default true: system notification when due). - checklist: items [{{id, text, done}}]. The user can tick items. -- note: text (plain text, newlines kept). +- note: text (plain text, newlines kept). Not for measured amounts; see Choosing a template. - progress: value/max or valuePath/maxPath, optional status/statusPath. - metrics: items (1-12) [{{label, value | path, format (text|number|percent|currency|datetime|relativeTime), digits, currency, prefix, suffix, tone, toneMap {{"VALUE": tone}}, signTone, hint | hintPath, emphasis}}], layout list|grid. +- chart: chart ({chart_styles}), then either items [{{label, value, tone, hint}}] or itemsPath (JSON Pointer to an array in the card data: numbers with labelsPath at a parallel label array, [label, value] pairs, or objects read with labelKey/valueKey/hintKey, default label/value/hint). Optional total | totalPath (the whole the items belong to; the uncovered rest is drawn as remainderLabel), format (number|percent|currency), digits, currency, prefix, suffix (units such as " GB"), labelFormat (text|date|time|weekday|datetime) and caption (one short sentence under the chart). Values are non-negative except on line charts. Order bar items largest first. Longer bound arrays keep their first items, except that line charts keep the latest points and donut or stacked charts merge the rest into one Other part. - cronJob: jobId of a Cron job; shows next run and last result live. -- custom: html (body markup with inline