Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions apps/web/THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,14 @@ Copyright (c) 2016 Roberto Huertas

Licensed under the MIT License. The full license text is available in the
upstream repository: <https://github.com/vscode-icons/vscode-icons/blob/master/LICENSE>.

## mindwalk

The 3D scene, playback, and trace/citymap type code under `src/mindwalk/` is
ported from the [`mindwalk`](https://github.com/cosmtrek/mindwalk) project's
`web/src` frontend (vendored in this repo at `.repos/mindwalk`).

Copyright (c) 2026 Ricko Yu

Licensed under the MIT License. The full license text is available in the
upstream repository: <https://github.com/cosmtrek/mindwalk/blob/master/LICENSE>.
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0",
"three": "^0.182.0",
"zustand": "^5.0.11"
},
"devDependencies": {
Expand All @@ -56,6 +57,7 @@
"@types/babel__core": "^7.20.5",
"@types/react": "~19.2.14",
"@types/react-dom": "~19.2.3",
"@types/three": "^0.182.0",
"@vercel/config": "^0.3.0",
"@vitejs/plugin-react": "^6.0.0",
"babel-plugin-react-compiler": "1.0.0",
Expand Down
98 changes: 98 additions & 0 deletions apps/web/src/mindwalk/MindwalkHarness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { ThreadId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import { useEffect, useMemo, useState } from "react";

import { PrimaryEnvironmentHttpClient } from "../environments/primary/httpClient";
import { runPrimaryHttp } from "../lib/runtime";
import { PlaybackEngine } from "./playback/reducer";
import { CityScene } from "./scene/CityScene";
import { TreeScene } from "./scene/TreeScene";
import type { CityMap } from "./types";
import "./harness.css";

type View = "tree" | "terrain";

/**
* Throwaway harness proving the verbatim-ported mindwalk scenes render a T3
* citymap. Static-map baseline only: no trace, so playback stays empty and
* CityScene runs in `locHeights` mode — the equivalent of mindwalk's `?map=1`.
* The real surface (playback deck, HUD, restyle) is later map work.
*/
export default function MindwalkHarness({ threadId }: { threadId: string }) {
const [city, setCity] = useState<CityMap | undefined>();
const [error, setError] = useState<string | undefined>();
const [view, setView] = useState<View>("terrain");
const [selectedPath, setSelectedPath] = useState<string | undefined>();

useEffect(() => {
// Clear first: losing the threadId must drop the previous citymap rather
// than leave it on screen behind the "pass a threadId" prompt.
setCity(undefined);
setError(undefined);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (!threadId) return;
let cancelled = false;
runPrimaryHttp(
PrimaryEnvironmentHttpClient.pipe(
Effect.flatMap((client) =>
client.citymap.threadCitymap({
params: { threadId: threadId as ThreadId },
headers: {},
}),
),
),
).then(
// The wire shape is byte-identical to mindwalk's CityMap by design (see
// packages/contracts/src/citymap.ts); the cast only drops readonly.
(map) => {
if (!cancelled) setCity(map as unknown as CityMap);
},
(cause: unknown) => {
if (!cancelled) setError(String(cause));
},
);
return () => {
cancelled = true;
};
}, [threadId]);

const playback = useMemo(() => new PlaybackEngine(undefined, city).snapshotAt(-1), [city]);
const selection = selectedPath === undefined ? {} : { selectedPath };

return (
<div className="mindwalk-harness bg-[#12151c]">
{city ? (
view === "tree" ? (
<TreeScene city={city} playback={playback} onSelect={setSelectedPath} {...selection} />
) : (
<CityScene
city={city}
playback={playback}
onSelect={setSelectedPath}
locHeights
{...selection}
/>
)
) : (
<div className="flex h-full items-center justify-center text-sm text-white/60">
{error
? `Citymap failed: ${error}`
: threadId
? "Building the map…"
: "Pass ?threadId=<id> to load a citymap."}
</div>
)}
<div className="absolute top-3 left-3 z-40 flex gap-1 rounded-md bg-black/50 p-1 text-xs text-white">
{(["terrain", "tree"] as const).map((candidate) => (
<button
key={candidate}
type="button"
onClick={() => setView(candidate)}
className={`rounded px-2 py-1 ${view === candidate ? "bg-white/20" : "hover:bg-white/10"}`}
>
{candidate}
</button>
))}
</div>
</div>
);
}
13 changes: 13 additions & 0 deletions apps/web/src/mindwalk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# mindwalk port

`scene/`, `playback/`, and `types.ts` are copied near-verbatim from mindwalk's
`web/src` (MIT, © 2026 Ricko Yu — see `../../THIRD_PARTY_NOTICES.md`), vendored
at `.repos/mindwalk` at the revision this copy was taken from.

Verbatim on purpose: the squarified-treemap math, instanced-mesh buffer
indexing, and colour handling in this code render _almost_ right when
mistranslated, and that class of bug costs days to bisect. Deviations are
limited to what this repo's stricter tsconfig forces (non-null assertions and
casts, all erased at runtime) plus the pre-commit `vp fmt` autoformat, and each
must preserve behavior. Diff against `.repos/mindwalk/web/src` before editing
anything here.
43 changes: 43 additions & 0 deletions apps/web/src/mindwalk/harness.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/* Minimal load-bearing styling for the ported scenes, lifted from mindwalk's
* styles.css: the scenes absolutely fill their host and the hover tip is a
* floating chip. Values are mindwalk's; restyling to T3 tokens is later work. */
.mindwalk-harness {
--hairline-strong: oklch(0.46 0.016 252);
--ink: oklch(0.91 0.014 88);
--muted: oklch(0.72 0.02 252);
--text-xs: 0.75rem;
position: relative;
height: 100%;
overflow: hidden;
}

.mindwalk-harness .city-scene {
position: absolute;
inset: 0;
}
.mindwalk-harness .city-scene canvas {
display: block;
}

.mindwalk-harness .scene-tip {
position: absolute;
z-index: 30;
display: none;
max-width: 380px;
padding: 5px 9px;
border: 1px solid var(--hairline-strong);
border-radius: 4px;
background: oklch(0.245 0.014 256 / 0.97);
color: var(--ink);
font-size: var(--text-xs);
letter-spacing: 0.01em;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
font-variant-numeric: tabular-nums;
}
.mindwalk-harness .scene-tip .dim {
color: var(--muted);
}
166 changes: 166 additions & 0 deletions apps/web/src/mindwalk/playback/recorder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Client-side video export: capture the live WebGL canvas with MediaRecorder
// while driving the playhead from the first event to the last, then hand back a
// webm Blob. No server, no extra dependency — captureStream + MediaRecorder are
// native browser APIs.

export interface RecordOptions {
canvas: HTMLCanvasElement;
total: number; // number of playback events (trace.events.length)
setSeq: (seq: number) => void; // drives the store's currentSeq
fps?: number; // capture frame rate
durationMs?: number; // wall-clock length of the exported run
onProgress?: (fraction: number) => void; // 0..1
signal?: AbortSignal; // cancel an in-flight recording
}

export interface RecordResult {
blob: Blob;
extension: string; // matches the recorded container (webm / mp4 / …)
}

// Map a MediaRecorder mimeType to a sensible file extension. Falls back to the
// container subtype when it's an unknown-but-plausible type.
function extensionForMime(mimeType: string): string {
const base = mimeType.split(";")[0]!.trim().toLowerCase(); // strip codecs
const subtype = base.split("/")[1] ?? "";
if (subtype.includes("webm")) return "webm";
if (subtype.includes("mp4")) return "mp4";
if (subtype.includes("x-matroska") || subtype.includes("matroska")) return "mkv";
if (subtype.includes("ogg")) return "ogv";
return subtype || "webm";
}

const DEFAULT_FPS = 30;
// Long sessions would otherwise produce minutes of video; cap the run and let
// the playhead cover more events per frame. Short sessions play close to
// real-ish time via the per-event floor.
const MAX_DURATION_MS = 30_000;
const PER_EVENT_MS = 120;

function pickMimeType(): string | undefined {
const candidates = ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"];
for (const type of candidates) {
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(type)) return type;
}
return undefined;
}

export function recordingSupported(): boolean {
return (
typeof MediaRecorder !== "undefined" &&
typeof HTMLCanvasElement !== "undefined" &&
typeof HTMLCanvasElement.prototype.captureStream === "function"
);
}

// Records the playback and resolves with the recorded blob and a file extension
// that matches the actual container. Rejects if the browser can't record or the
// run is aborted.
export function recordPlayback(opts: RecordOptions): Promise<RecordResult> {
const { canvas, total, setSeq, onProgress, signal } = opts;
const fps = opts.fps ?? DEFAULT_FPS;

if (!recordingSupported()) {
return Promise.reject(
new Error("This browser can't record the canvas (MediaRecorder unavailable)."),
);
}
if (total <= 0) {
return Promise.reject(new Error("Nothing to record — load a session first."));
}

const durationMs =
opts.durationMs ?? Math.min(MAX_DURATION_MS, Math.max(1000, total * PER_EVENT_MS));
const mimeType = pickMimeType();
const stream = canvas.captureStream(fps);
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
const chunks: BlobPart[] = [];
Comment on lines +75 to +77

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium playback/recorder.ts:75

canvas.captureStream(fps) is called before the returned Promise is constructed, so if it throws (e.g., the canvas is not origin-clean), the exception propagates synchronously instead of rejecting the promise returned by recordPlayback. Callers using recordPlayback(...).catch(...) cannot handle this failure. Move the stream and recorder setup inside the Promise executor so these errors reject as expected.

+  return new Promise<RecordResult>((resolve, reject) => {
+    let rafId = 0;
+    let startTs = 0;
+    let settled = false;
+
+    const fps = opts.fps ?? DEFAULT_FPS;
+    const stream = canvas.captureStream(fps);
+    const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
+    const chunks: BlobPart[] = [];
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/mindwalk/playback/recorder.ts around lines 75-77:

`canvas.captureStream(fps)` is called before the returned `Promise` is constructed, so if it throws (e.g., the canvas is not origin-clean), the exception propagates synchronously instead of rejecting the promise returned by `recordPlayback`. Callers using `recordPlayback(...).catch(...)` cannot handle this failure. Move the stream and recorder setup inside the `Promise` executor so these errors reject as expected.


return new Promise<RecordResult>((resolve, reject) => {
let rafId = 0;
let startTs = 0;
let settled = false;

const cleanup = () => {
if (rafId) cancelAnimationFrame(rafId);
stream.getTracks().forEach((track) => track.stop());
signal?.removeEventListener("abort", onAbort);
};

const finish = () => {
if (settled) return;
settled = true;
cleanup();
// the browser may have chosen a container different from what we
// requested; trust recorder.mimeType so the blob type and file extension
// match what was actually recorded
const actualType = recorder.mimeType || mimeType || "video/webm";
resolve({
blob: new Blob(chunks, { type: actualType }),
extension: extensionForMime(actualType),
});
};

const fail = (err: Error) => {
if (settled) return;
settled = true;
cleanup();
try {
if (recorder.state !== "inactive") recorder.stop();
} catch {
// recorder already torn down
}
reject(err);
};

const onAbort = () => fail(new Error("Recording cancelled."));

recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) chunks.push(e.data);
};
recorder.onerror = () => fail(new Error("Recording failed."));
recorder.onstop = finish;

if (signal) {
if (signal.aborted) {
fail(new Error("Recording cancelled."));
return;
}
signal.addEventListener("abort", onAbort);
}

const tick = (ts: number) => {
if (settled) return;
if (!startTs) startTs = ts;
const fraction = Math.min(1, (ts - startTs) / durationMs);
const seq = Math.min(total - 1, Math.floor(fraction * (total - 1)));
setSeq(seq);
onProgress?.(fraction);
if (fraction >= 1) {
// let the final frame paint before closing the recorder
rafId = requestAnimationFrame(() => {
if (recorder.state !== "inactive") recorder.stop();
});
return;
}
rafId = requestAnimationFrame(tick);
};

setSeq(0);
recorder.start();
rafId = requestAnimationFrame(tick);
});
}

// Triggers a browser download of the recorded Blob.
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// revoke on the next tick so the download has a chance to start
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
Loading