pi-mono/apps/p2/plugins/logs/web.tsx
Mario Zechner 20d86c3657 feat(p2): Chord v2 cleanup pass
p2 rewritten by the Chord v2 authoring rules (decision "App authoring rules confirmed
by the p2 audit"), in four parts merged together:
- core: session lifecycle (relocate, clone, fork, archive) and search rebuild are
  persisted jobs on a new server store; Sessions v6 with client-chosen ids and visible
  operations; Retry answers with the worker's real outcome; workers push branch,
  subagent and status state to the server; roots take the method's ctx.
- services: models, envs, ports, login, mcp, logs, sharing and voice follow external
  state (self.follow, followNative) instead of copy loops; methods answer after their
  state commits; presence from host.references.
- chat: chat mode, artifacts and prompt resources follow Durable and native state;
  shell commands are Chord jobs (shellId is the requestId); status pushed before
  send and stop answer; race-free draft claim.
- ui: optimistic updates on web (useOptimistic) and terminal (TuiScope.optimistic,
  extended in Chord with a reducer and a pending flag), pending states for long work,
  visible rollback, load waterfalls removed; merged with the one composer and
  immediate first send (ccff9876e) and the folder + button (5a31a6d5f).
docs/optimistic-ui.md describes the pattern.
2026-11-05 18:22:13 +00:00

139 lines
4 KiB
TypeScript

import { contribute, defineFacet, whenLive } from "@p2/app/v1";
import {
closeLayers,
errorText,
openLayer,
Sheet,
Slots,
useNavigation,
useObject,
useOwner,
useReadable,
useSheet,
useStatus,
} from "@p2/app/v1/web";
import { useEffect, useRef, useState } from "react";
import { Logs } from "./contract.ts";
import "./style.css";
function sourceLabel(source: string): string {
if (source.startsWith("session:")) return `Worker ${source.slice(8)}`;
return source === "dev" ? "Dev supervisor" : source === "terminals" ? "Terminal host" : "Server";
}
function LogsSheet({ source }: { source: string }) {
const logs = useObject(Logs, { source });
const status = useStatus(logs);
const sources = useReadable(logs?.sources) ?? [];
const lines = useReadable(logs?.lines) ?? [];
const [filter, setFilter] = useState("");
const [follow, setFollow] = useState(true);
const [message, setMessage] = useState<string>();
const area = useRef<HTMLPreElement>(null);
const nav = useNavigation();
const query = filter.toLowerCase();
const shown = lines.filter((line) => line.text.toLowerCase().includes(query));
const text = shown.map((line) => `${new Date(line.time).toISOString()} [${line.level}] ${line.text}`).join("\n");
// biome-ignore lint/correctness/useExhaustiveDependencies: New rendered lines must trigger tail-following after the DOM updates.
useEffect(() => {
if (follow && area.current) area.current.scrollTop = area.current.scrollHeight;
}, [follow, text]);
useEffect(() => {
let current = true;
void (logs ? whenLive(logs) : Promise.resolve()).catch((error: unknown) => {
if (current) setMessage(errorText(error));
});
return () => {
current = false;
};
}, [logs]);
const choices = [...new Set(["server", "terminals", "dev", source, ...sources.map((entry) => entry.id)])];
return (
<Sheet title="Logs" onClose={closeLayers} className="logs-sheet">
<div className="logs-controls">
<label className="label" htmlFor="logs-source">
Source
</label>
<select
id="logs-source"
className="input"
value={source}
onChange={(event) => {
const value = event.target.value;
void nav.replace({
...nav.route,
layers: nav.route.layers.map((layer, index) =>
index === nav.route.layers.length - 1 ? { kind: "sheet", name: "logs", locator: value } : layer,
),
});
}}
>
{choices.map((id) => (
<option key={id} value={id}>
{sourceLabel(id)}
</option>
))}
</select>
<input
className="input"
aria-label="Filter logs"
placeholder="Filter logs"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
<div className="logs-actions">
<label>
<input type="checkbox" checked={follow} onChange={(event) => setFollow(event.target.checked)} />{" "}
Follow tail
</label>
<button
type="button"
className="text-action"
onClick={() => {
void (
navigator.clipboard?.writeText(text) ?? Promise.reject(new Error("Clipboard unavailable"))
).then(
() => setMessage("Copied"),
() => setMessage("Could not copy logs"),
);
}}
>
Copy
</button>
</div>
</div>
{message && <output className="notice">{message}</output>}
<pre ref={area} className="logs-output" title="Log lines">
{text ||
(status === "live"
? "No log lines"
: status === "failed" || status === "ended"
? "Logs unavailable"
: "Loading logs…")}
</pre>
</Sheet>
);
}
function LogsButton() {
const sheet = useSheet("logs");
if (!useOwner()) return null;
return (
<>
<button
type="button"
className="foot-button"
onClick={() => openLayer({ kind: "sheet", name: "logs" }, { fromDrawer: true })}
>
Logs
</button>
{sheet && <LogsSheet source={sheet.locator ?? "server"} />}
</>
);
}
export default defineFacet((host) => {
contribute(host, "p2.web-logs", (self) => {
self.contribute(Slots, { "sidebar-foot:logs": LogsButton });
});
});