Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e5187ce
chore: add `prose-observer` library scaffold
celom May 19, 2026
def3b32
chore: upgrade nx to 22.7.2 and add react/react-dom dependencies
celom May 19, 2026
b47b266
chore: add `console` application scaffold
celom May 19, 2026
64e343a
feat(prose-observer): slice 1 — package skeleton + event types
celom May 19, 2026
c0dbf79
feat(prose-observer): slice 2 — observer impl, ring buffer, redaction…
celom May 19, 2026
99009cd
feat(prose-observer): slice 3 — catalog aggregation over the ring buffer
celom May 19, 2026
e2357d9
feat(prose-observer): slice 4 — HTTP + WS server with JSON API
celom May 19, 2026
8a671c8
feat(console): slice 5 — SPA shell wired to the observer server
celom May 19, 2026
3fbf688
feat(console): slice 6 — Gantt trace view + diff inspector
celom May 19, 2026
c4723af
feat(console): slice 7 — catalog view with drilldown
celom May 19, 2026
3b5ad0d
feat(console): slice 8 — live tail view
celom May 19, 2026
4f04750
feat(prose-observer): slice 9 — bundle SPA into the npm package
celom May 19, 2026
324381c
feat(prose-observer): slice 10 — CLI binary and prose subcommand
celom May 19, 2026
0d8b803
docs(prose-observer): slice 11 — README, docs guide, oversized-state …
celom May 19, 2026
8cb5385
feat(console): redesign UI with dark design system and side rail nav
celom May 19, 2026
a559bb8
refactor(console): group monogram and nav together in side rail
celom May 19, 2026
ae24aee
style: apply nx format:write to fix CI format:check
celom May 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,7 @@ vite.config.*.timestamp*
vitest.config.*.timestamp*

# Astro
.astro/
.astro/
.claude/worktrees
.nx/polygraph
.nx/self-healing
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
/dist
/coverage
/.nx/cache
/.nx/workspace-data
/.nx/workspace-data
.nx/self-healing
12 changes: 12 additions & 0 deletions apps/console/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';

export default [
...nx.configs['flat/react'],
...baseConfig,
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
// Override or add rules here
rules: {},
},
];
24 changes: 24 additions & 0 deletions apps/console/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Prose · Console</title>
<base href="/" />

<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />

<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,300;12..96,400;12..96,500;12..96,600;12..96,700;12..96,800&family=JetBrains+Mono:wght@300;400;500;600;700&display=swap"
/>

<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
5 changes: 5 additions & 0 deletions apps/console/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "@celom/console",
"version": "0.0.1",
"private": true
}
Binary file added apps/console/public/favicon.ico
Binary file not shown.
66 changes: 66 additions & 0 deletions apps/console/src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type {
ExecutionRecord,
ExecutionSummary,
FlowAggregate,
ObserverEvent,
} from '@celom/prose-observer';

/**
* WS `/stream` payloads: every `ObserverEvent` plus the backpressure
* heartbeat the server emits when it drops oldest queued entries.
*/
export type StreamMessage = ObserverEvent | { type: 'dropped'; count: number };

export async function listExecutions(): Promise<ExecutionSummary[]> {
const res = await fetch('/api/executions');
if (!res.ok) throw new Error(`listExecutions failed: ${res.status}`);
return (await res.json()) as ExecutionSummary[];
}

export async function fetchExecution(
correlationId: string
): Promise<ExecutionRecord | null> {
const res = await fetch(
`/api/executions/${encodeURIComponent(correlationId)}`
);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`fetchExecution failed: ${res.status}`);
return (await res.json()) as ExecutionRecord;
}

export async function listFlows(): Promise<FlowAggregate[]> {
const res = await fetch('/api/flows');
if (!res.ok) throw new Error(`listFlows failed: ${res.status}`);
return (await res.json()) as FlowAggregate[];
}

/**
* Open a WS subscription to `/stream`. Returns a `close()` function — call
* it from `useEffect` cleanup.
*
* Reconnects are deliberately NOT handled here in v1; the catalog/trace
* views poll-on-mount, so a transient stream drop just means the live tail
* stops updating until the user reloads.
*/
export function connectStream(
onEvent: (event: StreamMessage) => void,
onError?: (err: Event) => void
): () => void {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${window.location.host}/stream`);
ws.onmessage = (event) => {
try {
onEvent(JSON.parse(event.data) as StreamMessage);
} catch {
// Malformed frames are silently dropped — the server only emits JSON.
}
};
if (onError) ws.onerror = onError;
return () => {
try {
ws.close();
} catch {
// ignore
}
};
}
26 changes: 26 additions & 0 deletions apps/console/src/app/app.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

import App from './app';

describe('App', () => {
it('renders the navigation header', () => {
render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>
);
expect(screen.getByRole('link', { name: 'Prose Console' })).toBeTruthy();
expect(screen.getByRole('link', { name: 'catalog' })).toBeTruthy();
expect(screen.getByRole('link', { name: 'live' })).toBeTruthy();
});

it('prompts for ?correlationId on the trace route when none is set', () => {
render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>
);
expect(screen.getByText(/correlationId/i)).toBeTruthy();
});
});
199 changes: 199 additions & 0 deletions apps/console/src/app/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { useEffect, useState } from 'react';
import { Link, NavLink, Route, Routes, useLocation } from 'react-router-dom';

import { CatalogView } from '../views/Catalog';
import { LiveView } from '../views/Live';
import { TraceView } from '../views/Trace';

export function App() {
return (
<div className="relative min-h-screen">
<SideRail />
<div className="pl-16">
<TopStrip />
<main className="mx-auto max-w-[1320px] px-8 pt-8 pb-24">
<Routes>
<Route path="/" element={<TraceView />} />
<Route path="/catalog" element={<CatalogView />} />
<Route path="/live" element={<LiveView />} />
</Routes>
</main>
<FooterMark />
</div>
</div>
);
}

export default App;

/* ---------- side rail ---------- */

function SideRail() {
return (
<aside
aria-label="primary"
className="fixed inset-y-0 left-0 z-20 flex w-16 flex-col items-center justify-between border-r border-line bg-ink-1/80 py-5 backdrop-blur-sm"
>
<div className="flex flex-col items-center gap-6">
{/* Monogram — also satisfies the "Prose Console" accessible-name link */}
<Link
to="/"
aria-label="Prose Console"
className="group flex h-9 w-9 items-center justify-center rounded-[2px] border border-line-2 bg-ink-2 transition-colors hover:border-signal/60"
>
<span className="monogram text-fg-strong text-[20px] leading-none transition-colors group-hover:text-signal">
p
</span>
</Link>

<span className="my-1 h-6 w-px bg-line" aria-hidden="true" />

<nav className="flex flex-col items-center gap-1.5">
<RailLink to="/" label="trace" glyph="T" />
<RailLink to="/catalog" label="catalog" glyph="C" />
<RailLink to="/live" label="live" glyph="L" />
</nav>
</div>

<StreamStatus />
</aside>
);
}

function RailLink({
to,
label,
glyph,
}: {
to: string;
label: string;
glyph: string;
}) {
return (
<NavLink
to={to}
end={to === '/'}
aria-label={label}
className={({ isActive }) =>
[
'group relative flex h-9 w-9 items-center justify-center rounded-[2px] border transition-colors',
isActive
? 'border-signal/60 bg-signal/10 text-signal'
: 'border-transparent text-mute-2 hover:border-line-2 hover:text-fg',
].join(' ')
}
>
<span
className="font-display text-[16px] leading-none font-bold"
style={{ fontVariationSettings: "'opsz' 24" }}
>
{glyph}
</span>
<span
className="caps absolute top-1/2 left-12 -translate-y-1/2 rounded-sm border border-line-2 bg-ink-2 px-2 py-1 text-[9px] text-fg opacity-0 shadow-lg transition-opacity duration-150 group-hover:opacity-100"
aria-hidden="true"
>
{label}
</span>
</NavLink>
);
}

/* ---------- top instrument strip ---------- */

function TopStrip() {
const location = useLocation();
const segment = sectionFor(location.pathname);
return (
<header
className="hairline-b sticky top-0 z-10 flex items-center gap-6 bg-ink-0/85 px-8 py-3 backdrop-blur-md"
role="banner"
>
<div className="flex items-baseline gap-3">
<span className="caps text-mute">Section</span>
<span className="caps-tight text-fg-strong">{segment.title}</span>
</div>

<span className="text-line-3" aria-hidden="true">
/
</span>

<div className="flex items-baseline gap-3">
<span className="caps text-mute">Scope</span>
<span className="font-mono text-[11px] text-fg">
observer<span className="text-mute">@</span>local
</span>
</div>

<div className="ml-auto flex items-center gap-5">
<Clock />
<span className="text-line-3" aria-hidden="true">
/
</span>
<div className="flex items-center gap-2">
<span className="dot dot-signal animate-pulse-signal" />
<span className="caps text-signal">live</span>
</div>
</div>
</header>
);
}

function sectionFor(path: string): { title: string; key: string } {
if (path.startsWith('/catalog')) return { title: 'Catalog', key: 'catalog' };
if (path.startsWith('/live')) return { title: 'Live tail', key: 'live' };
return { title: 'Execution trace', key: 'trace' };
}

function Clock() {
const [t, setT] = useState<string>(() => fmtClock(new Date()));
useEffect(() => {
const id = setInterval(() => setT(fmtClock(new Date())), 1000);
return () => clearInterval(id);
}, []);
return (
<span
className="num text-[11px] tracking-wider text-mute-2"
aria-label="clock"
>
{t}
</span>
);
}

function fmtClock(d: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(
d.getUTCSeconds()
)} utc`;
}

/* ---------- stream pip in the rail ---------- */

function StreamStatus() {
// Visual-only indicator; the actual SSE/WS connection lives in the Live view.
return (
<div
className="flex flex-col items-center gap-2 pb-1"
aria-hidden="true"
title="stream"
>
<span className="dot dot-mint animate-pulse-signal" />
<span className="caps text-[8px] text-mute">ok</span>
</div>
);
}

/* ---------- foot mark ---------- */

function FooterMark() {
return (
<footer className="pointer-events-none fixed right-6 bottom-4 z-10 hidden items-center gap-2 text-[10px] text-mute md:flex">
<span className="caps">v0.0.1</span>
<span className="text-line-3">·</span>
<span className="font-display text-[12px] font-semibold tracking-tight text-fg/70">
prose
</span>
</footer>
);
}
Empty file.
Loading
Loading