PKU harness
Concepts

Dashboard widgets and data

There are two kinds of dashboard widget. The agent builds some from a generative-UI spec plus bindings to read tools, and plugins provide others as native components. The host handles refreshing, with one cache shared by all of them.

The dashboard plugin provides the Dashboard tab. The tab is an ordered list of widgets, such as your campus-card balance, your next class and the next shuttle bus.

You can tell the agent in a conversation which numbers you want to see, for example "show my card balance and the next shuttle", and it builds or edits the dashboard. You can also long-press a widget to move it, resize it or delete it, and you can add widgets that plugins provide from the widget gallery.

The two kinds of widget

There are two kinds of widget on the dashboard, and they can be mixed freely.

An AI-built widget is described by { spec, bindings }. The agent decides how it looks by writing a generative-UI spec with dashboard.upsertWidget. Its data comes from bindings: each binding maps a path in the state to a read tool.

A plugin-provided widget is described by { source: { plugin, widget, config? } }. It is the plugin's own native component, declared in contributes.widgets. The dashboard wraps it in an error boundary, so one failing widget doesn't break the others. The component gets its data by calling host.useToolData(...).

When the agent builds a dashboard, it looks at dashboard.catalog first. If a plugin widget fits, it places that widget (dashboard.addPluginWidget). It only builds its own widget when no plugin widget fits.

For both kinds, the dashboard draws the frame around the widget. The frame contains the title, how fresh the data is, and the long-press menu.

Widget model

type Widget = {
  id: string;
  title: string;
  size: 'sm' | 'md' | 'lg';           // sm = half-width tile, md = full width, lg = full-width hero
  spec: Record<string, unknown>;      // json-render spec (empty for plugin widgets)
  bindings: Record<string, Binding>;  // JSON pointer in state → data source
  source?: { plugin: string; widget: string; config?: Record<string, unknown> };
  position: number;
  createdAt: number;
  updatedAt: number;
};

type Binding = {
  tool: string;            // read-only tool id, e.g. "calendar.today"
  args?: Record<string, unknown>;
  path?: string;           // dot path into the result; empty = whole result
  ttlMinutes?: number;     // 0 – 1440, default 5
};

There are three sizes. sm is a half-width tile, md fills the width, and lg is a full-width large card.

Here is an AI-built widget that shows today's classes. Its bindings map /today in the state to the timetable.today tool, cached for 1 minute.

{
  "title": "下一节课",
  "size": "md",
  "spec": {
    "root": "col",
    "elements": {
      "col": { "type": "Column", "props": { "gap": "sm" }, "children": ["h", "t"] },
      "h": { "type": "Heading", "props": { "text": { "$template": "今天共 ${/today/count} 节课" }, "size": "md" } },
      "t": { "type": "Text", "props": { "text": { "$template": "下一节:${/today/next/name} ${/today/next/time}" }, "tone": "muted", "size": null } }
    }
  },
  "bindings": { "/today": { "tool": "timetable.today", "ttlMinutes": 1 } }
}

How widgets are refreshed

The host takes care of refreshing widgets, so plugins don't have to.

Every read-tool result is kept in one store, keyed by tool and arguments. AI-built widgets, plugin widgets, plugin screens and scheduled checks all share this store. If you know TanStack Query, this is the same model, called stale-while-revalidate: the cached value is shown first while a fresh one is fetched in the background. The rules are as follows.

  • The last value is kept. After a cold start, the numbers appear immediately, labelled with their age, for example "3 分钟前" (3 minutes ago). You only see a loading skeleton when nothing has ever been fetched.
  • A value goes stale once its time to live (TTL) has passed. The default TTL is 5 minutes. A stale query is fetched again only on the focused screen: when the screen gains focus, when the app comes back to the foreground, and on a timer while the screen is visible. Hidden tabs and the app in the background never poll.
  • Pull-to-refresh forces every query on the screen to refresh. Identical requests made at the same time share one promise, so they are sent only once.
  • If a refresh fails, the last value stays and is marked, for example "刷新失败 · 10 分钟前" (refresh failed, 10 minutes ago).
  • Scheduled checks write into the same store. A balance check in the background therefore also updates the balance widget on the dashboard.

For this reason, a plugin widget must not poll on its own timer. Load data with host.useToolData, and choose a TTL that matches how fast the data changes. Balances suit 5–15 minutes, and data that changes slowly suits 60 minutes or more.

Writing a plugin widget

This plugin widget shows how crowded the canteens are. It reads the canteen.crowd tool with host.useToolData and a TTL of 2 minutes. At the small size it lists 3 canteens, otherwise 6.

import type { PluginWidgetProps } from '@pku-harness/sdk';
import { View } from 'react-native';

type Crowd = { name: string; percent: number }[];

export function CrowdWidget({ host, size }: PluginWidgetProps) {
  const { Text, Skeleton } = host.components;
  const t = host.useTheme();
  const data = host.useToolData<{ canteens: Crowd }>('canteen.crowd', {}, { ttlMinutes: 2 });
  if (data.status === 'loading') return <Skeleton width="60%" height={20} />;
  if (!data.value) return <Text tone="muted">{data.error ?? '暂无数据'}</Text>;
  const list = data.value.canteens.slice(0, size === 'sm' ? 3 : 6);
  return (
    <View style={{ gap: t.spacing.xs }}>
      {list.map((c) => (
        <Text key={c.name} variant="callout">{`${c.name} ${c.percent}%`}</Text>
      ))}
    </View>
  );
}

Then register the component under contributes.widgets and give it a description written for the agent. The agent reads this description when it chooses widgets. See UI contributions.

On this page