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.
- package/package.json +10 -7
- package/src/cli/index.ts +21 -2
- package/src/cli/server.ts +33 -0
- package/src/hooks/handlers/agenda.ts +195 -7
- package/src/hooks/handlers/update-check.ts +1 -1
- package/src/hooks/lib/agenda-store.ts +2 -0
- package/src/hooks/lib/goal-links.ts +83 -0
- package/src/hooks/lib/models.ts +1 -1
- package/src/hooks/lib/projects.ts +5 -0
- package/src/hooks/lib/settings.ts +2 -0
- package/src/hooks/lib/telos-goals.ts +8 -2
- package/src/hooks/lib/token-usage.ts +2 -0
- package/src/tools/agent/project.ts +22 -20
- package/src/tools/control-room/attention.ts +146 -0
- package/src/tools/control-room/data.ts +76 -5
- package/src/tools/control-room/detail.ts +158 -0
- package/src/tools/control-room/matrix.ts +91 -19
- package/src/tools/control-room/prefs.ts +96 -0
- package/src/tools/control-room/server-config.ts +8 -0
- package/src/tools/control-room/server.ts +196 -11
- package/src/tools/control-room/snooze.ts +65 -0
- package/src/tools/control-room/static.ts +68 -0
- package/src/tools/control-room/ui/app.tsx +196 -50
- package/src/tools/control-room/ui/attention-bell.tsx +118 -0
- package/src/tools/control-room/ui/components/README.md +18 -0
- package/src/tools/control-room/ui/components/badge.tsx +38 -0
- package/src/tools/control-room/ui/components/button.tsx +43 -0
- package/src/tools/control-room/ui/components/card.tsx +45 -0
- package/src/tools/control-room/ui/components/input.tsx +24 -0
- package/src/tools/control-room/ui/components/label.tsx +18 -0
- package/src/tools/control-room/ui/components/popover.tsx +37 -0
- package/src/tools/control-room/ui/components/separator.tsx +25 -0
- package/src/tools/control-room/ui/components/skeleton.tsx +14 -0
- package/src/tools/control-room/ui/components/switch.tsx +27 -0
- package/src/tools/control-room/ui/components/table.tsx +60 -0
- package/src/tools/control-room/ui/components/toggle-group.tsx +38 -0
- package/src/tools/control-room/ui/dist/assets/index-BoQjFpzV.js +49 -0
- package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +2 -0
- package/src/tools/control-room/ui/dist/index.html +19 -0
- package/src/tools/control-room/ui/format.ts +0 -12
- package/src/tools/control-room/ui/frame.tsx +125 -0
- package/src/tools/control-room/ui/index.html +2 -2
- package/src/tools/control-room/ui/lib/api.ts +27 -0
- package/src/tools/control-room/ui/lib/cn.ts +6 -0
- package/src/tools/control-room/ui/lib/write.ts +43 -0
- package/src/tools/control-room/ui/package.json +28 -0
- package/src/tools/control-room/ui/parts.tsx +235 -0
- package/src/tools/control-room/ui/screens/log.tsx +274 -0
- package/src/tools/control-room/ui/screens/project-detail.tsx +302 -0
- package/src/tools/control-room/ui/screens/projects.tsx +128 -0
- package/src/tools/control-room/ui/screens/settings.tsx +205 -0
- package/src/tools/control-room/ui/screens/today.tsx +391 -0
- package/src/tools/control-room/ui/theme.css +127 -0
- package/src/tools/control-room/ui/vite.config.ts +36 -0
- package/src/tools/control-room/writes.ts +106 -0
- package/src/tools/ledger/outcomes.ts +9 -0
- package/src/tools/ledger/query.ts +2 -0
- package/src/tools/ledger/view.ts +31 -10
- package/src/tools/lib/handoff-note.ts +14 -0
- package/src/tools/lib/project-isc.ts +61 -0
- package/src/tools/control-room/ui/agenda.tsx +0 -43
- package/src/tools/control-room/ui/agents.tsx +0 -67
- package/src/tools/control-room/ui/app.css +0 -857
- package/src/tools/control-room/ui/board.tsx +0 -82
- package/src/tools/control-room/ui/handoffs.tsx +0 -37
- package/src/tools/control-room/ui/ledger.tsx +0 -137
- package/src/tools/control-room/ui/matrix.tsx +0 -117
- package/src/tools/control-room/ui/panel.tsx +0 -60
- package/src/tools/control-room/ui/signal.tsx +0 -161
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { useId, useState } from "react";
|
|
2
|
+
import { PAGE_OUTCOMES, type PageOutcome } from "../../../ledger/outcomes";
|
|
3
|
+
import type { LedgerView, LedgerViewRow } from "../../../ledger/view";
|
|
4
|
+
import { Badge } from "../components/badge";
|
|
5
|
+
import { Button } from "../components/button";
|
|
6
|
+
import { Input, NativeSelect } from "../components/input";
|
|
7
|
+
import { Label } from "../components/label";
|
|
8
|
+
import {
|
|
9
|
+
Table,
|
|
10
|
+
TableBody,
|
|
11
|
+
TableCell,
|
|
12
|
+
TableHead,
|
|
13
|
+
TableHeader,
|
|
14
|
+
TableRow,
|
|
15
|
+
} from "../components/table";
|
|
16
|
+
import { clock } from "../format";
|
|
17
|
+
import { Empty, Pending, Scroller } from "../frame";
|
|
18
|
+
import { type Loaded, useLoaded } from "../lib/api";
|
|
19
|
+
|
|
20
|
+
const MAX_ROWS = 200;
|
|
21
|
+
|
|
22
|
+
const COLUMNS = [
|
|
23
|
+
"when",
|
|
24
|
+
"machine",
|
|
25
|
+
"actor",
|
|
26
|
+
"action",
|
|
27
|
+
"target",
|
|
28
|
+
"change",
|
|
29
|
+
"outcome",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
export const LOG_KEYS = [
|
|
33
|
+
"project",
|
|
34
|
+
"runtime",
|
|
35
|
+
"machine",
|
|
36
|
+
"authority",
|
|
37
|
+
"outcome",
|
|
38
|
+
"since",
|
|
39
|
+
"until",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
export type LogFilter = Record<(typeof LOG_KEYS)[number], string>;
|
|
43
|
+
|
|
44
|
+
const AUTHORITIES = ["user", "agent"] as const;
|
|
45
|
+
const RUNTIMES = ["claude", "cursor", "codex", "copilot", "opencode", "unknown"] as const;
|
|
46
|
+
|
|
47
|
+
const EMPTY_FILTER: LogFilter = {
|
|
48
|
+
project: "",
|
|
49
|
+
runtime: "",
|
|
50
|
+
machine: "",
|
|
51
|
+
authority: "",
|
|
52
|
+
outcome: "",
|
|
53
|
+
since: "",
|
|
54
|
+
until: "",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** The machines a row could name are whatever the window actually contains. */
|
|
58
|
+
function machinesIn(view: Loaded<LedgerView>): string[] {
|
|
59
|
+
if (view.state !== "ready") return [];
|
|
60
|
+
return [...new Set(view.data.rows.map((r) => r.machine))].filter(Boolean).sort();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ledgerQuery(filter: LogFilter): string {
|
|
64
|
+
const params = new URLSearchParams();
|
|
65
|
+
for (const [key, value] of Object.entries(filter)) if (value) params.set(key, value);
|
|
66
|
+
const query = params.toString();
|
|
67
|
+
return query ? `/api/ledger?${query}` : "/api/ledger";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function outcomeVariant(outcome: string) {
|
|
71
|
+
if (outcome === "applied") return "neutral" as const;
|
|
72
|
+
if (outcome === "failed") return "outline" as const;
|
|
73
|
+
return "alarm" as const;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function Field({
|
|
77
|
+
label,
|
|
78
|
+
control,
|
|
79
|
+
}: {
|
|
80
|
+
label: string;
|
|
81
|
+
control: (id: string) => React.ReactNode;
|
|
82
|
+
}) {
|
|
83
|
+
const id = useId();
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex flex-col gap-1">
|
|
86
|
+
<Label htmlFor={id}>{label}</Label>
|
|
87
|
+
{control(id)}
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function Stats({ view }: { view: LedgerView }) {
|
|
93
|
+
const cells: { n: number; label: string }[] = [
|
|
94
|
+
{ n: view.stats.total, label: "actions" },
|
|
95
|
+
{ n: view.stats.refusals, label: "refusals" },
|
|
96
|
+
...PAGE_OUTCOMES.map((o: PageOutcome) => ({
|
|
97
|
+
n: view.stats.outcomes[o].total,
|
|
98
|
+
label: o,
|
|
99
|
+
})),
|
|
100
|
+
];
|
|
101
|
+
return (
|
|
102
|
+
<div className="mb-4 grid grid-cols-2 gap-px border border-divider bg-divider sm:grid-cols-3 lg:grid-cols-6">
|
|
103
|
+
{cells.map((cell) => (
|
|
104
|
+
<div key={cell.label} className="bg-bg px-3 py-2.5">
|
|
105
|
+
<div className="font-heading text-[26px] leading-none font-semibold tabular-nums">
|
|
106
|
+
{cell.n}
|
|
107
|
+
</div>
|
|
108
|
+
<div className="eyebrow">{cell.label}</div>
|
|
109
|
+
</div>
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A refusal carries the command it stopped; an edit carries its line counts. */
|
|
116
|
+
function Row({
|
|
117
|
+
r,
|
|
118
|
+
open,
|
|
119
|
+
onToggle,
|
|
120
|
+
}: {
|
|
121
|
+
r: LedgerViewRow;
|
|
122
|
+
open: boolean;
|
|
123
|
+
onToggle: () => void;
|
|
124
|
+
}) {
|
|
125
|
+
return (
|
|
126
|
+
<>
|
|
127
|
+
<TableRow className="cursor-pointer" onClick={onToggle}>
|
|
128
|
+
<TableCell className="whitespace-nowrap text-neutral-700">
|
|
129
|
+
{clock(r.ts)}
|
|
130
|
+
</TableCell>
|
|
131
|
+
<TableCell className="whitespace-nowrap">{r.machine}</TableCell>
|
|
132
|
+
<TableCell>
|
|
133
|
+
{r.actor}
|
|
134
|
+
<span className="block text-[11px] text-neutral-600">
|
|
135
|
+
{r.authority} · {r.runtime}
|
|
136
|
+
</span>
|
|
137
|
+
</TableCell>
|
|
138
|
+
<TableCell className="font-mono text-[11.5px]">{r.tool}</TableCell>
|
|
139
|
+
<TableCell title={r.target}>{r.target}</TableCell>
|
|
140
|
+
<TableCell className="whitespace-nowrap text-neutral-700">{r.change}</TableCell>
|
|
141
|
+
<TableCell>
|
|
142
|
+
<Badge variant={outcomeVariant(r.outcome)}>{r.outcome}</Badge>
|
|
143
|
+
{r.reason && (
|
|
144
|
+
<span className="block max-w-[220px] text-[11px] text-neutral-700">
|
|
145
|
+
{r.reason}
|
|
146
|
+
</span>
|
|
147
|
+
)}
|
|
148
|
+
</TableCell>
|
|
149
|
+
</TableRow>
|
|
150
|
+
{open && (r.command || r.reason) && (
|
|
151
|
+
<TableRow>
|
|
152
|
+
<TableCell colSpan={COLUMNS.length} className="bg-neutral-200">
|
|
153
|
+
<pre className="m-0 font-mono text-[11.5px] whitespace-pre-wrap">
|
|
154
|
+
{r.command ? `$ ${r.command}\n` : ""}
|
|
155
|
+
{r.reason ?? ""}
|
|
156
|
+
</pre>
|
|
157
|
+
</TableCell>
|
|
158
|
+
</TableRow>
|
|
159
|
+
)}
|
|
160
|
+
</>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function Log({
|
|
165
|
+
filter,
|
|
166
|
+
onFilter,
|
|
167
|
+
}: {
|
|
168
|
+
filter: LogFilter;
|
|
169
|
+
onFilter: (next: Partial<LogFilter>) => void;
|
|
170
|
+
}) {
|
|
171
|
+
const projects = useLoaded<{ slug: string }[]>("/api/projects");
|
|
172
|
+
const view = useLoaded<LedgerView>(ledgerQuery(filter));
|
|
173
|
+
const [expanded, setExpanded] = useState<string | null>(null);
|
|
174
|
+
|
|
175
|
+
const choices = (key: keyof LogFilter, values: readonly string[], all = "all") => (
|
|
176
|
+
<Field
|
|
177
|
+
label={key}
|
|
178
|
+
control={(id) => (
|
|
179
|
+
<NativeSelect
|
|
180
|
+
id={id}
|
|
181
|
+
value={filter[key]}
|
|
182
|
+
onChange={(e) => onFilter({ [key]: e.target.value })}
|
|
183
|
+
>
|
|
184
|
+
<option value="">{all}</option>
|
|
185
|
+
{values.map((value) => (
|
|
186
|
+
<option key={value} value={value}>
|
|
187
|
+
{value}
|
|
188
|
+
</option>
|
|
189
|
+
))}
|
|
190
|
+
</NativeSelect>
|
|
191
|
+
)}
|
|
192
|
+
/>
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<section className="flex min-h-0 flex-1 flex-col">
|
|
197
|
+
<div className="mb-4 flex flex-wrap items-end gap-3">
|
|
198
|
+
{choices(
|
|
199
|
+
"project",
|
|
200
|
+
projects.state === "ready" ? projects.data.map((p) => p.slug) : []
|
|
201
|
+
)}
|
|
202
|
+
{choices("runtime", RUNTIMES)}
|
|
203
|
+
{choices("machine", machinesIn(view), "both")}
|
|
204
|
+
{choices("authority", AUTHORITIES)}
|
|
205
|
+
{choices("outcome", PAGE_OUTCOMES)}
|
|
206
|
+
<Field
|
|
207
|
+
label="since"
|
|
208
|
+
control={(id) => (
|
|
209
|
+
<Input
|
|
210
|
+
id={id}
|
|
211
|
+
type="date"
|
|
212
|
+
value={filter.since}
|
|
213
|
+
onChange={(e) => onFilter({ since: e.target.value })}
|
|
214
|
+
/>
|
|
215
|
+
)}
|
|
216
|
+
/>
|
|
217
|
+
<Field
|
|
218
|
+
label="until"
|
|
219
|
+
control={(id) => (
|
|
220
|
+
<Input
|
|
221
|
+
id={id}
|
|
222
|
+
type="date"
|
|
223
|
+
value={filter.until}
|
|
224
|
+
onChange={(e) => onFilter({ until: e.target.value })}
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
227
|
+
/>
|
|
228
|
+
<Button variant="secondary" onClick={() => onFilter(EMPTY_FILTER)}>
|
|
229
|
+
reset
|
|
230
|
+
</Button>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<Pending value={view} />
|
|
234
|
+
{view.state === "ready" && (
|
|
235
|
+
<>
|
|
236
|
+
<Stats view={view.data} />
|
|
237
|
+
<div className="blueprint flex min-h-0 flex-1 flex-col bg-bg">
|
|
238
|
+
{view.data.rows.length === 0 ? (
|
|
239
|
+
<Empty>No actions in this window.</Empty>
|
|
240
|
+
) : (
|
|
241
|
+
<Scroller>
|
|
242
|
+
<Table className="min-w-[880px] text-[12.5px]">
|
|
243
|
+
<TableHeader>
|
|
244
|
+
<tr>
|
|
245
|
+
{COLUMNS.map((column) => (
|
|
246
|
+
<TableHead key={column}>{column}</TableHead>
|
|
247
|
+
))}
|
|
248
|
+
</tr>
|
|
249
|
+
</TableHeader>
|
|
250
|
+
<TableBody>
|
|
251
|
+
{view.data.rows.slice(0, MAX_ROWS).map((r) => (
|
|
252
|
+
<Row
|
|
253
|
+
key={r.id}
|
|
254
|
+
r={r}
|
|
255
|
+
open={expanded === r.id}
|
|
256
|
+
onToggle={() => setExpanded(expanded === r.id ? null : r.id)}
|
|
257
|
+
/>
|
|
258
|
+
))}
|
|
259
|
+
</TableBody>
|
|
260
|
+
</Table>
|
|
261
|
+
</Scroller>
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
{view.data.rows.length > MAX_ROWS && (
|
|
265
|
+
<p className="pt-2 text-[12px] text-neutral-500">
|
|
266
|
+
Newest {MAX_ROWS} of {view.data.rows.length}. Narrow the window for the
|
|
267
|
+
rest.
|
|
268
|
+
</p>
|
|
269
|
+
)}
|
|
270
|
+
</>
|
|
271
|
+
)}
|
|
272
|
+
</section>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Link } from "react-router";
|
|
3
|
+
import type { LedgerViewRow } from "../../../ledger/view";
|
|
4
|
+
import type { Decision, ProjectDetailView, SnoozableIsc } from "../../detail";
|
|
5
|
+
import { Badge } from "../components/badge";
|
|
6
|
+
import { Button } from "../components/button";
|
|
7
|
+
import { Separator } from "../components/separator";
|
|
8
|
+
import { clock } from "../format";
|
|
9
|
+
import { Empty, Panel, Pending } from "../frame";
|
|
10
|
+
import { useLoaded } from "../lib/api";
|
|
11
|
+
import { cn } from "../lib/cn";
|
|
12
|
+
import { setIsc, setSnooze } from "../lib/write";
|
|
13
|
+
|
|
14
|
+
const ISC_MARK: Record<SnoozableIsc["status"], string> = {
|
|
15
|
+
open: "",
|
|
16
|
+
done: "✓",
|
|
17
|
+
retired: "~",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const SNOOZE_DAYS = 7;
|
|
21
|
+
|
|
22
|
+
function outcomeVariant(outcome: string) {
|
|
23
|
+
if (outcome === "applied") return "neutral" as const;
|
|
24
|
+
if (outcome === "failed") return "outline" as const;
|
|
25
|
+
return "alarm" as const;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Retired is a decision, not a checkbox — only open and done toggle here. */
|
|
29
|
+
function Criteria({
|
|
30
|
+
slug,
|
|
31
|
+
iscs,
|
|
32
|
+
onChanged,
|
|
33
|
+
}: {
|
|
34
|
+
slug: string;
|
|
35
|
+
iscs: SnoozableIsc[];
|
|
36
|
+
onChanged: () => void;
|
|
37
|
+
}) {
|
|
38
|
+
const [error, setError] = useState<string | null>(null);
|
|
39
|
+
if (iscs.length === 0) return <Empty>No criteria written yet.</Empty>;
|
|
40
|
+
|
|
41
|
+
const toggle = (isc: SnoozableIsc) => {
|
|
42
|
+
void setIsc(slug, isc.id, isc.status === "open" ? "done" : "open").then((failure) => {
|
|
43
|
+
setError(failure);
|
|
44
|
+
if (!failure) onChanged();
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<>
|
|
50
|
+
{error && (
|
|
51
|
+
<p className="mb-2 border-l-2 border-alarm bg-alarm/10 px-3 py-2 text-[12px] text-alarm">
|
|
52
|
+
{error}
|
|
53
|
+
</p>
|
|
54
|
+
)}
|
|
55
|
+
<ul className="m-0 flex list-none flex-col p-0">
|
|
56
|
+
{iscs.map((isc) => (
|
|
57
|
+
<li
|
|
58
|
+
key={isc.id}
|
|
59
|
+
className={cn(
|
|
60
|
+
"grid grid-cols-[18px_58px_minmax(0,1fr)] items-start gap-3 border-b border-divider/60 py-2 last:border-b-0",
|
|
61
|
+
isc.status !== "open" && "opacity-60"
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
disabled={isc.status === "retired"}
|
|
67
|
+
onClick={() => toggle(isc)}
|
|
68
|
+
title={isc.status === "open" ? "close this criterion" : "reopen it"}
|
|
69
|
+
className={cn(
|
|
70
|
+
"mt-0.5 grid size-3.5 cursor-pointer place-items-center border border-neutral-500 text-[10px] leading-none text-bg hover:border-accent disabled:cursor-default",
|
|
71
|
+
isc.status === "done" && "bg-accent",
|
|
72
|
+
isc.status === "retired" && "bg-neutral-400"
|
|
73
|
+
)}
|
|
74
|
+
>
|
|
75
|
+
{ISC_MARK[isc.status]}
|
|
76
|
+
<span className="sr-only">{isc.status}</span>
|
|
77
|
+
</button>
|
|
78
|
+
<span className="pt-px text-[11.5px] text-neutral-600">ISC-{isc.id}</span>
|
|
79
|
+
<span className="flex items-start justify-between gap-3">
|
|
80
|
+
<span className="text-pretty">
|
|
81
|
+
{isc.text}
|
|
82
|
+
{isc.snoozedUntil && (
|
|
83
|
+
<span className="block text-[11.5px] text-neutral-600">
|
|
84
|
+
snoozed until {isc.snoozedUntil}
|
|
85
|
+
</span>
|
|
86
|
+
)}
|
|
87
|
+
</span>
|
|
88
|
+
{isc.status === "open" && (
|
|
89
|
+
<Button
|
|
90
|
+
variant="ghost"
|
|
91
|
+
size="sm"
|
|
92
|
+
onClick={() => {
|
|
93
|
+
void setSnooze(slug, isc.id, isc.snoozedUntil ? 0 : SNOOZE_DAYS).then(
|
|
94
|
+
(failure) => {
|
|
95
|
+
setError(failure);
|
|
96
|
+
if (!failure) onChanged();
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
{isc.snoozedUntil ? "wake" : `snooze ${SNOOZE_DAYS}d`}
|
|
102
|
+
</Button>
|
|
103
|
+
)}
|
|
104
|
+
</span>
|
|
105
|
+
</li>
|
|
106
|
+
))}
|
|
107
|
+
</ul>
|
|
108
|
+
</>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function Decisions({ decisions }: { decisions: Decision[] }) {
|
|
113
|
+
if (decisions.length === 0) return <Empty>Nothing recorded.</Empty>;
|
|
114
|
+
return (
|
|
115
|
+
<div className="flex flex-col gap-1.5 text-[12.5px]">
|
|
116
|
+
{decisions.map((d) => (
|
|
117
|
+
<div key={d.text} className="grid grid-cols-[78px_1fr] gap-3">
|
|
118
|
+
<span className="text-[11.5px] text-neutral-600">{d.date ?? "—"}</span>
|
|
119
|
+
<span>
|
|
120
|
+
{d.text}
|
|
121
|
+
{d.why && <span className="text-neutral-600"> — {d.why}</span>}
|
|
122
|
+
</span>
|
|
123
|
+
</div>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function Runtimes({ runtimes }: { runtimes: Record<string, number> }) {
|
|
130
|
+
const entries = Object.entries(runtimes);
|
|
131
|
+
if (entries.length === 0) return <Empty>No recorded actions in the window.</Empty>;
|
|
132
|
+
const max = Math.max(1, ...entries.map(([, n]) => n));
|
|
133
|
+
return (
|
|
134
|
+
<div className="flex flex-col gap-1.5">
|
|
135
|
+
{entries.map(([runtime, n]) => (
|
|
136
|
+
<div
|
|
137
|
+
key={runtime}
|
|
138
|
+
className="grid grid-cols-[70px_1fr_32px] items-center gap-2 text-[12px]"
|
|
139
|
+
>
|
|
140
|
+
<span>{runtime}</span>
|
|
141
|
+
<span className="h-1.5 bg-neutral-300">
|
|
142
|
+
<span
|
|
143
|
+
className="block h-1.5 bg-accent"
|
|
144
|
+
style={{ width: `${(n / max) * 100}%` }}
|
|
145
|
+
/>
|
|
146
|
+
</span>
|
|
147
|
+
<span className="text-right tabular-nums text-neutral-700">{n}</span>
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function RecentActions({ rows }: { rows: LedgerViewRow[] }) {
|
|
155
|
+
if (rows.length === 0) return <Empty>Nothing recorded yet.</Empty>;
|
|
156
|
+
return (
|
|
157
|
+
<div className="flex flex-col gap-1.5">
|
|
158
|
+
{rows.map((r) => (
|
|
159
|
+
<div key={r.id} className="grid grid-cols-[86px_1fr_auto] gap-2 text-[11.5px]">
|
|
160
|
+
<span className="text-neutral-600">{clock(r.ts).slice(5)}</span>
|
|
161
|
+
<span className="truncate">
|
|
162
|
+
<b className="font-medium">{r.tool}</b>{" "}
|
|
163
|
+
<span className="text-neutral-700">{r.target}</span>
|
|
164
|
+
</span>
|
|
165
|
+
<Badge variant={outcomeVariant(r.outcome)}>{r.outcome}</Badge>
|
|
166
|
+
</div>
|
|
167
|
+
))}
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function Head({ d }: { d: ProjectDetailView }) {
|
|
173
|
+
const open = d.iscs.filter((i) => i.status === "open").length;
|
|
174
|
+
return (
|
|
175
|
+
<>
|
|
176
|
+
<nav className="mb-1.5 text-[12px] text-neutral-600">
|
|
177
|
+
<Link to="/projects" className="hover:text-accent">
|
|
178
|
+
projects
|
|
179
|
+
</Link>{" "}
|
|
180
|
+
/ {d.slug}
|
|
181
|
+
</nav>
|
|
182
|
+
<div className="mb-5 flex flex-wrap items-start justify-between gap-6">
|
|
183
|
+
<div>
|
|
184
|
+
<h1 className="m-0 flex items-center gap-3 text-[38px]">
|
|
185
|
+
{d.slug}
|
|
186
|
+
<Badge variant="neutral">{d.status}</Badge>
|
|
187
|
+
</h1>
|
|
188
|
+
<p className="max-w-[760px] text-[13px] text-pretty text-neutral-800">
|
|
189
|
+
{d.goal || d.purpose}
|
|
190
|
+
</p>
|
|
191
|
+
</div>
|
|
192
|
+
<div className="text-right text-[11.5px] text-neutral-600">
|
|
193
|
+
<div>{d.remote ?? "no remote on record"}</div>
|
|
194
|
+
<div>{d.path ?? "not checked out here"}</div>
|
|
195
|
+
<div>
|
|
196
|
+
{open} open · serves {d.serves ?? "nothing on record"}
|
|
197
|
+
{d.servesBy && ` · ${d.servesBy}`}
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function ProjectDetail({ slug }: { slug: string }) {
|
|
206
|
+
const [nonce, setNonce] = useState(0);
|
|
207
|
+
const view = useLoaded<ProjectDetailView>(
|
|
208
|
+
`/api/project?slug=${encodeURIComponent(slug)}&v=${nonce}`
|
|
209
|
+
);
|
|
210
|
+
if (view.state !== "ready") return <Pending value={view} />;
|
|
211
|
+
const d = view.data;
|
|
212
|
+
const reload = () => setNonce((n) => n + 1);
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<section>
|
|
216
|
+
<Head d={d} />
|
|
217
|
+
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
|
218
|
+
<div className="flex flex-col gap-6">
|
|
219
|
+
<Panel
|
|
220
|
+
title="Ideal state criteria"
|
|
221
|
+
aside="changes here write to the record directly · no agent, no tokens"
|
|
222
|
+
>
|
|
223
|
+
<Criteria slug={d.slug} iscs={d.iscs} onChanged={reload} />
|
|
224
|
+
</Panel>
|
|
225
|
+
<div className="grid gap-6 md:grid-cols-2">
|
|
226
|
+
<Panel title="Next">
|
|
227
|
+
{d.next.length === 0 ? (
|
|
228
|
+
<Empty>nothing queued</Empty>
|
|
229
|
+
) : (
|
|
230
|
+
<ol className="m-0 flex list-decimal flex-col gap-1.5 pl-5 text-[12.5px]">
|
|
231
|
+
{d.next.map((n) => (
|
|
232
|
+
<li key={n}>{n}</li>
|
|
233
|
+
))}
|
|
234
|
+
</ol>
|
|
235
|
+
)}
|
|
236
|
+
</Panel>
|
|
237
|
+
<Panel title="Blockers">
|
|
238
|
+
{d.blockers.length === 0 ? (
|
|
239
|
+
<Empty>none</Empty>
|
|
240
|
+
) : (
|
|
241
|
+
<div className="flex flex-col gap-1.5 text-[12.5px]">
|
|
242
|
+
{d.blockers.map((b) => (
|
|
243
|
+
<p key={b} className="bg-accent-100 px-2 py-1.5 text-accent-900">
|
|
244
|
+
{b}
|
|
245
|
+
</p>
|
|
246
|
+
))}
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</Panel>
|
|
250
|
+
</div>
|
|
251
|
+
<Panel title="Decisions">
|
|
252
|
+
<Decisions decisions={d.decisions} />
|
|
253
|
+
</Panel>
|
|
254
|
+
</div>
|
|
255
|
+
<aside className="flex flex-col gap-6">
|
|
256
|
+
<Panel title="Handoff">
|
|
257
|
+
{d.handoff ? (
|
|
258
|
+
<>
|
|
259
|
+
<p className="text-[12.5px] text-pretty" title={d.handoff.full}>
|
|
260
|
+
{d.handoff.sentence}
|
|
261
|
+
</p>
|
|
262
|
+
{d.handoff.waitingOn && (
|
|
263
|
+
<p className="mt-2 bg-accent-100 px-2 py-1.5 text-[12px] text-accent-900">
|
|
264
|
+
waiting on you · {d.handoff.waitingOn}
|
|
265
|
+
</p>
|
|
266
|
+
)}
|
|
267
|
+
</>
|
|
268
|
+
) : (
|
|
269
|
+
<Empty>No handoff. Every session on this project closed cleanly.</Empty>
|
|
270
|
+
)}
|
|
271
|
+
</Panel>
|
|
272
|
+
<Panel
|
|
273
|
+
title="Agents on this project · 14d"
|
|
274
|
+
aside={
|
|
275
|
+
<Link
|
|
276
|
+
to={`/log?project=${encodeURIComponent(d.slug)}`}
|
|
277
|
+
className="hover:text-accent"
|
|
278
|
+
>
|
|
279
|
+
full log →
|
|
280
|
+
</Link>
|
|
281
|
+
}
|
|
282
|
+
>
|
|
283
|
+
<Runtimes runtimes={d.runtimes} />
|
|
284
|
+
<Separator className="my-3" />
|
|
285
|
+
<RecentActions rows={d.recent} />
|
|
286
|
+
</Panel>
|
|
287
|
+
<Panel title="Context">
|
|
288
|
+
{d.context.length === 0 ? (
|
|
289
|
+
<Empty>nothing recorded</Empty>
|
|
290
|
+
) : (
|
|
291
|
+
<ul className="m-0 flex list-disc flex-col gap-1 pl-4 text-[12.5px]">
|
|
292
|
+
{d.context.map((c) => (
|
|
293
|
+
<li key={c}>{c}</li>
|
|
294
|
+
))}
|
|
295
|
+
</ul>
|
|
296
|
+
)}
|
|
297
|
+
</Panel>
|
|
298
|
+
</aside>
|
|
299
|
+
</div>
|
|
300
|
+
</section>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Link } from "react-router";
|
|
2
|
+
import type { ProjectCard } from "../../data";
|
|
3
|
+
import { Badge } from "../components/badge";
|
|
4
|
+
import {
|
|
5
|
+
Table,
|
|
6
|
+
TableBody,
|
|
7
|
+
TableCell,
|
|
8
|
+
TableHead,
|
|
9
|
+
TableHeader,
|
|
10
|
+
TableRow,
|
|
11
|
+
} from "../components/table";
|
|
12
|
+
import { age } from "../format";
|
|
13
|
+
import { Empty, Pending, Scroller } from "../frame";
|
|
14
|
+
import { useLoaded } from "../lib/api";
|
|
15
|
+
import { cn } from "../lib/cn";
|
|
16
|
+
|
|
17
|
+
export const STATUS_FILTERS = [
|
|
18
|
+
{ value: "ranked", label: "active + paused" },
|
|
19
|
+
{ value: "active", label: "active" },
|
|
20
|
+
{ value: "paused", label: "paused" },
|
|
21
|
+
{ value: "all", label: "all" },
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
export type StatusFilter = (typeof STATUS_FILTERS)[number]["value"];
|
|
25
|
+
|
|
26
|
+
export function isStatusFilter(value: string | null): value is StatusFilter {
|
|
27
|
+
return STATUS_FILTERS.some((f) => f.value === value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const RANKED = new Set(["active", "paused"]);
|
|
31
|
+
|
|
32
|
+
const COLUMNS = [
|
|
33
|
+
{ label: "Project", numeric: false },
|
|
34
|
+
{ label: "Status", numeric: false },
|
|
35
|
+
{ label: "Serves", numeric: false },
|
|
36
|
+
{ label: "Open ISCs", numeric: true },
|
|
37
|
+
{ label: "Sessions / 30d", numeric: true },
|
|
38
|
+
{ label: "Agents", numeric: false },
|
|
39
|
+
{ label: "Here", numeric: false },
|
|
40
|
+
{ label: "Touched", numeric: true },
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
function keep(card: ProjectCard, filter: StatusFilter): boolean {
|
|
44
|
+
if (filter === "all") return true;
|
|
45
|
+
if (filter === "ranked") return RANKED.has(card.status);
|
|
46
|
+
return card.status === filter;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function Row({ card }: { card: ProjectCard }) {
|
|
50
|
+
return (
|
|
51
|
+
<TableRow>
|
|
52
|
+
<TableCell>
|
|
53
|
+
<Link
|
|
54
|
+
to={`/projects/${card.slug}`}
|
|
55
|
+
className="font-heading text-[15px] font-semibold text-ink hover:text-accent"
|
|
56
|
+
>
|
|
57
|
+
{card.slug}
|
|
58
|
+
</Link>
|
|
59
|
+
{card.next[0] && (
|
|
60
|
+
<span className="block text-[11.5px] text-neutral-700">{card.next[0]}</span>
|
|
61
|
+
)}
|
|
62
|
+
</TableCell>
|
|
63
|
+
<TableCell>
|
|
64
|
+
<Badge variant="neutral">{card.status}</Badge>
|
|
65
|
+
</TableCell>
|
|
66
|
+
<TableCell className="text-[12px]">
|
|
67
|
+
{card.serves ?? "unranked"}
|
|
68
|
+
{card.servesBy && (
|
|
69
|
+
<span className="ml-1 text-[10.5px] text-neutral-600">· {card.servesBy}</span>
|
|
70
|
+
)}
|
|
71
|
+
</TableCell>
|
|
72
|
+
<TableCell className="font-heading text-right text-[16px] font-semibold tabular-nums">
|
|
73
|
+
{card.openIscs}
|
|
74
|
+
</TableCell>
|
|
75
|
+
<TableCell className="text-right tabular-nums">{card.sessions30d}</TableCell>
|
|
76
|
+
<TableCell>
|
|
77
|
+
<span className="flex flex-wrap gap-1">
|
|
78
|
+
{Object.entries(card.runtimes).map(([runtime, n]) => (
|
|
79
|
+
<Badge key={runtime} variant="outline">
|
|
80
|
+
{runtime} {n}
|
|
81
|
+
</Badge>
|
|
82
|
+
))}
|
|
83
|
+
</span>
|
|
84
|
+
</TableCell>
|
|
85
|
+
<TableCell className="text-[12px] text-neutral-700">
|
|
86
|
+
{card.path ?? "not checked out here"}
|
|
87
|
+
</TableCell>
|
|
88
|
+
<TableCell className="text-right text-[12px] whitespace-nowrap text-neutral-700">
|
|
89
|
+
{age(card.ageDays)}
|
|
90
|
+
</TableCell>
|
|
91
|
+
</TableRow>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function Projects({ filter }: { filter: StatusFilter }) {
|
|
96
|
+
const board = useLoaded<ProjectCard[]>("/api/board");
|
|
97
|
+
if (board.state !== "ready") return <Pending value={board} />;
|
|
98
|
+
const rows = board.data.filter((card) => keep(card, filter));
|
|
99
|
+
return (
|
|
100
|
+
<div className="blueprint flex min-h-0 flex-1 flex-col bg-bg">
|
|
101
|
+
{rows.length === 0 ? (
|
|
102
|
+
<Empty>No project matches this filter.</Empty>
|
|
103
|
+
) : (
|
|
104
|
+
<Scroller>
|
|
105
|
+
<Table className="min-w-[880px] text-[13px]">
|
|
106
|
+
<TableHeader>
|
|
107
|
+
<tr>
|
|
108
|
+
{COLUMNS.map((column) => (
|
|
109
|
+
<TableHead
|
|
110
|
+
key={column.label}
|
|
111
|
+
className={cn(column.numeric && "text-right")}
|
|
112
|
+
>
|
|
113
|
+
{column.label}
|
|
114
|
+
</TableHead>
|
|
115
|
+
))}
|
|
116
|
+
</tr>
|
|
117
|
+
</TableHeader>
|
|
118
|
+
<TableBody>
|
|
119
|
+
{rows.map((card) => (
|
|
120
|
+
<Row key={card.slug} card={card} />
|
|
121
|
+
))}
|
|
122
|
+
</TableBody>
|
|
123
|
+
</Table>
|
|
124
|
+
</Scroller>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|