mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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.
139 lines
4 KiB
TypeScript
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 });
|
|
});
|
|
});
|