portable-agent-layer 0.72.0 → 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +10 -7
  2. package/src/cli/index.ts +21 -2
  3. package/src/cli/server.ts +33 -0
  4. package/src/hooks/handlers/agenda.ts +195 -7
  5. package/src/hooks/handlers/update-check.ts +1 -1
  6. package/src/hooks/lib/agenda-store.ts +2 -0
  7. package/src/hooks/lib/goal-links.ts +83 -0
  8. package/src/hooks/lib/models.ts +1 -1
  9. package/src/hooks/lib/projects.ts +5 -0
  10. package/src/hooks/lib/settings.ts +2 -0
  11. package/src/hooks/lib/telos-goals.ts +8 -2
  12. package/src/hooks/lib/token-usage.ts +2 -0
  13. package/src/tools/agent/project.ts +22 -20
  14. package/src/tools/control-room/attention.ts +146 -0
  15. package/src/tools/control-room/data.ts +76 -5
  16. package/src/tools/control-room/detail.ts +158 -0
  17. package/src/tools/control-room/matrix.ts +91 -19
  18. package/src/tools/control-room/prefs.ts +96 -0
  19. package/src/tools/control-room/server-config.ts +8 -0
  20. package/src/tools/control-room/server.ts +196 -11
  21. package/src/tools/control-room/snooze.ts +65 -0
  22. package/src/tools/control-room/static.ts +68 -0
  23. package/src/tools/control-room/ui/app.tsx +196 -50
  24. package/src/tools/control-room/ui/attention-bell.tsx +118 -0
  25. package/src/tools/control-room/ui/components/README.md +18 -0
  26. package/src/tools/control-room/ui/components/badge.tsx +38 -0
  27. package/src/tools/control-room/ui/components/button.tsx +43 -0
  28. package/src/tools/control-room/ui/components/card.tsx +45 -0
  29. package/src/tools/control-room/ui/components/input.tsx +24 -0
  30. package/src/tools/control-room/ui/components/label.tsx +18 -0
  31. package/src/tools/control-room/ui/components/popover.tsx +37 -0
  32. package/src/tools/control-room/ui/components/separator.tsx +25 -0
  33. package/src/tools/control-room/ui/components/skeleton.tsx +14 -0
  34. package/src/tools/control-room/ui/components/switch.tsx +27 -0
  35. package/src/tools/control-room/ui/components/table.tsx +60 -0
  36. package/src/tools/control-room/ui/components/toggle-group.tsx +38 -0
  37. package/src/tools/control-room/ui/dist/assets/index-BoQjFpzV.js +49 -0
  38. package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +2 -0
  39. package/src/tools/control-room/ui/dist/index.html +19 -0
  40. package/src/tools/control-room/ui/format.ts +0 -12
  41. package/src/tools/control-room/ui/frame.tsx +125 -0
  42. package/src/tools/control-room/ui/index.html +2 -2
  43. package/src/tools/control-room/ui/lib/api.ts +27 -0
  44. package/src/tools/control-room/ui/lib/cn.ts +6 -0
  45. package/src/tools/control-room/ui/lib/write.ts +43 -0
  46. package/src/tools/control-room/ui/package.json +28 -0
  47. package/src/tools/control-room/ui/parts.tsx +235 -0
  48. package/src/tools/control-room/ui/screens/log.tsx +274 -0
  49. package/src/tools/control-room/ui/screens/project-detail.tsx +302 -0
  50. package/src/tools/control-room/ui/screens/projects.tsx +128 -0
  51. package/src/tools/control-room/ui/screens/settings.tsx +205 -0
  52. package/src/tools/control-room/ui/screens/today.tsx +391 -0
  53. package/src/tools/control-room/ui/theme.css +127 -0
  54. package/src/tools/control-room/ui/vite.config.ts +36 -0
  55. package/src/tools/control-room/writes.ts +106 -0
  56. package/src/tools/ledger/outcomes.ts +9 -0
  57. package/src/tools/ledger/query.ts +2 -0
  58. package/src/tools/ledger/view.ts +31 -10
  59. package/src/tools/lib/handoff-note.ts +14 -0
  60. package/src/tools/lib/project-isc.ts +61 -0
  61. package/src/tools/control-room/ui/agenda.tsx +0 -43
  62. package/src/tools/control-room/ui/agents.tsx +0 -67
  63. package/src/tools/control-room/ui/app.css +0 -857
  64. package/src/tools/control-room/ui/board.tsx +0 -82
  65. package/src/tools/control-room/ui/handoffs.tsx +0 -37
  66. package/src/tools/control-room/ui/ledger.tsx +0 -137
  67. package/src/tools/control-room/ui/matrix.tsx +0 -117
  68. package/src/tools/control-room/ui/panel.tsx +0 -60
  69. package/src/tools/control-room/ui/signal.tsx +0 -161
@@ -0,0 +1,205 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { ControlRoomPrefs } from "../../prefs";
3
+ import type { ServerStatus } from "../../server";
4
+ import { Button } from "../components/button";
5
+ import { Input } from "../components/input";
6
+ import { Label } from "../components/label";
7
+ import { Switch } from "../components/switch";
8
+ import { Empty, Panel, Pending } from "../frame";
9
+ import { useLoaded } from "../lib/api";
10
+ import { setInstallSettings, setPrefs as writePrefs } from "../lib/write";
11
+
12
+ interface InstallSettings {
13
+ actor: string;
14
+ timezone: string;
15
+ }
16
+
17
+ function Field({
18
+ label,
19
+ hint,
20
+ value,
21
+ onChange,
22
+ }: {
23
+ label: string;
24
+ hint: string;
25
+ value: string;
26
+ onChange: (next: string) => void;
27
+ }) {
28
+ return (
29
+ <div className="flex flex-col gap-1">
30
+ <Label>{label}</Label>
31
+ <span className="text-[11px] text-neutral-600">{hint}</span>
32
+ <Input value={value} onChange={(e) => onChange(e.target.value)} />
33
+ </div>
34
+ );
35
+ }
36
+
37
+ function ThisInstall({ initial }: { initial: InstallSettings }) {
38
+ const [form, setForm] = useState(initial);
39
+ const [error, setError] = useState<string | null>(null);
40
+ const [saved, setSaved] = useState(false);
41
+ const dirty = form.actor !== initial.actor || form.timezone !== initial.timezone;
42
+
43
+ useEffect(() => {
44
+ if (dirty) setSaved(false);
45
+ }, [dirty]);
46
+
47
+ const save = () => {
48
+ void setInstallSettings({ ...form }).then((failure) => {
49
+ setError(failure);
50
+ setSaved(failure === null);
51
+ });
52
+ };
53
+
54
+ return (
55
+ <Panel title="This install">
56
+ <div className="flex flex-col gap-4">
57
+ <Field
58
+ label="actor"
59
+ hint="who caused a record — this travels with an export"
60
+ value={form.actor}
61
+ onChange={(actor) => setForm({ ...form, actor })}
62
+ />
63
+ <Field
64
+ label="timezone"
65
+ hint="how dates are read, e.g. Europe/Budapest"
66
+ value={form.timezone}
67
+ onChange={(timezone) => setForm({ ...form, timezone })}
68
+ />
69
+ {error && (
70
+ <p className="border-l-2 border-alarm bg-alarm/10 px-3 py-2 text-[12px] text-alarm">
71
+ {error}
72
+ </p>
73
+ )}
74
+ <div className="flex items-center gap-3">
75
+ <Button variant="primary" disabled={!dirty} onClick={save}>
76
+ Save
77
+ </Button>
78
+ {saved && <span className="text-[12px] text-neutral-600">saved</span>}
79
+ </div>
80
+ </div>
81
+ </Panel>
82
+ );
83
+ }
84
+
85
+ const ATTENTION_LABELS: Record<string, string> = {
86
+ refusals: "A hook blocked a call, or you denied one",
87
+ waiting: "A handoff left a question for you",
88
+ unranked: "A project has no purpose on record",
89
+ };
90
+
91
+ const THRESHOLDS = [
92
+ { key: "quietAfterDays", label: 'a project is "gone quiet" after' },
93
+ { key: "urgentWithinDays", label: "a dated step is urgent within" },
94
+ ] as const;
95
+
96
+ function Ranking({ initial }: { initial: ControlRoomPrefs }) {
97
+ const [prefs, setPrefs] = useState(initial);
98
+ const [error, setError] = useState<string | null>(null);
99
+
100
+ const save = (update: Record<string, unknown>) => {
101
+ void writePrefs(update).then((failure) => {
102
+ setError(failure);
103
+ if (!failure) setPrefs({ ...prefs, ...update } as ControlRoomPrefs);
104
+ });
105
+ };
106
+
107
+ return (
108
+ <>
109
+ <Panel title="Ranking">
110
+ <div className="flex flex-col gap-4">
111
+ {THRESHOLDS.map(({ key, label }) => (
112
+ <div key={key} className="flex flex-col gap-1">
113
+ <Label>{label}</Label>
114
+ <div className="flex items-center gap-2">
115
+ <Input
116
+ type="number"
117
+ min={1}
118
+ max={365}
119
+ className="w-20"
120
+ value={prefs[key]}
121
+ onChange={(e) => {
122
+ const days = Number(e.target.value);
123
+ setPrefs({ ...prefs, [key]: days });
124
+ if (Number.isInteger(days) && days > 0) save({ [key]: days });
125
+ }}
126
+ />
127
+ <span className="text-[12px] text-neutral-700">days</span>
128
+ </div>
129
+ </div>
130
+ ))}
131
+ <div className="flex items-center justify-between gap-3 border-t border-divider pt-3 text-[12.5px]">
132
+ <span>rank stated goals in the grid, not just projects</span>
133
+ <Switch
134
+ aria-label="rank stated goals in the grid"
135
+ checked={prefs.rankGoals}
136
+ onCheckedChange={(rankGoals) => save({ rankGoals })}
137
+ />
138
+ </div>
139
+ {error && (
140
+ <p className="border-l-2 border-alarm bg-alarm/10 px-3 py-2 text-[12px] text-alarm">
141
+ {error}
142
+ </p>
143
+ )}
144
+ </div>
145
+ </Panel>
146
+ <Panel title="Attention">
147
+ <p className="mb-3 text-[12px] text-neutral-700">
148
+ What lights the bell. Each source is a fact PAL already holds — nothing here
149
+ asks a model.
150
+ </p>
151
+ <div className="flex flex-col">
152
+ {Object.entries(prefs.attention).map(([source, on]) => (
153
+ <div
154
+ key={source}
155
+ className="flex items-center justify-between gap-3 border-t border-divider py-2.5 text-[12.5px] first:border-t-0"
156
+ >
157
+ <span>{ATTENTION_LABELS[source] ?? source}</span>
158
+ <Switch
159
+ aria-label={ATTENTION_LABELS[source] ?? source}
160
+ checked={on}
161
+ onCheckedChange={(next) => save({ attention: { [source]: next } })}
162
+ />
163
+ </div>
164
+ ))}
165
+ </div>
166
+ </Panel>
167
+ </>
168
+ );
169
+ }
170
+
171
+ export function Settings() {
172
+ const settings = useLoaded<InstallSettings>("/api/settings");
173
+ const prefs = useLoaded<ControlRoomPrefs>("/api/prefs");
174
+ const status = useLoaded<ServerStatus>("/api/status");
175
+
176
+ if (settings.state !== "ready") return <Pending value={settings} />;
177
+ return (
178
+ <div className="grid items-start gap-6 md:grid-cols-2">
179
+ <ThisInstall initial={settings.data} />
180
+ {prefs.state === "ready" && <Ranking initial={prefs.data} />}
181
+ <Panel title="Where it runs">
182
+ {status.state === "ready" ? (
183
+ <dl className="m-0 grid grid-cols-[110px_1fr] gap-y-1.5 text-[12.5px]">
184
+ <dt className="text-neutral-600">machine</dt>
185
+ <dd className="m-0">{status.data.machine}</dd>
186
+ <dt className="text-neutral-600">port</dt>
187
+ <dd className="m-0 tabular-nums">{status.data.port}</dd>
188
+ <dt className="text-neutral-600">pid</dt>
189
+ <dd className="m-0 tabular-nums">{status.data.pid}</dd>
190
+ <dt className="text-neutral-600">ledger</dt>
191
+ <dd className="m-0">{status.data.ledgerFiles} file(s)</dd>
192
+ <dt className="text-neutral-600">up since</dt>
193
+ <dd className="m-0">{status.data.startedAt}</dd>
194
+ </dl>
195
+ ) : (
196
+ <Empty>not answering</Empty>
197
+ )}
198
+ <p className="mt-3 text-[11px] text-neutral-600">
199
+ Loopback only. The machine label is set with <code>pal cli machine</code>, and
200
+ stays on this machine.
201
+ </p>
202
+ </Panel>
203
+ </div>
204
+ );
205
+ }
@@ -0,0 +1,391 @@
1
+ import { useState } from "react";
2
+ import type { AgendaView, HandoffCard, SignalView, SummaryView } from "../../data";
3
+ import type { Matrix, MatrixItem } from "../../matrix";
4
+ import { Badge } from "../components/badge";
5
+ import {
6
+ Table,
7
+ TableBody,
8
+ TableCell,
9
+ TableHead,
10
+ TableHeader,
11
+ TableRow,
12
+ } from "../components/table";
13
+ import { tenths } from "../format";
14
+ import { Empty, Panel, Pending } from "../frame";
15
+ import { type Loaded, useLoaded } from "../lib/api";
16
+ import { cn } from "../lib/cn";
17
+ import { GoalsList, HandoffList, ItemCard, MovesList, ReasonTags } from "../parts";
18
+
19
+ export const LAYOUTS = [
20
+ { value: "matrix", label: "matrix" },
21
+ { value: "list", label: "list" },
22
+ { value: "briefing", label: "briefing" },
23
+ ] as const;
24
+
25
+ export type Layout = (typeof LAYOUTS)[number]["value"];
26
+
27
+ export function isLayout(value: string | null): value is Layout {
28
+ return LAYOUTS.some((l) => l.value === value);
29
+ }
30
+
31
+ const QUADRANTS = [
32
+ { id: "now", title: "Do now", note: "matters and cannot wait", lit: true },
33
+ {
34
+ id: "plan",
35
+ title: "Give it a slot",
36
+ note: "matters, nothing forcing it",
37
+ lit: false,
38
+ },
39
+ {
40
+ id: "noise",
41
+ title: "Loud, not load-bearing",
42
+ note: "pressing but serves nothing",
43
+ lit: false,
44
+ },
45
+ { id: "later", title: "Let it sit", note: "neither", lit: false },
46
+ ] as const;
47
+
48
+ const FORTNIGHT_DAYS = 14;
49
+
50
+ function goalsOf(matrix: Matrix): MatrixItem[] {
51
+ return [...matrix.now, ...matrix.plan, ...matrix.noise, ...matrix.later].filter(
52
+ (i) => i.kind === "goal"
53
+ );
54
+ }
55
+
56
+ function rankedOf(matrix: Matrix): { item: MatrixItem; quadrant: string }[] {
57
+ return QUADRANTS.flatMap(({ id, title }) =>
58
+ (matrix[id] as MatrixItem[]).map((item) => ({ item, quadrant: title }))
59
+ );
60
+ }
61
+
62
+ function Quadrant({
63
+ title,
64
+ note,
65
+ lit,
66
+ items,
67
+ onSaved,
68
+ compact,
69
+ }: {
70
+ title: string;
71
+ note: string;
72
+ lit: boolean;
73
+ items: MatrixItem[];
74
+ onSaved: () => void;
75
+ compact?: boolean;
76
+ }) {
77
+ return (
78
+ <div
79
+ className={cn(
80
+ "blueprint flex flex-col gap-2 p-4",
81
+ lit ? "bg-accent-100" : "bg-transparent",
82
+ compact ? "min-h-[120px]" : "min-h-[160px]"
83
+ )}
84
+ >
85
+ <div className="flex items-baseline justify-between gap-2">
86
+ <h3 className="m-0 text-[20px]">{title}</h3>
87
+ <span className="text-[11px] text-neutral-700">{note}</span>
88
+ </div>
89
+ {items.length === 0 ? (
90
+ <Empty>nothing here</Empty>
91
+ ) : (
92
+ items.map((item) => (
93
+ <ItemCard key={`${item.kind}:${item.id}`} item={item} onSaved={onSaved} />
94
+ ))
95
+ )}
96
+ </div>
97
+ );
98
+ }
99
+
100
+ function Fortnight() {
101
+ const view = useLoaded<SummaryView>(`/api/summary?days=${FORTNIGHT_DAYS}`);
102
+ const s = view.state === "ready" ? view.data : null;
103
+ return (
104
+ <Panel title={`Last ${FORTNIGHT_DAYS} days`}>
105
+ <dl className="m-0 grid grid-cols-3 gap-2">
106
+ <Figure n={s ? String(s.actions) : "—"} label="actions" />
107
+ <Figure n={s ? String(s.refusals) : "—"} label="refusals" />
108
+ <Figure
109
+ n={s?.rating === null || !s ? "—" : tenths(s.rating)}
110
+ label="rating, last 10"
111
+ />
112
+ </dl>
113
+ </Panel>
114
+ );
115
+ }
116
+
117
+ function Figure({ n, label }: { n: string; label: string }) {
118
+ return (
119
+ <div>
120
+ <dt className="font-heading text-[26px] leading-none font-semibold tabular-nums">
121
+ {n}
122
+ </dt>
123
+ <dd className="m-0 text-[11px] text-neutral-600">{label}</dd>
124
+ </div>
125
+ );
126
+ }
127
+
128
+ function DueBadges() {
129
+ const signal = useLoaded<SignalView>("/api/signal");
130
+ if (signal.state !== "ready") return <Pending value={signal} />;
131
+ const due = Object.entries(signal.data.due).filter(([, b]) => b.state === "due");
132
+ if (due.length === 0) return <Empty>Nothing is due.</Empty>;
133
+ return (
134
+ <div className="flex flex-col gap-2">
135
+ {due.map(([name, badge]) => (
136
+ <div key={name} className="text-[12px]">
137
+ <Badge variant="accent">{name}</Badge>
138
+ <span className="ml-2 text-neutral-700">{badge.detail}</span>
139
+ </div>
140
+ ))}
141
+ </div>
142
+ );
143
+ }
144
+
145
+ function Sidebar({ matrix, handoffs }: { matrix: Matrix; handoffs: HandoffCard[] }) {
146
+ return (
147
+ <aside className="flex flex-col gap-6">
148
+ <Panel title="Where you left off">
149
+ <HandoffList cards={handoffs} />
150
+ </Panel>
151
+ <Panel title="Goals">
152
+ <GoalsList goals={goalsOf(matrix)} />
153
+ </Panel>
154
+ <Fortnight />
155
+ <Panel title="Needs a run">
156
+ <DueBadges />
157
+ </Panel>
158
+ </aside>
159
+ );
160
+ }
161
+
162
+ function MatrixLayout({
163
+ matrix,
164
+ handoffs,
165
+ moves,
166
+ onSaved,
167
+ }: {
168
+ matrix: Matrix;
169
+ handoffs: HandoffCard[];
170
+ moves: AgendaView["moves"];
171
+ onSaved: () => void;
172
+ }) {
173
+ return (
174
+ <div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_300px]">
175
+ <div>
176
+ <div className="mb-6 grid gap-px border border-divider bg-divider md:grid-cols-3">
177
+ {moves.map((move, i) => (
178
+ <div
179
+ key={move.move}
180
+ className="grid grid-cols-[26px_1fr] gap-2 bg-bg px-4 py-3"
181
+ >
182
+ <span className="font-heading text-[22px] leading-none font-semibold text-accent">
183
+ {String(i + 1).padStart(2, "0")}
184
+ </span>
185
+ <span>
186
+ <span className="block font-medium text-pretty">{move.move}</span>
187
+ <span className="text-[12px] text-neutral-700">{move.because}</span>
188
+ </span>
189
+ </div>
190
+ ))}
191
+ </div>
192
+ <div className="grid grid-cols-[18px_minmax(0,1fr)_minmax(0,1fr)]">
193
+ <div />
194
+ <div className="eyebrow pb-1.5 pl-3">urgent</div>
195
+ <div className="eyebrow pb-1.5 pl-3">not urgent</div>
196
+ <div className="eyebrow [writing-mode:vertical-rl] rotate-180 text-center">
197
+ important
198
+ </div>
199
+ {QUADRANTS.slice(0, 2).map((q) => (
200
+ <Quadrant
201
+ key={q.id}
202
+ {...q}
203
+ items={matrix[q.id] as MatrixItem[]}
204
+ onSaved={onSaved}
205
+ />
206
+ ))}
207
+ <div className="eyebrow [writing-mode:vertical-rl] rotate-180 text-center">
208
+ not important
209
+ </div>
210
+ {QUADRANTS.slice(2).map((q) => (
211
+ <Quadrant
212
+ key={q.id}
213
+ {...q}
214
+ items={matrix[q.id] as MatrixItem[]}
215
+ onSaved={onSaved}
216
+ />
217
+ ))}
218
+ </div>
219
+ </div>
220
+ <Sidebar matrix={matrix} handoffs={handoffs} />
221
+ </div>
222
+ );
223
+ }
224
+
225
+ function ListLayout({
226
+ matrix,
227
+ handoffs,
228
+ moves,
229
+ }: {
230
+ matrix: Matrix;
231
+ handoffs: HandoffCard[];
232
+ moves: AgendaView["moves"];
233
+ onSaved: () => void;
234
+ }) {
235
+ return (
236
+ <div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_260px]">
237
+ <div className="blueprint overflow-x-auto bg-bg">
238
+ <Table className="min-w-[720px] text-[13px]">
239
+ <TableHeader>
240
+ <tr>
241
+ {["Item", "Placed", "Why", "Next"].map((column) => (
242
+ <TableHead key={column}>{column}</TableHead>
243
+ ))}
244
+ </tr>
245
+ </TableHeader>
246
+ <TableBody>
247
+ {rankedOf(matrix).map(({ item, quadrant }) => (
248
+ <TableRow key={`${item.kind}:${item.id}`}>
249
+ <TableCell>
250
+ <span className="font-heading text-[15px] font-semibold">
251
+ {item.label}
252
+ </span>
253
+ </TableCell>
254
+ <TableCell className="whitespace-nowrap">{quadrant}</TableCell>
255
+ <TableCell className="min-w-[200px]">
256
+ <ReasonTags item={item} />
257
+ </TableCell>
258
+ <TableCell className="max-w-[320px] text-[12px] text-neutral-800">
259
+ {item.detail}
260
+ </TableCell>
261
+ </TableRow>
262
+ ))}
263
+ </TableBody>
264
+ </Table>
265
+ </div>
266
+ <aside className="flex flex-col gap-6">
267
+ <Panel title="Three moves">
268
+ <MovesList moves={moves} />
269
+ </Panel>
270
+ <Panel title="Grid">
271
+ <div className="grid grid-cols-2 gap-1">
272
+ {QUADRANTS.map((q) => (
273
+ <div key={q.id} className="border border-divider p-2">
274
+ <span className="font-heading block text-[24px] leading-none font-semibold">
275
+ {(matrix[q.id] as MatrixItem[]).length}
276
+ </span>
277
+ <span className="eyebrow">{q.title}</span>
278
+ </div>
279
+ ))}
280
+ </div>
281
+ </Panel>
282
+ <Panel title="Where you left off">
283
+ <HandoffList cards={handoffs} />
284
+ </Panel>
285
+ </aside>
286
+ </div>
287
+ );
288
+ }
289
+
290
+ function BriefingLayout({
291
+ matrix,
292
+ handoffs,
293
+ moves,
294
+ onSaved,
295
+ }: {
296
+ matrix: Matrix;
297
+ handoffs: HandoffCard[];
298
+ moves: AgendaView["moves"];
299
+ onSaved: () => void;
300
+ }) {
301
+ return (
302
+ <div className="grid items-start gap-8 lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)]">
303
+ <div className="flex flex-col gap-8">
304
+ <div>
305
+ <h6 className="eyebrow mb-2">If you only do three things</h6>
306
+ <MovesList moves={moves} size="large" />
307
+ </div>
308
+ <div>
309
+ <h6 className="eyebrow mb-2">Where you left off</h6>
310
+ <HandoffList cards={handoffs} />
311
+ </div>
312
+ </div>
313
+ <div className="flex flex-col gap-6">
314
+ <div>
315
+ <h6 className="eyebrow mb-2">The grid</h6>
316
+ <div className="grid grid-cols-2 gap-1.5">
317
+ {QUADRANTS.map((q) => (
318
+ <Quadrant
319
+ key={q.id}
320
+ {...q}
321
+ compact
322
+ items={matrix[q.id] as MatrixItem[]}
323
+ onSaved={onSaved}
324
+ />
325
+ ))}
326
+ </div>
327
+ </div>
328
+ <Panel title="Goals">
329
+ <GoalsList goals={goalsOf(matrix)} />
330
+ </Panel>
331
+ </div>
332
+ </div>
333
+ );
334
+ }
335
+
336
+ function Heading({ agenda, matrix }: { agenda: AgendaView; matrix: Matrix }) {
337
+ const written = agenda.generatedAt
338
+ ? `agenda written ${agenda.ageHours ?? 0}h ago at session stop`
339
+ : "no agenda written yet";
340
+ return (
341
+ <div className="mb-5 flex flex-wrap items-baseline justify-between gap-4">
342
+ <div className="flex flex-wrap items-baseline gap-4">
343
+ <h1 className="m-0 text-[34px]">
344
+ {new Date().toLocaleDateString(undefined, {
345
+ weekday: "long",
346
+ day: "numeric",
347
+ month: "long",
348
+ })}
349
+ </h1>
350
+ <span
351
+ className={cn("text-[12px]", agenda.stale ? "text-alarm" : "text-neutral-700")}
352
+ >
353
+ {written} · {matrix.now.length} items need you
354
+ </span>
355
+ </div>
356
+ {matrix.unranked > 0 && (
357
+ <span className="text-[12px] text-neutral-700">
358
+ {matrix.unranked} project(s) with no purpose on record
359
+ </span>
360
+ )}
361
+ </div>
362
+ );
363
+ }
364
+
365
+ export function Today({ layout }: { layout: Layout }) {
366
+ const [nonce, setNonce] = useState(0);
367
+ const matrix = useLoaded<Matrix>(`/api/matrix?v=${nonce}`);
368
+ const agenda = useLoaded<AgendaView>("/api/agenda");
369
+ const handoffs = useLoaded<HandoffCard[]>("/api/handoffs");
370
+ const reload = () => setNonce((n) => n + 1);
371
+
372
+ const waiting: Loaded<unknown>[] = [matrix, agenda, handoffs];
373
+ const notReady = waiting.find((v) => v.state !== "ready");
374
+ if (notReady || matrix.state !== "ready") return <Pending value={notReady ?? matrix} />;
375
+ if (agenda.state !== "ready" || handoffs.state !== "ready") return null;
376
+
377
+ const shared = {
378
+ matrix: matrix.data,
379
+ handoffs: handoffs.data,
380
+ moves: agenda.data.moves,
381
+ onSaved: reload,
382
+ };
383
+ return (
384
+ <section>
385
+ <Heading agenda={agenda.data} matrix={matrix.data} />
386
+ {layout === "matrix" && <MatrixLayout {...shared} />}
387
+ {layout === "list" && <ListLayout {...shared} />}
388
+ {layout === "briefing" && <BriefingLayout {...shared} />}
389
+ </section>
390
+ );
391
+ }