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
5 changes: 1 addition & 4 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,8 @@
"@thinkrail/contracts": "workspace:*",
"@xterm/addon-clipboard": "0.2.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-image": "0.9.0",
"@xterm/addon-ligatures": "0.10.0",
"@xterm/addon-serialize": "0.14.0",
"@xterm/addon-unicode11": "0.9.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/addon-web-fonts": "0.1.0",
"@xterm/xterm": "6.0.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/chat/HistoryOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -421,10 +421,12 @@ export function HistoryOverlay({
// with focus anywhere — `Ctrl+R` by `shell/useGlobalHotkeys` (which routes a scope cycle back through
// `ChatView` while the overlay is open), Escape by the window listener above.
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
if ((e.metaKey || e.ctrlKey) && e.code === "KeyS") {
// Always swallow — Cmd/Ctrl+S is the browser's own "save page" shortcut. Only a prompt row
// selection actually opens the save-as-template dialog; on a message hit (or none) this is a
// no-op, same as Enter's message-hit gating above.
// Matched by `e.code` (the physical key), not `e.key` (the character): on a Cyrillic layout the S
// key produces `ы`, so a `key`-based guard let the browser's save dialog through.
e.preventDefault();
const item = resolveHistorySelection(stage, result, selected);
if (item?.kind === "prompt") onSaveAsTemplate(item.hit);
Expand Down
9 changes: 3 additions & 6 deletions apps/web/src/chat/tools/visualize/MermaidView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Maximize2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { onThemeSwap } from "@/themes";
import { CodeBlock } from "../CodeBlock";
import { renderMermaid } from "./mermaid";
import { PanZoomView } from "./PanZoomView";
Expand Down Expand Up @@ -33,14 +34,10 @@ export function MermaidView({ source, title }: { source: string; title?: string
setError(null);
run();
// Re-render when the theme flips so token-derived colors stay in sync.
const observer = new MutationObserver(run);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
const stopThemeWatch = onThemeSwap(run);
return () => {
cancelled = true;
observer.disconnect();
stopThemeWatch();
};
}, [source]);

Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/panels/ChangesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ export function ChangesPanel({ workspaceId }: { workspaceId: string }) {
// One rejection has a *meaning*: `UNKNOWN_COMMIT` — the host naming a commit scope whose commit the repo no
// longer has (a rebase, a branch reset). That falls back to the branch scope with a toast, so the panel is
// neither wedged on a dead sha nor silently showing a different scope than the user picked. Every other
// failure (timeout, dropped socket, git error) must leave the chosen scope alone — hence the code, not just
// "the read failed".
// failure (timeout, prolonged network outage, git error) must leave the chosen scope alone — hence the code,
// not just "the read failed".
const { reload } = useWorkspaceRead(
workspaceId,
(id) => getTransport().request("git.status", { workspaceId: id, scope }),
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/panels/MonacoDiff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,23 +33,23 @@ export default function MonacoDiff({
view: "split" | "inline";
ignoreWhitespace: boolean;
}) {
const observerRef = useRef<MutationObserver | null>(null);
const stopThemeWatchRef = useRef<(() => void) | null>(null);
// The diff widget + its two TextModels, captured at mount so our unmount cleanup can dispose them in the
// right order — see below.
const editorRef = useRef<MonacoDiffEditor | null>(null);
const modelsRef = useRef<{ dispose(): void }[]>([]);

// Mirrors MonacoEditor's observer: follow atomic `[data-theme]` swaps while mounted.
// Follows atomic `[data-theme]` swaps while mounted, via the themes module's shared watcher.
const onMount: DiffOnMount = (editor, m) => {
observerRef.current = watchThemeSwap(m, THEME);
stopThemeWatchRef.current = watchThemeSwap(m, THEME);
editorRef.current = editor;
const model = editor.getModel();
modelsRef.current = model ? [model.original, model.modified] : [];
};

useEffect(
() => () => {
observerRef.current?.disconnect();
stopThemeWatchRef.current?.();
// Dispose the diff *widget* before its TextModels. Disposing a model while a live widget still
// references it trips Monaco 0.52+'s "TextModel got disposed before DiffEditorWidget model got
// reset" assertion (@monaco-editor/react#647 / monaco-editor#4779, unfixed in 4.7.0), and
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/panels/MonacoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ const beforeMount: BeforeMount = (m) => defineThinkrailTheme(m);

/** Read-only file viewer; language is inferred from `path`. Editing + save land with `fs.writeFile`. */
export default function MonacoEditor({ path, content }: { path: string; content: string }) {
const observerRef = useRef<MutationObserver | null>(null);
const stopThemeWatchRef = useRef<(() => void) | null>(null);

// Mirrors TerminalInstance's observer: follow atomic `[data-theme]` swaps while mounted.
// Follows atomic `[data-theme]` swaps while mounted, via the themes module's shared watcher.
const onMount: OnMount = (_editor, m) => {
observerRef.current = watchThemeSwap(m, EDITOR_THEME);
stopThemeWatchRef.current = watchThemeSwap(m, EDITOR_THEME);
};

useEffect(() => () => observerRef.current?.disconnect(), []);
useEffect(() => () => stopThemeWatchRef.current?.(), []);

return (
<MonacoReact
Expand Down
25 changes: 22 additions & 3 deletions apps/web/src/panels/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,12 @@ arrangement (so the mobile shell is an additive layer, not a rewrite).
with no yes/no follow-up. The hook returns a `dialogs` node each consumer renders. **Selecting a
project** (clicking its row — the chevron expands/collapses separately) **deselects any active
workspace**, so the shell returns to that project's Welcome — a deliberate "project home" gesture; the
workspace's tabs survive in the store, so re-selecting it restores its view. Also
workspace's tabs survive in the store, so re-selecting it restores its view. **Terminals need more than the
store to survive that round trip**, since the whole workspace surface — `TerminalsPanel` included —
unmounts: each instance therefore **detaches** its PTY instead of closing it
(`detachedPtyByClientId` in `TerminalInstance`) and the next mount re-adopts the same shell, so a
long-running process is never silently killed by a project-home gesture. Only a *closed tab* kills its PTY.
The painted scrollback does not survive (a remount is a fresh xterm buffer); the process does. Also
`FileTree`, `SpecsPanel`, `RightPanel`,
`ChangesPanel` (the changed files under a header that says **what** is being diffed — the
**`ChangesScopeMenu`** scope pill + the shared **`BranchPicker`** target-branch pill — plus the
Expand Down Expand Up @@ -378,7 +383,7 @@ a project picker, the prompt hero, and the reused
`${scopeKey}:${targetRef}`, so switching the diff scope or re-pointing the target branch resets and
re-reads exactly like a workspace switch, and one scope's list can never linger under another. `onFailure`
receives **the rejection**, not just the workspace id: a caller that reacts to one *named* failure (see the
vanished-commit rule below) must be able to tell it from a timeout or a dropped socket.
vanished-commit rule below) must be able to tell it from a timeout or an unnamed host failure.
The one read that deliberately does **not** go through this hook is `ChangesScopeMenu`'s lazy pair — they
are *open*-triggered, not tick-triggered — so the menu is instead **keyed by its full identity,
`(workspaceId, targetRef)`**: its commit rows are `git log <base>..HEAD`, so re-pointing the target changes
Expand Down Expand Up @@ -498,7 +503,7 @@ a project picker, the prompt hero, and the reused
a sentence in a rail header squeezes the sibling target-branch pill down to an ellipsis. A scope naming a commit the repo no longer has (rebase, branch reset) makes
the host reject `git.status` with the **named** code `UNKNOWN_COMMIT` (`wsErrorCode`), and *that* rejection —
and only that one — **resets to the branch scope with a toast** rather than staying wedged on a dead sha.
Every other failure (timeout, dropped socket, git error) leaves the user's chosen scope alone, keeps the
Every other failure (timeout, prolonged network outage, git error) leaves the user's chosen scope alone, keeps the
last good list, and says so once per failing streak: silently swapping the scope on a network blip is a
worse lie than a stale list. The code exists precisely because "the read failed" cannot distinguish the two.
- **"Never answered", "failed", and "answered empty" are three states, never two.** The panel holds the
Expand Down Expand Up @@ -699,6 +704,20 @@ a project picker, the prompt hero, and the reused
nullable editor selection-foreground override when provided. `MonacoDiff` re-themes exactly like
`MonacoEditor` — both consume `monacoSetup.ts`'s define + observer, so a palette swap lands in the
diff tab too.
- **Terminal renderer + font measurement.** `TerminalInstance` runs xterm's **default DOM renderer** on
purpose — `addon-webgl` is *not* loaded, and loading it would be a regression (see `architecture.md`
Decision #11: the DOM renderer is a prerequisite for touch, and `WebglAddon.dispose()` leaks its WebGL2
context, which our per-worktree terminal churn would hit). Addons are exactly `fit`, `clipboard`,
`unicode11` and `web-fonts`; anything else pinned but unimported is dead weight and a trap for the next
reader. `web-fonts` is load-bearing rather than cosmetic: our code font ships as per-alphabet woff2 subsets,
so the Cyrillic/CJK file lands *after* xterm has measured the character cell (which it does once, at
construction, and never again — unlike Monaco, which re-measures an untrusted early reading). Without the
re-measure, non-Latin glyphs render into cells sized for the fallback font and the PTY holds the wrong
cols/rows; the panel drives `relayout()` itself so it knows when to re-`fit()`. Its pre-bind output buffer is
a bounded waiting state: successful bind filters it to the adopted PTY, while permanent creation failure
clears it and stops accepting page-wide terminal frames. PTY sizing distinguishes desired, in-flight, and
host-acknowledged grids; only a successful `terminal.resize` advances the acknowledgement, so reconnect
replay cannot leave a full-screen app permanently sized to a request the host never applied.
- Heavy deps (Monaco / shiki / xterm) load via `React.lazy(() => import())` to stay out of the eager bundle.
A lazy chunk that fails to load (or a render throw) is contained by the `components/ErrorBoundary` the
**shell** wraps each region in (see `shell/SPEC.md`), so a single panel degrades instead of blanking the
Expand Down
Loading
Loading