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,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built page, off disk. Vite compiles src/tools/control-room/ui into
|
|
3
|
+
* ui/dist; this serves it, and says so plainly when the build has not been run
|
|
4
|
+
* rather than answering a blank page.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
const UI_DIST = resolve(import.meta.dir, "ui", "dist");
|
|
12
|
+
|
|
13
|
+
export const BUILD_COMMAND = "bun run build:ui";
|
|
14
|
+
|
|
15
|
+
export function isBuilt(): boolean {
|
|
16
|
+
return existsSync(resolve(UI_DIST, "index.html"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A clone has vite; a published install has the prepacked dist and never needs
|
|
21
|
+
* it. Either way the page is there by the time the server answers.
|
|
22
|
+
*/
|
|
23
|
+
export function buildPage(): boolean {
|
|
24
|
+
const repoRoot = resolve(import.meta.dir, "..", "..", "..");
|
|
25
|
+
// node_modules/.bin/vite is a shell shim that Windows cannot spawn directly;
|
|
26
|
+
// the package's own JS entry runs the same build under any runtime.
|
|
27
|
+
const vite = resolve(repoRoot, "node_modules", "vite", "bin", "vite.js");
|
|
28
|
+
if (!existsSync(vite)) return false;
|
|
29
|
+
const built = spawnSync(process.execPath, [vite, "build"], {
|
|
30
|
+
cwd: resolve(import.meta.dir, "ui"),
|
|
31
|
+
encoding: "utf-8",
|
|
32
|
+
});
|
|
33
|
+
return built.status === 0 && isBuilt();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function unbuiltPage(): Response {
|
|
37
|
+
return new Response(
|
|
38
|
+
`<!doctype html><meta charset="utf-8"><title>PAL control room</title>` +
|
|
39
|
+
`<body style="font:14px system-ui;padding:3rem;max-width:34rem">` +
|
|
40
|
+
`<h1>The page has not been built</h1>` +
|
|
41
|
+
`<p>The control room's assets are a build artifact rather than source. Run:</p>` +
|
|
42
|
+
`<pre style="background:#eee;padding:1rem">${BUILD_COMMAND}</pre></body>`,
|
|
43
|
+
{ status: 503, headers: { "content-type": "text/html; charset=utf-8" } }
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function indexHtml(): Response {
|
|
48
|
+
if (!isBuilt()) return unbuiltPage();
|
|
49
|
+
return new Response(Bun.file(resolve(UI_DIST, "index.html")), {
|
|
50
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Only what the build emitted, resolved inside dist — a request that climbs out
|
|
56
|
+
* of it with `..` is a miss, not a file read.
|
|
57
|
+
*/
|
|
58
|
+
export function staticAsset(pathname: string): Response | null {
|
|
59
|
+
const target = resolve(UI_DIST, `.${pathname}`);
|
|
60
|
+
if (!isInsideDist(target)) return null;
|
|
61
|
+
return existsSync(target) ? new Response(Bun.file(target)) : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Compared as paths, not strings — a separator differs by platform. */
|
|
65
|
+
function isInsideDist(target: string): boolean {
|
|
66
|
+
const rel = relative(UI_DIST, target);
|
|
67
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
68
|
+
}
|
|
@@ -1,67 +1,213 @@
|
|
|
1
|
-
import { StrictMode
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
2
|
import { createRoot } from "react-dom/client";
|
|
3
|
+
import {
|
|
4
|
+
BrowserRouter,
|
|
5
|
+
Navigate,
|
|
6
|
+
NavLink,
|
|
7
|
+
Route,
|
|
8
|
+
Routes,
|
|
9
|
+
useParams,
|
|
10
|
+
useSearchParams,
|
|
11
|
+
} from "react-router";
|
|
3
12
|
import type { ServerStatus } from "../server";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
13
|
+
import { AttentionBell } from "./attention-bell";
|
|
14
|
+
import { Screen, Scroller, Seg } from "./frame";
|
|
15
|
+
import { useLoaded } from "./lib/api";
|
|
16
|
+
import { cn } from "./lib/cn";
|
|
17
|
+
import { LOG_KEYS, Log, type LogFilter } from "./screens/log";
|
|
18
|
+
import { ProjectDetail } from "./screens/project-detail";
|
|
19
|
+
import {
|
|
20
|
+
isStatusFilter,
|
|
21
|
+
Projects,
|
|
22
|
+
STATUS_FILTERS,
|
|
23
|
+
type StatusFilter,
|
|
24
|
+
} from "./screens/projects";
|
|
25
|
+
import { Settings } from "./screens/settings";
|
|
26
|
+
import { isLayout, LAYOUTS, type Layout, Today } from "./screens/today";
|
|
12
27
|
|
|
13
|
-
|
|
28
|
+
const LAYOUT_KEY = "pal.control-room.layout";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A blueprint's registration marks are drawn 6px outside its border, so a scroll
|
|
32
|
+
* box whose children are blueprints would count them as overflow. The negative
|
|
33
|
+
* margin borrows that room back from the page gutter, leaving content aligned.
|
|
34
|
+
*/
|
|
35
|
+
const MARK_ROOM = "-m-2 p-2";
|
|
36
|
+
|
|
37
|
+
function rememberedLayout(): Layout {
|
|
38
|
+
try {
|
|
39
|
+
const stored = localStorage.getItem(LAYOUT_KEY);
|
|
40
|
+
return isLayout(stored) ? stored : "matrix";
|
|
41
|
+
} catch {
|
|
42
|
+
return "matrix";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function remember(layout: Layout): void {
|
|
47
|
+
try {
|
|
48
|
+
localStorage.setItem(LAYOUT_KEY, layout);
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function TodayRoute() {
|
|
53
|
+
const [params, setParams] = useSearchParams();
|
|
54
|
+
const asked = params.get("layout");
|
|
55
|
+
const layout = isLayout(asked) ? asked : rememberedLayout();
|
|
14
56
|
return (
|
|
15
|
-
<div className="
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
57
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
58
|
+
<div className="mb-4 flex justify-end">
|
|
59
|
+
<Seg
|
|
60
|
+
label="layout"
|
|
61
|
+
options={LAYOUTS}
|
|
62
|
+
value={layout}
|
|
63
|
+
onPick={(next) => {
|
|
64
|
+
remember(next);
|
|
65
|
+
setParams({ layout: next });
|
|
66
|
+
}}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
<Scroller className={MARK_ROOM}>
|
|
70
|
+
<Today layout={layout} />
|
|
71
|
+
</Scroller>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ProjectsRoute() {
|
|
77
|
+
const [params, setParams] = useSearchParams();
|
|
78
|
+
const asked = params.get("status");
|
|
79
|
+
const filter: StatusFilter = isStatusFilter(asked) ? asked : "ranked";
|
|
80
|
+
return (
|
|
81
|
+
<Screen
|
|
82
|
+
title="Projects"
|
|
83
|
+
aside={
|
|
84
|
+
<Seg
|
|
85
|
+
label="status"
|
|
86
|
+
options={STATUS_FILTERS}
|
|
87
|
+
value={filter}
|
|
88
|
+
onPick={(next) => setParams({ status: next })}
|
|
89
|
+
/>
|
|
90
|
+
}
|
|
91
|
+
>
|
|
92
|
+
<Projects filter={filter} />
|
|
93
|
+
</Screen>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function ProjectDetailRoute() {
|
|
98
|
+
const { slug } = useParams();
|
|
99
|
+
return (
|
|
100
|
+
<Scroller className={MARK_ROOM}>
|
|
101
|
+
<ProjectDetail slug={slug ?? ""} />
|
|
102
|
+
</Scroller>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function LogRoute() {
|
|
107
|
+
const [params, setParams] = useSearchParams();
|
|
108
|
+
const filter = Object.fromEntries(
|
|
109
|
+
LOG_KEYS.map((key) => [key, params.get(key) ?? ""])
|
|
110
|
+
) as LogFilter;
|
|
111
|
+
const update = (next: Partial<LogFilter>) => {
|
|
112
|
+
const merged = { ...filter, ...next };
|
|
113
|
+
setParams(Object.fromEntries(Object.entries(merged).filter(([, v]) => v)));
|
|
114
|
+
};
|
|
115
|
+
return (
|
|
116
|
+
<Screen
|
|
117
|
+
title="Action log"
|
|
118
|
+
aside={
|
|
119
|
+
<span className="text-[12px] text-neutral-700">
|
|
120
|
+
every tool call this install recorded
|
|
25
121
|
</span>
|
|
122
|
+
}
|
|
123
|
+
>
|
|
124
|
+
<Log filter={filter} onFilter={update} />
|
|
125
|
+
</Screen>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function SettingsRoute() {
|
|
130
|
+
return (
|
|
131
|
+
<Screen title="Settings">
|
|
132
|
+
<Scroller className={MARK_ROOM}>
|
|
133
|
+
<Settings />
|
|
134
|
+
</Scroller>
|
|
135
|
+
</Screen>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const TABS = [
|
|
140
|
+
{ to: "/", label: "Today", end: true },
|
|
141
|
+
{ to: "/projects", label: "Projects", end: false },
|
|
142
|
+
{ to: "/log", label: "Action log", end: false },
|
|
143
|
+
{ to: "/settings", label: "Settings", end: false },
|
|
144
|
+
] as const;
|
|
145
|
+
|
|
146
|
+
function Header({ status }: { status: ServerStatus | null }) {
|
|
147
|
+
return (
|
|
148
|
+
<header className="flex flex-wrap items-center gap-5 border-b border-divider bg-bg px-7 py-2.5">
|
|
149
|
+
<div className="flex min-w-[120px] items-baseline gap-2">
|
|
150
|
+
<span className="font-heading text-[22px] font-semibold tracking-wide">PAL</span>
|
|
151
|
+
<span className="eyebrow">control</span>
|
|
152
|
+
</div>
|
|
153
|
+
<nav className="-mb-[11px] flex gap-0.5">
|
|
154
|
+
{TABS.map((tab) => (
|
|
155
|
+
<NavLink
|
|
156
|
+
key={tab.to}
|
|
157
|
+
to={tab.to}
|
|
158
|
+
end={tab.end}
|
|
159
|
+
className={({ isActive }) =>
|
|
160
|
+
cn(
|
|
161
|
+
"font-heading border-b-2 px-3 py-2 text-[15px] font-semibold whitespace-nowrap",
|
|
162
|
+
isActive
|
|
163
|
+
? "border-accent text-ink"
|
|
164
|
+
: "border-transparent text-neutral-600 hover:text-accent"
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
>
|
|
168
|
+
{tab.label}
|
|
169
|
+
</NavLink>
|
|
170
|
+
))}
|
|
171
|
+
</nav>
|
|
172
|
+
<div className="ml-auto flex items-center gap-3 text-[12px] text-neutral-700">
|
|
173
|
+
<span className="inline-block size-1.5 bg-accent" />
|
|
174
|
+
<b className="font-medium text-ink">{status?.machine ?? "…"}</b>
|
|
26
175
|
<span>
|
|
27
|
-
|
|
176
|
+
· {status ? `${status.ledgerFiles} ledger file(s)` : "…"} · port{" "}
|
|
177
|
+
{status?.port ?? "…"}
|
|
28
178
|
</span>
|
|
179
|
+
<AttentionBell />
|
|
29
180
|
</div>
|
|
30
|
-
</
|
|
181
|
+
</header>
|
|
31
182
|
);
|
|
32
183
|
}
|
|
33
184
|
|
|
34
185
|
function App() {
|
|
35
|
-
const
|
|
36
|
-
const [error, setError] = useState<string | null>(null);
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
getJson<ServerStatus>("/api/status")
|
|
39
|
-
.then(setStatus)
|
|
40
|
-
.catch((e: Error) => setError(e.message));
|
|
41
|
-
}, []);
|
|
186
|
+
const status = useLoaded<ServerStatus>("/api/status");
|
|
42
187
|
return (
|
|
43
|
-
<
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
188
|
+
<BrowserRouter>
|
|
189
|
+
<div className="flex h-screen flex-col overflow-hidden">
|
|
190
|
+
<Header status={status.state === "ready" ? status.data : null} />
|
|
191
|
+
<main className="mx-auto flex w-full max-w-[1480px] min-h-0 flex-1 flex-col px-7 pt-6 pb-6">
|
|
192
|
+
<Routes>
|
|
193
|
+
<Route path="/" element={<TodayRoute />} />
|
|
194
|
+
<Route path="/projects" element={<ProjectsRoute />} />
|
|
195
|
+
<Route path="/projects/:slug" element={<ProjectDetailRoute />} />
|
|
196
|
+
<Route path="/log" element={<LogRoute />} />
|
|
197
|
+
<Route path="/settings" element={<SettingsRoute />} />
|
|
198
|
+
<Route path="*" element={<Navigate to="/" replace />} />
|
|
199
|
+
</Routes>
|
|
200
|
+
</main>
|
|
201
|
+
<footer className="flex flex-wrap justify-between gap-4 border-t border-divider px-7 py-3 text-[11px] text-neutral-600">
|
|
202
|
+
<span>
|
|
203
|
+
loopback only · every number from ~/.pal · no model runs on this page
|
|
204
|
+
</span>
|
|
205
|
+
<span>
|
|
206
|
+
{status.state === "ready" ? `up since ${status.data.startedAt}` : ""}
|
|
207
|
+
</span>
|
|
208
|
+
</footer>
|
|
50
209
|
</div>
|
|
51
|
-
|
|
52
|
-
<summary>When something breaks — projects, agents, signal, ledger</summary>
|
|
53
|
-
<div className="grid">
|
|
54
|
-
<Board />
|
|
55
|
-
<Signal />
|
|
56
|
-
<Agents />
|
|
57
|
-
<Ledger />
|
|
58
|
-
</div>
|
|
59
|
-
</details>
|
|
60
|
-
<div className="foot">
|
|
61
|
-
<span>loopback only · every number from ~/.pal · no model runs on this page</span>
|
|
62
|
-
<span>{status ? `up since ${status.startedAt}` : ""}</span>
|
|
63
|
-
</div>
|
|
64
|
-
</div>
|
|
210
|
+
</BrowserRouter>
|
|
65
211
|
);
|
|
66
212
|
}
|
|
67
213
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useNavigate } from "react-router";
|
|
3
|
+
import type { AttentionItem, AttentionView } from "../attention";
|
|
4
|
+
import { Button } from "./components/button";
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./components/popover";
|
|
6
|
+
import { Empty } from "./frame";
|
|
7
|
+
import { useLoaded } from "./lib/api";
|
|
8
|
+
import { cn } from "./lib/cn";
|
|
9
|
+
import { post } from "./lib/write";
|
|
10
|
+
|
|
11
|
+
const DOT: Record<AttentionItem["severity"], string> = {
|
|
12
|
+
alarm: "bg-alarm",
|
|
13
|
+
waiting: "bg-accent",
|
|
14
|
+
note: "bg-neutral-400",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function BellIcon() {
|
|
18
|
+
return (
|
|
19
|
+
<svg
|
|
20
|
+
width="16"
|
|
21
|
+
height="16"
|
|
22
|
+
viewBox="0 0 24 24"
|
|
23
|
+
fill="none"
|
|
24
|
+
stroke="currentColor"
|
|
25
|
+
strokeWidth="1.5"
|
|
26
|
+
strokeLinecap="round"
|
|
27
|
+
strokeLinejoin="round"
|
|
28
|
+
aria-hidden="true"
|
|
29
|
+
>
|
|
30
|
+
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
|
31
|
+
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
|
32
|
+
</svg>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Item({ item, onOpen }: { item: AttentionItem; onOpen: () => void }) {
|
|
37
|
+
return (
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
onClick={onOpen}
|
|
41
|
+
className={cn(
|
|
42
|
+
"grid w-full cursor-pointer grid-cols-[8px_1fr_auto] items-start gap-3 border-b border-divider px-4 py-3 text-left last:border-b-0 hover:bg-neutral-200",
|
|
43
|
+
item.read && "opacity-55"
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
<span className={cn("mt-1.5 size-1.5", DOT[item.severity])} />
|
|
47
|
+
<span>
|
|
48
|
+
<span className="block font-medium">{item.title}</span>
|
|
49
|
+
<span className="block text-[12px] text-neutral-700">{item.detail}</span>
|
|
50
|
+
</span>
|
|
51
|
+
<span className="text-[11px] whitespace-nowrap text-neutral-600">
|
|
52
|
+
{item.at.slice(5, 10)}
|
|
53
|
+
</span>
|
|
54
|
+
</button>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function AttentionBell() {
|
|
59
|
+
const [nonce, setNonce] = useState(0);
|
|
60
|
+
const [open, setOpen] = useState(false);
|
|
61
|
+
const view = useLoaded<AttentionView>(`/api/attention?v=${nonce}`);
|
|
62
|
+
const navigate = useNavigate();
|
|
63
|
+
const reload = () => setNonce((n) => n + 1);
|
|
64
|
+
const unread = view.state === "ready" ? view.data.unread : 0;
|
|
65
|
+
|
|
66
|
+
const openItem = (item: AttentionItem) => {
|
|
67
|
+
void post("/api/attention/read", { ids: [item.id] }).then(() => {
|
|
68
|
+
setOpen(false);
|
|
69
|
+
reload();
|
|
70
|
+
void navigate(item.href);
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
76
|
+
<PopoverTrigger asChild>
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
aria-label={`${unread} things need you`}
|
|
80
|
+
className="relative cursor-pointer border border-transparent p-1.5 text-ink hover:border-divider"
|
|
81
|
+
>
|
|
82
|
+
<BellIcon />
|
|
83
|
+
{unread > 0 && (
|
|
84
|
+
<span className="absolute -top-1 -right-1 grid min-w-4 place-items-center bg-accent-900 px-1 text-[10px] font-semibold text-bg tabular-nums">
|
|
85
|
+
{unread}
|
|
86
|
+
</span>
|
|
87
|
+
)}
|
|
88
|
+
</button>
|
|
89
|
+
</PopoverTrigger>
|
|
90
|
+
<PopoverContent className="max-h-[70vh] overflow-auto">
|
|
91
|
+
<div className="flex items-baseline justify-between border-b border-divider px-4 py-2.5">
|
|
92
|
+
<h2 className="font-heading m-0 text-[16px] font-semibold">
|
|
93
|
+
Needs your attention
|
|
94
|
+
</h2>
|
|
95
|
+
<Button
|
|
96
|
+
variant="ghost"
|
|
97
|
+
size="sm"
|
|
98
|
+
onClick={() => {
|
|
99
|
+
void post("/api/attention/read", { all: true }).then(reload);
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
mark all read
|
|
103
|
+
</Button>
|
|
104
|
+
</div>
|
|
105
|
+
{view.state === "ready" && view.data.items.length === 0 ? (
|
|
106
|
+
<div className="px-4">
|
|
107
|
+
<Empty>Nothing is waiting on you.</Empty>
|
|
108
|
+
</div>
|
|
109
|
+
) : (
|
|
110
|
+
view.state === "ready" &&
|
|
111
|
+
view.data.items.map((item) => (
|
|
112
|
+
<Item key={item.id} item={item} onOpen={() => openItem(item)} />
|
|
113
|
+
))
|
|
114
|
+
)}
|
|
115
|
+
</PopoverContent>
|
|
116
|
+
</Popover>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# ui/components
|
|
2
|
+
|
|
3
|
+
shadcn/ui components, vendored and re-themed for Industry.
|
|
4
|
+
|
|
5
|
+
Two adaptations were made to every file, and both apply to anything added later:
|
|
6
|
+
|
|
7
|
+
- **No RSC.** The `"use client"` directive is dropped — nothing here renders on a
|
|
8
|
+
server. It matches `components.json`'s `"rsc": false`.
|
|
9
|
+
- **Industry, not the shadcn default palette.** The upstream components are
|
|
10
|
+
rounded, filled and shadowed and speak in semantic tokens (`bg-primary`,
|
|
11
|
+
`text-muted-foreground`, `border-input`). Industry is square, hairline and
|
|
12
|
+
unfilled, and its tokens are the ones in `../theme.css` (`accent-*`,
|
|
13
|
+
`neutral-*`, `divider`, `ink`, `bg`). Class strings are rewritten accordingly;
|
|
14
|
+
no parallel semantic token layer exists, so there is one vocabulary to learn.
|
|
15
|
+
|
|
16
|
+
`bunx shadcn@latest add <name>` will fetch a component, but its output needs both
|
|
17
|
+
passes above before it belongs here. Copying the shape from
|
|
18
|
+
`~/Development/git/catalyst/src/components/ui` is usually faster.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
2
|
+
import { Slot } from "radix-ui";
|
|
3
|
+
import type * as React from "react";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
|
|
6
|
+
const badgeVariants = cva(
|
|
7
|
+
"inline-flex w-fit shrink-0 items-center justify-center gap-1 rounded-none border border-transparent px-2 py-0.5 text-[10px] whitespace-nowrap [&>svg]:size-3",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
accent: "bg-accent-100 text-accent-900",
|
|
12
|
+
neutral: "bg-neutral-200 text-neutral-800",
|
|
13
|
+
outline: "border-divider text-neutral-700",
|
|
14
|
+
alarm: "border-alarm/40 bg-alarm/10 text-alarm",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
defaultVariants: { variant: "neutral" },
|
|
18
|
+
}
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
function Badge({
|
|
22
|
+
className,
|
|
23
|
+
variant,
|
|
24
|
+
asChild = false,
|
|
25
|
+
...props
|
|
26
|
+
}: React.ComponentProps<"span"> &
|
|
27
|
+
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
|
28
|
+
const Comp = asChild ? Slot.Root : "span";
|
|
29
|
+
return (
|
|
30
|
+
<Comp
|
|
31
|
+
data-slot="badge"
|
|
32
|
+
className={cn(badgeVariants({ variant }), className)}
|
|
33
|
+
{...props}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export { Badge };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
2
|
+
import { Slot } from "radix-ui";
|
|
3
|
+
import type * as React from "react";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-none border font-medium whitespace-nowrap transition-colors disabled:pointer-events-none disabled:opacity-45 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
primary: "border-accent bg-accent text-bg hover:bg-accent-600",
|
|
12
|
+
secondary: "border-divider bg-transparent text-ink hover:bg-neutral-200",
|
|
13
|
+
ghost: "border-transparent bg-transparent text-ink hover:bg-neutral-200",
|
|
14
|
+
},
|
|
15
|
+
size: {
|
|
16
|
+
default: "h-8 px-4 text-[12px]",
|
|
17
|
+
sm: "h-6 px-2 text-[11px]",
|
|
18
|
+
icon: "size-8",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
defaultVariants: { variant: "secondary", size: "default" },
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
function Button({
|
|
26
|
+
className,
|
|
27
|
+
variant,
|
|
28
|
+
size,
|
|
29
|
+
asChild = false,
|
|
30
|
+
...props
|
|
31
|
+
}: React.ComponentProps<"button"> &
|
|
32
|
+
VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
|
|
33
|
+
const Comp = asChild ? Slot.Root : "button";
|
|
34
|
+
return (
|
|
35
|
+
<Comp
|
|
36
|
+
data-slot="button"
|
|
37
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
38
|
+
{...props}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { Button };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
/** Industry's card is the blueprint object: square, hairline, unfilled, marked. */
|
|
5
|
+
function Card({ className, ...props }: React.ComponentProps<"section">) {
|
|
6
|
+
return (
|
|
7
|
+
<section
|
|
8
|
+
data-slot="card"
|
|
9
|
+
className={cn("blueprint flex flex-col bg-bg p-4", className)}
|
|
10
|
+
{...props}
|
|
11
|
+
/>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function CardHeader({ className, ...props }: React.ComponentProps<"header">) {
|
|
16
|
+
return (
|
|
17
|
+
<header
|
|
18
|
+
data-slot="card-header"
|
|
19
|
+
className={cn("mb-3 flex items-baseline justify-between gap-3", className)}
|
|
20
|
+
{...props}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function CardTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
|
26
|
+
return (
|
|
27
|
+
<h2 data-slot="card-title" className={cn("eyebrow m-0", className)} {...props} />
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function CardAction({ className, ...props }: React.ComponentProps<"span">) {
|
|
32
|
+
return (
|
|
33
|
+
<span
|
|
34
|
+
data-slot="card-action"
|
|
35
|
+
className={cn("text-[11px] text-neutral-600", className)}
|
|
36
|
+
{...props}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
42
|
+
return <div data-slot="card-content" className={cn(className)} {...props} />;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { Card, CardAction, CardContent, CardHeader, CardTitle };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
const FIELD =
|
|
5
|
+
"h-8 rounded-none border border-divider bg-bg px-2 text-[12px] text-ink outline-none transition-colors focus-visible:border-accent disabled:pointer-events-none disabled:opacity-45";
|
|
6
|
+
|
|
7
|
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
8
|
+
return (
|
|
9
|
+
<input type={type} data-slot="input" className={cn(FIELD, className)} {...props} />
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Industry's forms are native controls; catalyst does the same rather than wrap Radix Select. */
|
|
14
|
+
function NativeSelect({ className, ...props }: React.ComponentProps<"select">) {
|
|
15
|
+
return (
|
|
16
|
+
<select
|
|
17
|
+
data-slot="native-select"
|
|
18
|
+
className={cn(FIELD, "cursor-pointer", className)}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { Input, NativeSelect };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Label as LabelPrimitive } from "radix-ui";
|
|
2
|
+
import type * as React from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
|
|
5
|
+
function Label({
|
|
6
|
+
className,
|
|
7
|
+
...props
|
|
8
|
+
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
|
9
|
+
return (
|
|
10
|
+
<LabelPrimitive.Root
|
|
11
|
+
data-slot="label"
|
|
12
|
+
className={cn("eyebrow flex items-center gap-2 select-none", className)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { Label };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Popover as PopoverPrimitive } from "radix-ui";
|
|
2
|
+
import type * as React from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
|
|
5
|
+
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
|
6
|
+
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function PopoverTrigger({
|
|
10
|
+
...props
|
|
11
|
+
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
|
12
|
+
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function PopoverContent({
|
|
16
|
+
className,
|
|
17
|
+
align = "end",
|
|
18
|
+
sideOffset = 8,
|
|
19
|
+
...props
|
|
20
|
+
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
|
21
|
+
return (
|
|
22
|
+
<PopoverPrimitive.Portal>
|
|
23
|
+
<PopoverPrimitive.Content
|
|
24
|
+
data-slot="popover-content"
|
|
25
|
+
align={align}
|
|
26
|
+
sideOffset={sideOffset}
|
|
27
|
+
className={cn(
|
|
28
|
+
"z-50 w-[380px] rounded-none border border-divider bg-bg text-ink shadow-lg outline-none",
|
|
29
|
+
className
|
|
30
|
+
)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
</PopoverPrimitive.Portal>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export { Popover, PopoverContent, PopoverTrigger };
|