4e2db3dc2f1273e55b5b505a65d6a56f80e4e78d
630
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4c24df93c1 |
a chat can change model mid-conversation
the selector locked on `hasStarted`, so the tier was fixed for the life of a conversation. nothing justified it — the commit that added the flag says only "default model" — and the hazard it resembles is narrower than what it blocked: crossing HARNESS hands an opencode `ses_…` id to `claude --resume`, which reads as a dead agent rather than a wrong dispatch. changing TIER was always safe. so tier is free, harness is pinned, and the server refuses a crossing rather than relying on the UI to. the claude side does it the way the CLI's /model does: `Query.setModel()`, a control request that needs streaming input mode, which is the only mode this file uses. nothing is torn down and nothing is re-resumed — cwd, permission mode and transcript all continue and only the next response changes. an earlier plan here was to kill the session and re-create it with the stored resume id; that would have worked and was unnecessary. opencode already switched per turn (serve-runner POSTs the model to the live session), so only the UI was in its way. FIXES A SILENT MISMATCH while it is here. model reached the SDK once, in createSession's query() options, and every later turn was pushed onto that same call — so a changed model was dropped while session:init, the cost fold and the durable log all reported the new tier. reachable today by leaving a chat idle past the 30-minute GC: the UI said sonnet, opus answered, nothing logged it. the fold now tracks what the harness actually did rather than what the client asked for, and a refused switch reverts the picker. the switch is a durable `model:switched` event and a centred marker in the transcript, because the useful question is the BOUNDARY — which half of this conversation is opus. per-message model was already stamped server-side and shown nowhere, and claude-sessions.ts reads the FIRST model in a transcript, so a switch was invisible in history. tests: turn-stream proves the marker is durable and lands after the previous model's last words; the selector tests pull in opposite directions, so unlocking everything fails one and restoring the blanket lock fails the other. verified by re-breaking both. not done: opencode's `steer` path (a send mid-turn) returns before the model POST, so a switch there applies on the next full turn. session.model also stays stale for opencode tier changes — harness routing is unaffected. |
||
|
|
0d29e242a3 |
themes are installable, like plugins
`platform/themes/<id>/` — untracked, same rule as `plugins/`, only the instructions
tracked. Drop a directory in and the theme appears in the picker on the next page load.
themes/my-theme/theme.css required — `[data-theme='my-theme'] { … }`
themes/my-theme/theme.json required — { name, family, dark }
themes/my-theme/assets/ optional — wallpaper, logo
── Why this is cheaper than installing a plugin ──
A plugin is code: it needs a manifest module, mounting, and a frontend rebuild to reach
the bundler. A theme is a block of CSS custom properties, so it needs none of that. The
server reads the directory at request time and the client adds a `<link>`. No install
step, no rebuild, no restart — which is the whole reason this is eighty lines of
discovery rather than four hundred.
The id is the DIRECTORY NAME, not a manifest field, for the same reason a plugin's
`appName` is not one: an id that can disagree with where its files sit eventually will.
Discovery checks that the CSS actually targets its own directory name, because the
failure is otherwise silent — the theme appears, is selected, and nothing changes.
── Problems are reported, not swallowed ──
A malformed theme returns a reason rather than vanishing:
broken-probe: theme.css does not target [data-theme='broken-probe']
The client logs those, so an author has somewhere to look instead of wondering why
their theme never appeared.
── Boundaries kept ──
The `theme` package still performs no IO. It gained an `extraThemes` prop; Officer
fetches and injects, exactly as it supplies the panel registry to `workspaces`. A
failure to fetch is not an error — no server, no session, no directory all mean "you get
the built-in themes", which is a complete product.
`/api/themes` is a `core` capability: read-only, and not deniable, because an account
that cannot read the theme list renders unstyled. Installing remains a directory on
disk; an upload endpoint would be a way to write arbitrary CSS into the page from a
request.
Verified in-process: listing, the problem report, CSS served as text/css, and a
traversal attempt answered 404.
── Needs a restart ──
Server-side change. Until then the client's fetch 404s and silently keeps the built-ins,
so nothing breaks in the meantime.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
54f63b70df |
arrows are the only global binding
`ctrl+shift+h`/`l` are gone. The arrows do that job, and two spellings for one action is what invited the reasonable guess that `ctrl+shift+j` moved down — which opens the browser console. Worth recording that the reported conflict was not one we had: nothing bound `ctrl+shift+j`. The global letters were `h` and `l` only, and the `j`/`k` keys live inside the list with NO modifier. But an interface that makes someone guess a wrong key is the interface's fault, not the guess's. Inside the list the vim keys stay, unmodified: `j`/`k`/`l`/`h` beside `↓`/`↑`/`→`/`←`. Nothing there can collide with a browser shortcut, and this is a vim person's application. Verified: `ctrl+shift+l` no longer cycles, `ctrl+shift+→` does, and `j` then `l` still browses inside the list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f492905023 |
one modifier, and arrows beside the vim keys
Standardised on `ctrl+shift` for all three global bindings, and added arrows everywhere the vim keys work — `h`/`←`, `l`/`→`, `j`/`↓`, `k`/`↑` are now interchangeable. ctrl+shift+→ or ctrl+shift+l next theme ctrl+shift+← or ctrl+shift+h previous theme ctrl+shift+space the list ── Why ctrl+shift and not the alternatives ── Checked GNOME's actual defaults rather than reasoning from convention: <Control><Alt>Left/Right switch workspace <Control><Shift><Alt>Left/Right move window to workspace <Super><Alt>Left/Right switch workspace So ctrl+alt is unusable — those arrows change your workspace, and `ctrl+alt+l` locks the screen, which is presumably why the original bindings put the letters on ctrl+shift in the first place. `shift+alt` is free on Linux but is the keyboard-layout switcher on Windows and `alt+space` opens the window menu. `ctrl+shift` has no OS binding at all. Its one collision is select-by-word inside a text field — which is now explicitly protected. ── The guard moved earlier ── Typing was only checked once the list was OPEN. With arrows on the global cycle that was no longer enough: `ctrl+shift+←/→` is select-by-word in every text field, and stealing it mid-sentence to change a colour scheme would be indefensible. The check now runs before the global cycle too. Verified against a focused textarea: theme unchanged, and the word selection still happened. The first run of that test reported a hijack, and the test was wrong — it clicked a selector that matched nothing, swallowed the failure, and read body-focus as a text field. `ctrl+alt+space` still opens the list. It shipped an hour ago and fingers learn faster than they unlearn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4ecd45c4a9 |
themes get their own control, and the vimazing keys
Fourteen themes was too many to keep under an avatar next to "Sign out" — that is where a preference goes when nobody has decided it matters. It now has its own button in the header, between the controls and the avatar, grouped by family. ── The keys, ported from the owner's vimazing ── ctrl+shift+l / ctrl+shift+h cycle forward / back, from anywhere, no UI ctrl+alt+space open the list j / k move the focus l or space apply the focused theme to the app BEHIND the list h back to where you started enter keep what you are looking at escape restore and close The browse is the point, and it is why the overlay is a portal over a 55% backdrop rather than a modal: you are not reading names, you are watching the application change. The theme you started on is captured when the list opens, so `h` and `escape` undo a whole browse however many previews happened inside it. Typing is still typing: the list ignores j/k/l/h while focus is in an input, textarea, select or anything contenteditable. ── Where it lives ── The key handling is in the theme package — it is behaviour that belongs to themes, and it needs no UI. The list and the button are Officer's, because a dialog would drag a component library into a package that currently has none. Verified by driving the real keyboard in a browser: start duck-dark · ctrl+shift+l -> plain · ctrl+alt+space -> overlay j, l -> tokyo-night (previewed) · escape -> plain, closed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ba0c8600b8 |
chat: render images in a turn, and stop mangling the ones that were already links
Remote images were broken in the web chat and the failure looked like a network fault. It was a rewrite. injectImages scans a turn for absolute file paths ending in an image extension and points them at the file browser — good behaviour, since an agent that writes /tmp/shot.png should show it rather than make you go and open it. But 'https://host/images/duck.png' CONTAINS '/images/duck.png', so a perfectly good markdown image became  scheme stripped, host read as a directory. Every remote image failed. injectImages now skips regions that are already links — !?[..](..) alongside the code fences it already skipped — and refuses any match preceded by ':' or '/', or starting with '//', since those are the tail or the whole of a URL somebody wrote deliberately. Moved to chat-images.ts with tests, because a regex with three exceptions is exactly the thing that regresses silently. Rendering added with it: markdown images now go through ChatImage, capped at 14rem HIGH — not wide, so a wide screenshot and a tall phone capture take comparable room — and open full size in a dialog on click. A failure names the alt text and the URL, because the useful question is always which host did not answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8fa3f7da3e |
hex everywhere, because the palettes were half broken
Reported as "the ellipsis doesn't show in the streaming bubble". It was the visible corner of something much larger: every Tailwind semantic utility was broken under all eleven ported palettes. `@theme` mapped `--color-muted-foreground: hsl(var(--muted-foreground))`, which needs an unwrapped triplet. A ported palette publishes `#6e6a86`, so the mapping produced `hsl(#6e6a86)` — invalid — and the utility fell back to white or transparent: duck-dark text-muted-foreground=rgb(148,163,184) bg-muted=rgb(30,41,59) rose-pine text-muted-foreground=rgb(255,255,255) bg-muted=rgba(0,0,0,0) The themes looked convincing because panels, the page and the header read their variables DIRECTLY rather than through a utility class. Everything that went through Tailwind was quietly wrong, and the thinking dots — `bg-muted-foreground/60` — were simply invisible. So: 123 HSL triplets converted to hex across the three hand-written themes, and 37 `hsl(var(--x))` mappings unwrapped. One format everywhere, which is also the format every published palette ships in. The triplets only ever existed so `bg-card/50` could get opacity. That was tested earlier tonight and turned out unnecessary — Tailwind v4 emits `color-mix(in oklab, var(--card) 50%, transparent)` for any colour format. Having proved it, I then left the mappings wrapped, which is what broke the palettes. Verified across six themes: `bg-muted` and `bg-muted-foreground/60` resolve in every one of them, and Duck's values are unchanged through the conversion — `bg-muted` is `rgb(30,41,59)` before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
94a55fe679 |
panel chrome stops being black
Reported against a dark theme: the panel header's title, zoom readout and controls were hardcoded `text-black`, `text-black/60`, `hover:bg-black/10` and `border-black/10`. Legible on Duck's pale glass, invisible on every palette theme — which is the whole class of bug the theme work exists to remove, sitting in the one component I moved between packages and did not re-read. Three derived tokens, so no theme has to think about them: --panel-header-fg-dim the header's ink at 60% (the zoom readout) --panel-header-border the header's ink at 15% (the rule under it) --panel-control-hover the header's ink at 12% (control hover) The swap, drop-target and app-picker chrome used `duck-teal` and `text-white` directly. Those now use `--panel-accent` and a new `--panel-accent-fg`, so a chip on the accent stays readable whatever the accent is. Verified across five themes rather than by reading: duck-dark ink=rgb(248,250,252) duck-light ink=rgb(2,8,23) rose-pine ink=rgb(224,222,244) gruvbox ink=rgb(235,219,178) catppuccin-latte ink=rgb(76,79,105) Two comments corrected while there: both asserted the chrome "is black throughout", which was true when written and is now the opposite of true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fd78495b5a |
every theme declares the mark
The logo is now stated by each of the fourteen themes rather than supplied by a default none of them mentions. The base default stays empty, which is the right answer for a theme written by a stranger — showing nothing beats showing someone else's brand. Explicit costs one line and buys two things: a theme that ships a brand says so, and a white-labelled build replaces a line it can see instead of hunting for the default that was quietly doing the work. Verified in the browser across four themes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b106f67288 |
twenty tokens is the contract; eleven palettes prove it
Officer now ships fourteen themes. Eleven of them were written by other people, ported unchanged, and none of them knows that panels, docks, glass, grids or scrollbars exist. ── What changed ── `base.css` derives every surface from the standard tokens: a panel is `card` at 92%, the header is `card` at 85%, grid lines are `border` faded, scrollbars are `muted-foreground`. Those defaults sit on `:root`; a theme sits on `[data-theme='x']` and both land on <html>, so a theme's own declaration simply outranks them. No machinery. State tones are defaulted too — shadcn's vocabulary has `destructive` and nothing else, and green-means-good is a convention rather than a palette decision. That is what drops the required contract from 29 to 20: exactly the tokens every published shadcn palette already ships. So there are now three tiers, and an author picks one: palette 20 tokens Tokyo Night, Gruvbox, Nord — surfaces derived textured + a few surfaces glass, blur, radius, a wallpaper of its own white-label + assets its own logo and background Duck is tier three and is untouched — verified by probe: it still reports `panel=#ffffff1f`, `blur=24px` and its own grid, because its declarations outrank the new defaults. ── Verified before committing, not assumed ── The whole HSL-triplet design existed to make `bg-card/50` work. Tested it: Tailwind v4 emits `color-mix(in oklab, var(--card) 50%, transparent)` for a HEX token, with a `@supports` fallback. The unwrapped triplets were never necessary, and the ported palettes are plain hex. Six themes probed in the browser: each derives panels from its own card colour, including the light one (Catppuccin Latte). ── Credit ── The eleven palettes and the idea of a small standard contract come from the owner's `vimazing` repository, which solved this more simply than we had. What Officer adds is the surface layer underneath, so a palette gets a textured app for free. ── One open decision ── `--logo-src` defaults to empty, so a palette theme shows no mark at all. Correct for a third-party theme; probably wrong for Officer, where the alternative is defaulting to the officer.dev logo and letting a white-label theme replace it. One line either way. tsgo clean, suite unchanged, builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
571deac731 |
one react-resizable-panels, and honest metadata for the peers
Two dependency problems, and only one of them was real. ── Real: two majors of the same library ── The root asked for `react-resizable-panels@^4.0.15` (4.6.5) while `src/workspaces/components` asked for `^2.1.7` (2.1.9). The workspace code targets 2.x, whose API differs — v4 has no `PanelGroup` export, no `order` prop and no `ref` on `Panel` — so a file that resolved the root's copy failed to typecheck in a way that read like a broken extraction. Nothing imported the root's version. It was vestigial, and removing it collapses the repository to one: every importer now resolves 2.1.9 and the lockfile has zero v4 entries. ── Not real: the second React ── Declaring react/react-dom in the package created `src/workspaces/workspaces/node_modules/react`, which looks exactly like the duplicate-instance bug that silently breaks hooks. It is not one. Both locations resolve to the SAME file in bun's store — the local entry is a symlink — which I established by resolving the specifier from each place instead of reading directory names, having already reported it as a hazard on the strength of an `ls`. Declared as optional peers regardless: that is the right metadata for a consumer, and it stops bun installing peers it does not need to. Notes corrected to match, since the wrong version of that story was written down. tsgo clean, package 114/114, full suite 950 pass / 7 fail unchanged, frontend builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4c97e69ecd |
extract the workspace framework into its own package
Phase 1: the Workspace/Panel system is `src/workspaces/workspaces`, and it no longer
knows Officer exists.
The proof is its test suite. 114 tests mount `WorkspaceView` under a BARE React tree —
no react-query provider, no router, no Officer hook — which is only possible because
the host now supplies everything: a registry mapping `appType` to a component, and a
layout with a way to change it.
Officer is untouched. All 24 `WorkspaceView` sites and 8 `WorkspaceLayout` sites call
exactly what they called before; `Workspace/index.ts` became an adapter that adds back
the three things that are genuinely ours — the app registry, fullscreen persistence in
sessionStorage, and the handshake that stands the nav header down for a fullscreen
panel.
── The shape, and why it is this shape ──
`core/` is the tree and every operation on it: pure, no dependencies, no React. `react/`
is one renderer. That split is phase 2's whole cost saving — a second renderer reuses
`splitPanel`, `movePanel`, `normalizeLayout` and the rest untouched, and what will not
port is the resize INTERACTION rather than the layout engine.
It was already half done: `layout-utils.ts` imported only `./types`, and `WorkspaceView`
already received `{ key, value, setValue, isLoaded }` as a prop and always wrote through
an updater. The real coupling was the registry, fullscreen, and four UI imports.
Also deleted ~175 lines of dead code — `DragOverlay`, `LayoutEditor`, `WorkspaceHeader`
— referenced by nothing.
── Two dependency traps ──
**A second React.** Declaring react/react-dom in the package produced a second copy
under its own node_modules. Two React instances break hooks at runtime and are invisible
until something renders. The package now declares almost nothing while it lives here.
**Two versions of react-resizable-panels.** The root asks ^4.0.15 (4.6.5),
`src/workspaces/components` asks ^2.1.7 (2.1.9), and this code targets 2.x — v4 has no
`PanelGroup`, no `order`, no `ref` on Panel. Inheriting the root resolution produced a
screenful of type errors that read like a broken extraction and were a version
mismatch. Pinned; the duplication itself is worth fixing one day.
── Verified ──
tsgo clean. Package suite 114/114. Full suite 950 pass / 7 fail, unchanged. Frontend
builds, and the running app was screenshotted against the pre-extraction baseline: 9 of
16 byte-identical, the rest live data and the rotating wallpaper. Panel chrome, headers,
splitters and the grid all render as before.
Docs written as part of the work: the package README is the contract, and
WORKSPACES-NOTES.md carries the open items — panel-to-panel channels, the theme
classes the package still assumes, and phase 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f977d26fef |
a ratchet on hardcoded colour, and the docs to match
The three previous attempts at this job died of endlessness, not difficulty. Nothing stopped the next component arriving with its own `text-emerald-400` while an old one was being fixed, so the number never fell for long. `bun run check:theme` counts palette classes per directory against a committed baseline. It does not care what the number is, only which way it moves. 942 across 21 directories today. Every directory can be paid down independently and none can quietly regrow — which turns an infinite job into a finite one. A baseline rather than a ban, deliberately: a rule forbidding palette classes outright fails on its first run, and a check nobody can make pass is a check everybody turns off. Proven by adding one `text-emerald-400` and watching it fail with the directory, the delta, and what to use instead. ── Documentation, which is part of the work rather than after it ── `src/workspaces/theme/README.md` is new and is the contract: how to write a theme, all 84 declarations by family, why colours are unwrapped HSL triplets while surfaces are complete CSS values, why assets are quoted paths, and the rules for components. Start from Plain, not from Duck — every value you change is then one you decided about. `plugins/EXTRACTING-A-PLUGIN.md` gains a rule beside the others: a plugin never hardcodes a colour, and pays its debt down DURING the extraction. The counts are named there — Soulseek 313, Invoices 95, Wallet 68 — because "some colours" does not convey that it is two thirds of the platform's total. The kickoff prompt says the same thing in the form a fresh agent will meet it. That is what makes the plugin exemption safe rather than a way of losing the work: the ratchet stops covering a directory the moment it leaves, so the rule has to travel with it. `THEMES-NOTES.md` rewritten to match reality — it still listed done things as open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
997f7e7a87 |
the account stores a theme, not a colour mode
Your theme now follows the account instead of living in one browser's localStorage.
Choosing Plain on a laptop reaches the phone.
`appearance.colorMode` is superseded by `appearance.theme`. Along the way this
resolves a mismatch that had been sitting there: the server's default was
`appearance: { theme: 'light' }` while the client read `appearance.colorMode`, so that
default had never once been read by anything. Both sides say `theme` now.
The Light/Dark item is gone from the user menu. Light and dark are two themes, both
already in the list above it, and that toggle was the last writer of the legacy key.
── Two bugs found by testing this, both mine, both silent ──
**A default that looked like a choice.** `useSettings` returns `DEFAULT_SETTINGS`
until the request lands — `settings` is never null, it is a complete valid object. So
"wait for settings" fired on the first render against the defaults, marked itself
done, applied the default theme and never saw the real one. Then the persist side
wrote that default over the account's actual preference. It overwrote the owner's own
`dark` while I was testing it; restored by hand from the `colorMode` it had not yet
destroyed. `useSettings` now exposes `settingsLoaded` so the difference is expressible.
**Defaults masking a migration.** With that fixed, an account holding only
`colorMode: 'dark'` STILL came up light. The client merges `DEFAULT_SETTINGS` over
whatever arrives, so a missing `theme` is filled in with the default before any
consumer sees it — and a default is indistinguishable from a choice. The migration
therefore belongs on the SERVER, which sees the stored object before anything is
merged over it. It is read-only: a GET that rewrites the row on every call is a
surprise, and the client persists on its next change anyway.
Verified by putting the account into the pre-themes shape and asking the new handler
directly, rather than by restarting to find out:
stored: {"colorMode":"dark"} -> returned: {"colorMode":"dark","theme":"duck-dark"}
── Needs a restart ──
The first server-side change in this work. Everything until now was frontend and a
rebuild was enough.
950 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
12f1e93c4a |
panels are a themed surface, not three literal rgba pairs
The cornerstone of the layout was the least themeable thing in it. Measured before touching anything, which is the only reason this is the right fix: duck-dark radius=8px bg=rgba(255,255,255,0.12) border=rgba(0,0,0,0.25) blur=24px plain radius=0px bg=rgba(255,255,255,0.12) border=rgba(0,0,0,0.25) blur=24px So the RADIUS was already following `--radius` and I had been telling the owner otherwise — the rounding in the Plain screenshot was something else. What did not follow anything was the surface: three literal rgba pairs and a fixed `backdrop-blur-xl` in the component. Now: plain radius=0px bg=rgb(255,255,255) border=rgb(0,0,0) blur=0px Two distinctions the old code drew in literals are kept as tokens rather than flattened, because both are real and a theme should be able to revisit them. `--panel-raised-*` is the fullscreen surface: DENSER glass, not darker, because panel chrome is dark text and a heavy dark fill hid every icon. `--panel-border-bare` is for a panel with no header, which has nothing else separating it from what it sits on. `--panel-blur` earns its place beyond theming: a flat theme was paying for a 24px backdrop blur it did not want, and on a solid panel the compositor does that work for nothing. ── A near miss worth recording ── The first attempt silently changed nothing. The patch matched on four spaces of indentation where the file has two, `str.replace` reported no error, the build succeeded, and `panel-blur` DID apply — so the class landed while the surface did not. It looked like a partial success and would have shipped as one. Caught by measuring computed styles again rather than trusting the edit; the fix now asserts the replacement changed the file. 950 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dfb8a6d6cb |
a sixth tone, and no raw palette left in the core apps
The five core apps — FileBrowser, FileViewer, SystemMonitor, Chat, Plugins — now contain ZERO raw Tailwind palette classes. Every colour in them names a state or a surface, so all of it follows the theme. ── Busy ── The 38 ambers were the blocker, and the answer was not to pick between two wrong mappings. They mean RUNNING — Chat's own comment says "the amber a RUNNING task chip wears" — and the vocabulary had no word for it. `warning` would have made a working job look like a failing one, and `--status-in-progress` is blue. So `tone.ts` has six tones instead of five. It was always written as a closed set that could grow when a genuine sixth state appeared; this is that. `--busy-ink` comes with it, and generalises: a fill bright enough to carry black text is far too light to BE text. The other five tones are dark enough to do both jobs, which is why the problem had not surfaced. `text-amber-950` — ink ON the fill — became `text-busy-foreground`, which is what it always was. ── On fidelity, which stopped being the constraint ── Duck's exact colours were preserved up to this point because an extraction has to be provably invisible. The owner has since said the palette is not precious: the wallpaper, the graph-paper grid and the mark are what matter, and everything else can move. So shades collapse — one busy, one red, one green — rather than being approximated to keep old pixels. The verification built for the old constraint keeps its value: it proves the token layer resolves, which is a different claim from "the colours did not change". Verified: the utilities compile (`text-busy-ink`, `bg-busy`, `border-busy`, `text-busy-foreground`) and resolve in the browser. 950 pass / 7 fail unchanged. tsgo clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a52d3e06f3 |
one red and one green, in the core apps
82 raw palette classes in FileBrowser, FileViewer, SystemMonitor, Chat and Plugins now name a state instead of a colour. `docs/design-language-interface.md` has said to do this for a long time and `tone.ts` implements it; eight files were following it. Only the families whose semantic token IS the colour already in use: destructive #ef4444 == text-red-500 exact success #16a34a == text-green-600 exact So `red-500` and `green-600` are byte-identical renames, and the neighbouring shades (`red-400`, `red-600`, `green-500`, `emerald-500`) shift by a little on purpose — having one red is the point. Verified on screen, not just typechecked. Three greys became `muted-foreground`, which is what "quieter than the text" means here. ── Amber is deliberately untouched, and needs a decision ── 38 amber classes remain and a regex must not have them. They are not warnings. Chat's own comment says it plainly: "the amber a RUNNING task chip wears". It is a BUSY tone, and the token set has no such thing — `--status-in-progress` exists and is blue. Mapping them to `warning` would change both meaning and colour: Duck's `--warning` is #fbbd23 (amber-400) while the code uses amber-600 #d97706 as text and amber-950 as ink on a fill. Text at that lightness on white is not readable, and "running" would start looking like "something is wrong". Two ways out, both the owner's call: add a sixth tone for busy/queued, or accept warning's colour and meaning for it. ── Deliberately out of scope ── The plugin candidates — Soulseek 308, Invoices 95, Wallet 68, Jellyfin 48, Photos 47, Transmission 41 — are two thirds of the debt and are leaving this repository. Their colours belong to their extraction, when the rules exist and their imports are being rewritten anyway. White and black are also untouched: they are media overlays in the file viewer and photo surfaces, correct in every theme because they sit on someone's photograph rather than on a themed surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e9cb244818 |
the wallpaper, the header and the mark belong to the theme
Asked whether these could follow the theme too. They are the three most visible things
on screen and were the three least reachable: `Background.tsx` hardcoded
`url('/landscape1.webp')`, the header carried `rgba(255,255,255,0.25)` and `#1d2724`
in an inline style, and the logo was an `<img src>`.
Between them that is most of what "the app looks like Officer" actually meant. The
Plain theme turned every panel white and all three stayed exactly as they were, which
is how they were found — the leak detector doing its job on its first outing.
Verified across all three themes:
duck-dark wallpaper=landscape1.webp header=rgba(255,255,255,0.25)
duck-light wallpaper=landscape1.webp header=rgba(255,255,255,0.25)
plain wallpaper=none header=rgb(255,255,255)
── Images could not be done the obvious way ──
`--logo-image: url('/officer-logo.svg')` fails the build. Bun's CSS bundler resolves
`url()` at BUILD time and these files are served from `public/` at RUNTIME, so it goes
looking for a module that does not exist. Colours apply themselves; images cannot.
So a theme publishes a quoted PATH — an ordinary string token the bundler ignores —
and `useThemeAsset` reads it and wraps it in `url()`. One hook rather than the read
inline in two components, because the quote-stripping is the kind of detail that gets
forgotten the second time.
The logo is a `<div role="img">` with a background image rather than an `<img>`, since
CSS cannot set a `src` and a white-labelled build has to replace the mark from its
theme rather than by editing the header.
The header's title colour goes through a `.header-ink` class: `EditableTitle` accepts
a className and not a style, and the token stays the source of truth either way.
Panel radius and the ~985 raw palette usages are still outstanding — see
THEMES-NOTES.md.
tsgo clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1be4bcd39a |
prove the extraction faithful, and fix the one thing that was not
The screenshots said seven dark screens differed and could not say why. Comparing COMPUTED theme variables between the old build and the new one, in the same colour mode, answered it in a single run: 47 of 53 matched exactly, and the six that did not were the `--duck-*` variables — EMPTY in the new build. The utilities were remapped to `--brand-primary`, `--ink` and friends, which is correct. What that missed is code reading the VARIABLE rather than using the utility. `prose.css` does exactly that eleven times — chat markdown links, blockquotes, inline code, checkboxes — and `landing/styles.css` does it six more. All silently resolved to nothing. Fixed by keeping the legacy names as aliases in every theme, pointing at the new source of truth rather than duplicating values. That also covers any plugin or inline style doing the same thing, which I have not enumerated. They retire when the utility rename lands. Every variable now matches. ── What the screenshots are actually worth here ── Less than I assumed. The wallpaper rotates per page load, so a control capture of the same build against itself had 4 of 16 screens differ — and a different 4 each time. Between 5 and 6 come out byte-identical to the baseline on any run; the rest is noise I mistook for signal for two rounds, including one where I removed a `color-scheme` line to test a theory it turned out not to explain. The variable diff is the instrument that settles it, and it is cheap. Added as `scripts/theme-vardiff.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
105766a4eb |
extract the visual layer into its own workspace
The application no longer knows what it looks like. It imports one stylesheet and
mounts one provider; every colour, surface and radius lives in `src/workspaces/theme`.
Proven both directions, because either alone would be worthless:
Duck renders IDENTICALLY to before — checked against screenshots taken before
anything was touched, not asserted.
Plain renders as white with black ink, no component edits — panels turn white, the
teal avatar turns black, the grid fills vanish.
── What moved ──
`globals.css` held three unrelated things: Tailwind wiring, the token contract, and
the Duck brand's hex values. Split along those seams. `base.css` is structure and
names no colour; a theme file is values and nothing else, scoped to
`[data-theme='<id>']`. Adding a theme is a CSS file plus one registry line.
Light and dark are separate themes rather than a mode — the owner's call, and it
removes a concept from the app. Each is written out in full: a theme that only makes
sense as a diff against another cannot be authored by a stranger or sold to a
customer. `.dark` survives only as a per-theme flag, because 396 `dark:` utilities in
33 components resolve against it; retiring those retires the flag.
The `duck-*` utility names are kept for now and pointed at neutral variables, so a
non-Duck theme can define them sensibly. Renaming 1,470 occurrences in the same commit
as the extraction would make any visual difference impossible to attribute. Worth
recording that `duck-dark` was never a brand colour — `#14532d` in light, near-white
in dark, used by 946 components to mean "text". It is `--ink` now.
Surfaces are complete CSS values rather than HSL triplets: `--panel-bg` has to be able
to be a gradient or an image, or the glass the app already has would be unthemeable.
The old provider is a re-export, so its six call sites are untouched and the
extraction stays one variable.
── Bug found by the Plain theme ──
`useThemeSync` stamped the server's `appearance.colorMode` over the local theme on
every load, so choosing Plain and reloading silently reverted it. Diagnosed by asking
the page what it was wearing rather than reasoning: `stored` said `duck-dark` when the
script had written `plain`. The server value is now a starting point for a browser
that has never chosen, not a continuous instruction.
── Tools ──
Two, both of which lied to me before they worked. The snapshot script photographed
light twice and called it a clean run (one browser context, accumulated init scripts);
it now warns when a light/dark pair is identical. The preview server returned the
shell for missing assets, so the logo and wallpaper vanished and read as regressions
in the comparison — it falls through to upstream now.
Dead Tailwind v3 residue deleted: `components/ui/tailwind.{config.ts,css}`, referenced
by nothing, with content globs pointing at a directory that does not exist.
── Not done, deliberately ──
`THEMES-NOTES.md` has the full list. The headlines: the reference repo has not been
read, so the switching mechanism is deliberately the dumbest thing that works; the
wallpaper, panel radius and logo are still component-owned and Plain proves it; and
~985 raw Tailwind palette usages remain, worst in Soulseek.
tsgo clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
7980ee3761 |
photograph the app before touching how it looks
The themes work moves every colour out of the components and into a token layer, and the entire value of that is being INVISIBLE. Nothing here could have told us whether that held: the tests do not render, and comparing two screens from memory an hour apart misses exactly the things this can break — a border colour, a radius, one mode of two. Eight routes, both colour modes, against the running app. Byte comparison rather than a pixel-tolerance diff: a false alarm costs opening two images, a false all-clear costs the refactor. The first version was worthless and looked fine. It reused one browser context and added an init script per mode — Playwright ACCUMULATES those, so the dark pass ran the light script too, and the class toggle after `goto` was undone by the provider's own effect. Six of eight routes came out byte-identical in light and dark, which reads as a clean run. A context per mode fixes it, the class is asserted after settling rather than before, and identical light/dark pairs now print a warning instead of passing quietly. Verified rather than assumed: 15 distinct images of 16, every route differs between modes, and one was opened to confirm it is the authenticated app and not eight variations of an error boundary. Baselines are gitignored — 7MB of machine-specific PNGs. Regenerate with the script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b3b44ee77d |
changing route in a preview no longer reloads the app
Reported as "I change routes and a second later the whole app refreshes". The second
was mine: the 800ms debounce on writing the location down.
`DevProxyFrame`'s session effect had the composed URL in its dependency list. That URL
changes every time the app navigates, so the effect re-ran — and its first act is
`setReady(false)`, which swaps the frame for "Connecting…" and takes the iframe out of
the tree. Coming back mounted a fresh one. A full page load, one debounce after every
click.
Watching the URL was never right: the cookie is issued with `Path=/api/dev-proxy` and
no port in it, so it already covers every path and port the panel can reach. It now
depends on entering proxy mode and on an explicit reload, where re-requesting is the
whole point.
── A second bug, found while fixing the first, not yet reported ──
Freezing `src` at mount stopped the app's navigation from reloading the frame — and
also stopped a newly TYPED url from loading, because nothing remounted. The panel
would have silently ignored a change of address.
The two pull in opposite directions, which is what makes the pair the interesting
part rather than either fix. They are told apart by which value changes: `entry` moves
only when a person points the panel somewhere else, `target` moves when the app does.
So the frame is keyed on `entry` and loads `target`.
── The tests were made to fail first ──
Three component tests, and both bugs were re-introduced one at a time to check the
tests actually catch them:
URL back in the effect deps -> "the app navigating does NOT reload" FAILS
key back to reloadToken alone -> "a newly typed url DOES load" FAILS
Either bug alone would have been passed by a test written for the other, so they only
mean anything as a pair. Both were invisible to reading the component and obvious in
the DOM — the assertion is node identity across a rerender, which is the same question
as whether the browser re-requested the page.
`fetch` is stubbed and restored per test rather than `mock.module('hooks/useClient')`:
Bun has one module registry for the whole run, so that mock is process-wide and
permanent, and `useDashboardState.test.tsx` already owns it.
tsgo clean, 937 pass / 7 fail unchanged, frontend builds. Needs a rebuild — frontend
only, so the Rebuild button is enough; no restart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d771736a0d |
the preview panel follows the app's navigation
The panel knew one URL: the one you typed. Navigate three pages into an app and the header still showed the front door, "open in a new tab" still went there, and a reload — which fires by itself whenever a dev server restarts — threw away where you were and started again at `/`. Now the app reports where it is, and a reload composes it back. ── How the panel finds out ── The frame is same-origin for a proxied preview, so `contentWindow.location` is readable. It is PUSHED rather than read, because a dev server is an SPA: a route change is `history.pushState`, which fires no event and never reloads the document. Polling would have to be constant and would still lag. The script the proxy already injects now patches `pushState`/`replaceState`, listens for `popstate`/`hashchange`, and posts the path — with the proxy prefix stripped, since that is plumbing and the panel should not have to know about it to show a URL. Only proxied previews can do this. A cross-origin frame reports nothing, and grafting a remembered path onto someone else's host would be inventing a URL, so `targetUrl` composes only when there is a port. ── Two fields, and the loop that forced them ── `url` is what you pointed the panel at; `current` is where it has got to. The obvious design is one field — a browser has one address bar — and it feeds itself: the stored value IS the iframe's `src`, so recording a route change hands the frame a new `src` for the page it is already showing, which browsers treat as a navigation. Every click would cost a reload. Belt and braces, because one of the two would be enough only until someone changed the other: the fields are separate, AND `PreviewFrame` captures its `src` at mount and ignores later changes. Remount-by-key is still how a reload happens. `ev.source` identifies which frame is talking. `message` is a window-level event and two same-origin previews hear each other; comparing the posting window to this iframe's `contentWindow` is exact, where matching on URL would break the moment two panels pointed at the same port. Writes are debounced 800ms. `useDashboardState.setValue` PATCHes the server on every call and an SPA emits a route change per click; the value is only read at mount, so being a second behind costs nothing. Also: the header shows `localhost:3001/settings` rather than `api/dev-proxy/3001/settings`, and editing starts from that — one is a thing you can retype, the other is not. ── Tests ── The reporter is RUN, not string-matched: `new Function` against a fake window, which also means a syntax error in the injected script fails the suite rather than silently breaking every proxied page. Covers prefix stripping, the app root reporting `/` rather than empty, query and hash surviving, the report on load, pushState, and popstate. Plus the composition helpers, including that an ordinary URL is never composed with a path. ── One stale comment corrected while in there ── The file claimed `allow-same-origin` "is present, and has to be". There is no `sandbox` attribute at all, so nothing was present — and a sandbox with `allow-same-origin` plus `allow-scripts` is worth roughly nothing anyway, since the frame can remove its own sandbox attribute. Now says that. Not verified against a live dev server through the real proxy — that needs the server restarted with this code. tsgo clean, 934 pass / 7 fail unchanged, frontend builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
92bac490d7 |
attach a PDF and the agent reads it; the microphone button is gone
Two requests, one file apart. ── The microphone ── Removed from the chat composer. The HOOK stays: `useAudioRecording` and `blobToWav` are also the file browser's DictateDialog, which is not what was asked to go. ── PDFs, by not extracting anything ── A PDF entry sat in the attach menu once with no `onSelect` at all — the menu offered it and did nothing, because nothing here could extract PDF text. It is back and real, and it still extracts nothing: the file is uploaded, left on disk, and the agent is given its PATH. Its own `Read` handles PDFs by page, better than any extraction this could have shipped, and it can go back for a section later without paying for it twice. The alternative was base64 in the message. That is megabytes in the turn, re-sent for the rest of the conversation, to produce worse text. ── What this uncovered ── `attachmentIds` has been on this websocket and in `types.ts` for a long time and NOTHING on the server read it. Images work because they travel separately as base64, so the ids were dead on the wire — and a PDF is exactly the case that needs them. They resolve to paths now, appended to the prompt as `Attached files (read from disk)`. Ids arrive in a websocket message and become a filesystem path, so they are checked rather than trusted: only the `<uuid>.<ext>` shape uploads actually generate, and only in the two directories uploads actually write to. Verified — a valid id resolves, an unknown one yields nothing, and `../../../../etc/passwd` and `; rm -rf /` yield nothing. Both upload directories are searched because the first message of a chat carries ids written to `tmp/` (no session yet) and later ones carry ids under the session. PDFs may be 25 MB against an image's 5: nothing carries them into a prompt, so the ceiling is storage rather than context. tsgo clean, frontend builds, 914 pass / 7 fail unchanged. `[open]` For a MEMBER the file lands under `data/<email>/attachments/`, and whether their Linux user can read it depends on the confinement ACLs. Untested — the owner path is what was verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f4f8ea32d1 |
a paused job shows a pause icon, not a spinner
The details panel kept spinning after Pause, so a frozen dev server looked like a working one. The spinner is the whole claim being made, and it was false — the state most likely to be watched, since the panel is where you sit while a `bun dev` runs. Amber either way: it is still a live job holding its port and its memory, which is what separates paused from stopped. What changes is the motion, and the line beside it now reads `paused` rather than `running…`. ── And the state moved up a level ── `paused` lived in the panel, which unmounts when you close it. Closing and reopening would have put a spinning icon back over a stopped process — the exact failure this commit is fixing, arriving by a different route. It is the tray's now, so it survives the panel being closed. Still per-session: a reload forgets, and the button then offers Pause on a job that is already frozen. SIGSTOP makes that harmless, and the honest fix is asking the process rather than remembering — `ps -o state=` on the pids holding the output file would make it survive anything, at the cost of a spawn per poll. Worth doing if this ever misleads. The chip is unchanged: a paused job still shows the running dot there, because live is what that dot means and the distinction belongs where the controls are. tsgo clean, frontend builds, 914 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bc2bafe24a |
a queue can no longer be delivered to the wrong conversation
My fix was worse than the bug it replaced. Queue in one chat, switch to another,
and the messages were SENT there — into a conversation the person had not written
them in.
`ChatDetailPanel` mounts `<EmbeddableChat sessionId={undefined}>` and passed no
panel id, so `queueKey` fell through to `screen:new` for every conversation it
showed. One key, every chat. Switching restored the queue into the chat being
opened, and the mount-drain I added — which exists so a returning queue is not left
sitting there — then delivered it. Two of my changes, each defensible alone,
combining into the worst outcome available.
Three things, in order of what actually protects the user:
**A key that cannot be shared.** `queueKey` returns null when neither a panel nor a
session identifies the mount, and null persists nothing. Then an unidentifiable
chat behaves exactly as it did before any of this: queue local to the mount, lost on
a switch. That is the original complaint, and it is strictly better than delivering
to the wrong place. A shared key is worse than no key.
**The drain refuses a null key too.** Belt and braces: nothing was restored, so
nothing may be sent.
**`ChatDetailPanel` now says which conversation it is**, from `resumeSessionId`,
which it already had in scope. The identity was one line away the whole time.
Three tests pin it, including the collision itself: two resumed conversations must
not produce the same key, and a null key must never persist.
The lesson I would rather write down than repeat: I added the mount-drain reasoning
about a queue's promise, and never asked what it would do if the key were wrong. A
mechanism that sends messages needs its failure mode considered before its happy
path.
tsgo clean, frontend builds, 901 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
0d3a7fc33f |
queued messages survive switching to another chat
First report from somebody who is not us. Queue messages in a chat that is working, open a different conversation, come back: gone. Not a bug in the sense of code doing the wrong thing — `ChatPanelInner` is keyed on the session id, so opening another conversation REMOUNTS it, and that key is load-bearing (`useChat` seeds its state from the first session id it sees, so a remount is the honest way to say "this is a different chat"). The queue was local state and died with the mount. It was built for the minute between writing a message and a turn ending; switching away mid-turn was never in that picture. Now it lives in a module-level store, seeded on mount and written through on every change. Losing text somebody typed is the worst thing this could do, and it was doing it silently. ── Keyed by panel AND session ── Two panels can both be on a new chat, where the session id is `new` for both. Keying on the session alone would hand one panel's queue to the other — the same class of mistake wearing the opposite face. ── One decision that was not in the report ── A restored queue now DELIVERS on return if the agent is free. The existing drain only fires when a turn ends, and a remount has no such transition to watch, so without this you would come back to your messages sitting in the tray with nothing about to happen — which is the second report I would get. "These go when the agent is free" is the promise; the agent being free is exactly that case. Only on mount, and only when nothing is running: while a turn IS running the ordinary drain owns them, and firing here too would deliver twice. ── Not persisted, deliberately ── Module state, so it dies with the tab. A queue is a promise about the next minute; restoring one on a page loaded tomorrow would fire a message into a conversation whose context has moved on. Surviving a panel switch is the whole requirement. 11 tests, including the two-panels-on-one-conversation and both-on-a-new-chat collisions. tsgo clean, frontend builds, 898 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fccabfcea3 |
the preview authorises itself before it mounts the frame
401 on every asset, white pane. The server was never wrong — replaying the exact browser sequence with a minted owner token gave HTML 200, `set-cookie` present, and the sub-resource authorised. So the fault was in how the frame got its credential, and both mechanisms it relied on were guesses. Putting the token on the iframe URL guesses which key the browser stored it under. Letting the frame mount and hoping guesses that a cookie from some earlier load is still there. When either guess misses, the HTML renders, every sub-resource 401s, and the pane is white — which is the shape that makes this hard to read, because the document loading looks like success. So neither is used now. The panel asks `/api/dev-proxy/session` with the ordinary API client — which already knows how to authenticate, because it sends the header on every other call it makes — and mounts the frame only once that returns. The iframe URL carries nothing. `iframeSrc` is deleted rather than left as a fallback: a second path that runs when the first fails is how you get a bug that only appears sometimes. `Authorization` rather than a query parameter, so the token never reaches an address, a log or a Referer. Re-run on every reload, so a preview left open overnight recovers by itself instead of showing a wall of 401s. ── Also fixed: a lie attached to every binary asset ── The non-rewritable branch returned the upstream's `content-encoding` and `content-length` unchanged, but `fetch` had already decompressed the body — so the browser was told to inflate plain bytes, with a length that no longer matched. The rewrite branch had always deleted both. Images, fonts and wasm were affected; nothing had hit it yet because the test app serves none. Verified: `/session` 200 and sets the cookie with a token, 401 without; the frame 200 with the cookie, 401 with nothing. `[open]` A request that escapes the prefix hits the SPA catch-all and returns `200 text/html` instead of 404, so a `<script>` gets HTML, the MIME check refuses it silently, and nothing appears as an error. That masks this entire class of bug and is worth closing separately. tsgo clean, frontend builds, 890 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
73eb128fb1 |
pause, resume and restart a background job without asking the agent
Three buttons in the expanded task panel, beside Stop. Not on the chip: the panel is where you decide what to do with a job, and a row of unlabelled glyphs on a pill is where you misclick. ── Pause / Resume ── SIGSTOP and SIGCONT — the only pair that means "stop doing things and keep everything". The process keeps its memory, its open files and its bound port, and picks up mid-instruction. Signals go to every pid holding the job's output file, because for a shell job that is the shell AND what it spawned, and freezing one of them would be worse than freezing neither. Verified by watching a job that writes a line a second: 3 lines, frozen at 3 across three seconds of pause, 6 after resume. Worth knowing before pressing it: a paused server does not REFUSE connections, it stops answering them, so a browser pointed at it hangs rather than erroring. That is what pausing is, not a shortcoming here. ── Restart, and the cost it accepts ── The command is recovered by joining the transcript to itself: the harness records the `tool_use` carrying `command`, and separately its result — the text `Command running in background with ID: <id>`. Neither alone is enough; together they are a join on `tool_use_id`, and that is the only place the command survives. The transcript is found FROM the output path rather than searched for, since a task file at `<tmp>/claude-<uid>/<slug>/<session>/tasks/<id>.output` names both. The respawned process appends to that same output file. That is what keeps everything else working untouched — the log view reads that file, and Stop finds the process by asking which pids hold it open. The real cost, chosen deliberately: the platform becomes a SECOND owner of a task. The harness stops watching when its own child dies, so the platform supervises what it spawned and emits the same `task:started` / `task:notification` pair everything downstream is built on. `emitToSession` is the seam, and it is the only one — nothing else learns that a task can have two kinds of parent. A restart re-announces an id already in the transcript, so `task:started` now RESETS an existing task row rather than appending a second one. Without that the tray showed the job twice, once finished and once running. Restart is offered for a finished job too, which is the case that matters most: a dev server that fell over is exactly what you want back without typing a sentence at an agent that may be mid-turn. Verified end to end against a planted task: command recovered, old process killed, new one spawned, output APPENDED (`first run output` then `RESTARTED-OK`), exit reported through the callback, supervision registry released afterwards. `[open]` A restarted job does not survive an `officer` restart — the platform is its parent now. The harness's own jobs never did either. tsgo clean, frontend builds, 890 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7d64b0a0d6 |
paste localhost:3000 into the preview and see the server's port
No proxy host, no certificate, no port opened. `/api/dev-proxy/5173/…` forwards to `http://127.0.0.1:5173/…` over the platform's own origin, and the panel maps what you type onto it. ── Why a proxy at all, when the ask was "just point at localhost" ── Neither direct route works from a browser on https://<platform>. `http://localhost:5173` resolves on the VIEWER's machine, not the server's, and `http://<server>:5173` is mixed content inside an https page and is blocked before it is even attempted. Both fail looking exactly like the dev server being down. The bytes have to arrive over an origin the page already trusts, which is the platform. A dev server can therefore stay bound to 127.0.0.1. The workflow this replaces — one NPM proxy host per app — needs it on 0.0.0.0 plus a firewall rule for the docker bridge, so this is both less setup and less exposure. ── What came back from `188b45c1^`, and what did not ── The 46-line HTML/asset rewrite, which is the hard-won part: absolute paths in the document, plus an injected script that intercepts fetch/XHR/WebSocket for the URLs built at runtime. Its JWT injection is deleted — those apps called the PLATFORM's API; a dev server proxied here talks to itself, and handing its process the owner's bearer token would be giving credentials to whatever is on that port. The other 314 lines are not back: spawning, port discovery, idle timers, log collection, a proxyId registry. The port is the id, and the background-job button already starts dev servers. ── HMR is bridged, because "see it" without "follow it" is half the feature ── The injected script rewrites the dev server's socket under the prefix; the relay dials `ws://127.0.0.1:<port>` and forwards frames both ways. Same shape as the vault relay including the non-obvious part — state registered synchronously and frames queued until the upstream opens, because Bun requires the upgrade to be synchronous and an HMR client speaks immediately. An upstream close closes the client, which is what makes a dev-server restart reconnect by itself. ── Access, which was not in the ask and is not optional ── This route reaches any loopback port on the machine, on a host that answers from the public internet. An iframe cannot carry a bearer token — it is a navigation — so the panel puts it in the URL once, the proxy verifies it is the OWNER through `resolveAuthToken`, and issues a short-lived cookie every sub-resource then carries. Anonymous and non-owner requests get 401, verified. Owner-only rather than a permission: reading an arbitrary loopback port is the `execution` power the registry says can never be granted. Both exemptions are registered with reasons, and the boot check earned its keep — it refused to start until the socket provider was claimed too. ── Verified against a real loopback-only server ── Confirmed unreachable from outside, then through the proxy: index served and rewritten, `/_bun/` chunk resolved, JSON passed through untouched, 401 anonymous, 401 with a bad token, "nothing is listening" on a dead port. Websocket: refused without the cookie, target parsed, frames relayed in both directions. One bug found by testing rather than reading: the upstream path was derived by slicing a hardcoded prefix, so every asset request returned the index page. It is found from the `/<port>` segment now. A test asserting `http://localhost:5173` is kept verbatim is now inverted, with the reason written beside it — that was correct when the panel only framed real hostnames. tsgo clean, frontend builds, 890 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
74f2e59069 |
the tray stops filling with the agent's own commands
"So the pills don't get polluted with those calls that you're making." First, a correction to what I assumed. The platform is not mis-detecting anything: the SDK emits `task_started` only for real background work, and I checked — a foreground `echo` produces no task event at all. What produces these is the HARNESS auto-backgrounding a foreground command that runs long. Measured against the installed SDK: a 24-second foreground Bash call emits `task_started` with `task_type: 'local_bash'`, identical in shape to one started with `run_in_background: true`. There is no field that separates them, and there should not be — by then they are the same thing. So the chips were real. They were just not anybody's business: a tray of green ticks for greps and typechecks, sitting beside the one job that was started on purpose. The rule is now about ORIGIN rather than state, using the arm-and-claim that already exists for the preview: running shown, whoever started it — it is live and it can be stopped finished, yours kept (up to five), because you asked for it and the outcome is the point finished, the agent's dropped the moment it ends pinned kept regardless Running jobs stay visible deliberately, including mine. A background command of the agent's that has gone wrong is exactly the thing worth seeing and stopping, and the stop button already adopts anything you press it on. Extracted as `visibleTasks`, pure and tested — six cases including the cap, the pin override, and running-before-finished surviving the new filter. `[open]` Origin is process state, so a reload forgets it and a previously-yours finished chip is then treated as the agent's and dropped. Acceptable — after a reload you are not watching that completion — and making it survive means putting origin in the transcript, which is a wire change. tsgo clean, frontend builds, 884 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7fdb09af0a |
speaking mid-turn no longer declares the turn over
Reported as "the output I see with turn complete is very very misleading", and it
was: the transcript printed "Turn finished · no closing message" between a tool
call and an injected message, while the header two inches away still said Working
and a task chip still said 1 RUNNING. The UI stated the opposite of what was
happening.
`turn-rows` treats every user message as a turn boundary. That was right when the
only way to speak was to wait. Delivering a queued message INTO a running turn made
it wrong: the boundary split the live turn, the half before it therefore counted as
`complete`, and having tool calls and no answer it matched exactly the rule that
prints a silent ending. The seam meant for a rare confusing case fired on an
ordinary one, and said something false.
So a user message now carries `injected` when it was sent while a turn was already
running, and only a message that STARTS a turn is a boundary. Read from
`isGeneratingRef` at send time rather than from state — `sendPrompt` is called
from closures built a render earlier, and the question is "was a turn running when
this was sent", which is what the ref holds.
Five tests, including the contrast case: the same messages WITHOUT the flag still
produce the boundary and the ending, because a genuine second turn should end the
first. A turn that really did finish silently still says so — that seam was worth
having and is not what was broken.
Verified live on the way in: the injection reached this session mid-turn, attached
to a tool result, and changed what the turn did.
Still outstanding from the same screenshot, not touched here: foreground Bash calls
render as background tasks ("Running in the background…", and a chip in the tray).
That is a separate misreport.
tsgo clean, frontend builds, 878 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
0230e62a71 |
the platform stops explaining what Headscale is
Correcting my own reasoning from the last commit. I left "Headscale's API", "the Headscale admin credential" and similar alone on the grounds that they name the thing the plugin drives, so renaming them would make the sentences false. True as far as it goes, and it misses the point: those sentences are in the PLATFORM. It has no business knowing what Headscale is at all. Correct language in the wrong repository is still a remnant. Ten of them, each replaced with the platform's own vocabulary rather than the plugin's domain: - `crypto.ts` and `secret-store.ts` listed "the Headscale admin credential" among what one key used to protect. It is "the tailnet's admin credential" — the purpose, which is a word this repository owns. - `catalogue.ts` and `availability.ts` explained `alsoServes` — a LIVE mechanism — entirely through a Headscale example that left the repository. Restated as the general shape, with one parenthesis saying which case produced it. - The photos types file explained another plugin's sidecar. - `App.tsx` cited `/headscale/:section` as the example of a route pair; that route does not exist. `/photos/:section` does. - `plugins/router.ts` pointed at `useCompanionLogStream`, a hook that lives in a plugin now. - `registry.ts` said in the present tense that `headscale` is `admin` — the permission is the plugin's and it declares its own, so a member-facing enrolment surface would be the PLUGIN's to add, not this file's. - `proxy.sh` cited `headscale/schema.ts` as a style reference. That file is not in this repository. - machine-setup said "Its Headscale app" — the app is the Offscale plugin. `machine-setup/lib/tailscale.sh` keeps all ten of its mentions: it exists to explain tailscale, headscale and offscale to somebody choosing between them, on a core install, before any plugin exists. That is machine setup's subject, not the plugin's domain leaking upward. What survives elsewhere is only dated history — "was here until 2026-08-15", "It was: music, vault, slskd, headscale, …" — plus the notes about the real `headscale_servers` orphan and the secret-store key removed yesterday. Those record that something called headscale used to be here, which is a different claim from explaining what it does. tsgo clean, frontend builds, 873 pass / 7 fail unchanged, setup scripts parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
27aa72d923 |
finish the offscale sweep: two real remnants and eleven stale names
Asked for a thorough check that nothing of offscale is left in the platform. The extraction was sound — no router, no permission entry, no app-store entry, no screen, no panel app, no env var anything reads. Two things were genuinely wrong, and both were in setup rather than in the server. ── The secret store minted a key for a plugin ── `officer-setup` bootstrapped two purposes: `jwt` and `headscale`. The second was a key for a PLUGIN, under a name the rename had already replaced, on a machine where the plugin is not installed and nothing would ever ask for it. offscale encrypts under `offscale`, and `getKey` creates on demand — which is precisely what lets core know nothing about plugins. Now core bootstraps `jwt` alone. ── `.env.example` documented variables for a deleted router ── HEADSCALE_URL, HEADSCALE_API_KEY and HEADSCALE_USER were described as driving `/api/vpn`. That router was deleted on 2026-08-14 and nothing has read any of them since. Left as a note saying where the tailnet's credentials live now, because somebody with an older `.env` will otherwise wonder why setting them does nothing. ── Names that outlived the thing ── Eleven comments referred to the plugin's route, panels, console or server registry as "headscale": three sidecar config files describing their own shape by pointing at it, two screens citing `/headscale` as the example of a Workspace/Panel route, the terminal's note about the Console, and the photos types. Also `catalogue.ts`, which still claimed headscale "is CORE now", and `working-on-officer.md`, which told the reader a core install runs six processes including `officer-headscale`. Upstream references are deliberately untouched: "Headscale's API", "the Headscale admin credential", "is this a Headscale server?" all name the thing the plugin drives, and renaming those would make the sentences false. `docs/secret-store.md` argued headscale must be core because the tailnet is the perimeter. Marked superseded rather than deleted, with the distinction that settles it: the perimeter is the tailnet EXISTING, which machine-setup still arranges on a core install — not the platform shipping the screen that administers it. Verified by counting: live code, setup provisioning, env references, permission entries, app-store entries, hono mounts, dock defaults, screens and panel apps all zero. What is left is generated files (Plugins.gen, plugin-schemas.gen), the bundled catalogue entry, test fixtures using "offscale" as an example app name, and history. tsgo clean, frontend builds, 873 pass / 7 fail unchanged, setup scripts parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c0e61a4cba |
the trap keeps its evidence, and says where the procedure is
This bug will not be reproduced on demand. It will arrive as somebody's report, hours later, from a browser whose console is long gone — so a `console.warn` is seen by nobody, and "what did you see?" gets an answer built from memory and a screenshot. So the trap now persists. Up to five occurrences in `localStorage['officer:editor-buffer-trap']`, each carrying the stack at the moment of the edit, the first 200 characters that arrived, how many, which file, and when. The question becomes "paste this key", and the answer is complete. Recording a bug must never become one: private mode, a full quota and a disabled storage API all throw at that write, and none of them is worth breaking an edit over. The console line survives either way. `docs/editor-buffer-corruption.md` is the procedure — what it looks like, the four candidates already eliminated and why, the one command to ask a reporter for, how to read each field, what an EMPTY key would mean (either the editor was focused, making it a paste, or the insertion came in chunks under the threshold), and the instruction to delete every piece of this once the cause is known. Linked from TODO.md and from CLAUDE.md's reading list, so somebody handed a report finds it without grepping for it. Stated there rather than left implicit: a fix invented without knowing the cause would hide the symptom on the one screen where it is visible while the mechanism carried on doing whatever else it does. The corruption is never saved and reopening the tab clears it, so waiting is cheap and a wrong fix is not. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fc8d02e8e1 |
set a trap for the editor buffer corruption
Not a fix — I could not find the cause by reading, and shipping a guess dressed as
one is worse than saying so.
What reading DID settle, and it removes two of the three candidates:
- The end-of-turn refresh cannot be it. `reloadFile` sets `originalContent` and
clears `isDirty`, so no refresh can produce the dirty dot in the screenshot.
- Nothing in the app types into the page: no `execCommand('insertText')`, no
synthetic keyboard events, no clipboard writes anywhere near the editor.
That leaves Monaco's own change event, which means the model was edited the way a
paste or a programmatic `executeEdits` edits it. Neither is identifiable from a
screenshot taken afterwards, which is why this needs to be caught in the act.
`reportSuspiciousEdit` logs any insertion over 200 characters arriving while the
editor does NOT have focus, with an Error constructed at that moment so the stack
names the caller. A person cannot type 200 characters into an editor they are not
focused on; whatever does will be in that trace.
It prevents nothing and changes no behaviour. Delete it once the cause is known.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e0d3f59b9b |
name the tables nothing on this machine owns
The marketplace screen now lists tables in the database that no code here claims, with a row estimate and, where the prefix says so, which absent plugin they look like they belong to. This is the information that cost an evening. `bun db:push` saw tables the schema did not declare, asked an interactive question no non-TTY caller could answer, applied nothing and exited 0 — and the install reported success while the plugin was unusable. The cause was two tables from before an extraction, found by reading `\dt` by hand. Nothing in the platform would ever have mentioned them. ── What counts as an owner ── The core schema, or a plugin DIRECTORY — not a plugin INSTALL. A plugin that is present but uninstalled still owns its tables, because that is the promise uninstall makes: keep every row so a reinstall puts you back where you were. So this reports only what no code on this machine describes at all, which on a healthy install is nothing, and the block does not render. A plugin whose schema will not import has its prefix treated as claimed rather than having every one of its tables reported as unowned — a broken manifest should not read as data with no home. ── No delete button, deliberately ── Dropping a table is not something to offer beside a list of plugins, and the right action differs per row: `music_favorites` wants the music plugin installed, `headscale_servers` wants dropping. Naming them is the part that was missing. Verified by planting `headscale_servers` and `music_playlists_old` and reading them back through the same call the route makes: the first attributed to nothing, the second to music, which is not on this machine. Row counts come from `pg_class.reltuples` — an exact count on a large table is not worth blocking a page render for a number that only answers "is there anything in here". tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c98b5643aa |
show what the commands are actually saying
Install, uninstall and purge narrated what the platform was doing and hid what it was running. Every spawn captured stdout and stderr, held them, and surfaced the last 600 characters only if the exit code was non-zero. That is enough to explain a crash and useless for the two things people want while watching an install: knowing a slow step is still moving, and seeing the warning a command prints on its way to exiting 0. The second is not hypothetical — `db:push` reported success on a machine where it created no tables. Its output was captured, discarded because the exit code was fine, and the failure surfaced minutes later as 42P01 in a sidecar log. Whatever drizzle said about that push, nobody ever read it. `runLogged` streams both pipes as they arrive, so they interleave the way they do in a terminal. Following only stdout would have been silent for exactly the commands worth watching: git writes everything to stderr, drizzle-kit writes progress there and results to stdout. Two pieces of noise control, because a log nobody can read is not an improvement: `git clone` gets `--progress` instead of `--quiet` (git says nothing when it thinks it has no terminal, and that is the step where a slow link looks like a hang) with per-percent rewrites collapsed to the last fragment per chunk; and drizzle's spinner, which is eight lines saying the same thing with a different braille glyph, collapses to the first — otherwise "Changes applied" scrolls past underneath it. Steps and output share ONE ordered list rather than two. Their order relative to each other is the whole information: "which step was this output from" is the only question the log exists to answer. Output renders dimmer and indented under the step that produced it. Verified by running a real purge and reinstall: the clone's object counts, the barrel generation naming both plugins, drizzle's push, then "schema: applied in 2029ms (1 table verified)". tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3c03e61c5e |
delete a plugin and everything it owns, and stop trusting exit codes
Two halves of the same problem: a plugin installed onto a fresh machine, reported success at every step, mounted, started its sidecar, registered — and then failed its first query with 42P01, because `offscale_servers` had never been created. Nothing said so, and there was no way back to a clean machine to try again. ── The install now asks Postgres ── `pushSchema` only checked the exit code, and `db:push` is a compound command: generate the barrel, cd, run drizzle-kit. It can exit 0 having created nothing. So the install now reads the plugin's schema for the tables it declares and verifies they exist, failing the install with a message naming what is missing instead of leaving a stack trace to appear in a sidecar log minutes later. Tables that are NOT prefixed with the app name are reported as a warning rather than a failure — they still get created, but `purge` will not remove them and a reinstall would find them already there. ── `purge`, next to an uninstall that deliberately keeps everything ── Uninstall is careful on purpose: every table and row survives so a reinstall puts you back where you were. That is what makes it safe to try, and it left no way to get to a CLEAN machine. A second install finds the directory (so the catalogue hides the plugin) and the tables (so push has nothing to do, and a broken schema stays broken). So a different verb, a different word, its own confirmation. It reads the table list from the schema BEFORE deleting the source, uninstalls (stopping the sidecar — nothing may be running against tables about to vanish), drops, deletes the directory, then regenerates the barrel unconditionally, since a barrel naming a directory that no longer exists is what stopped a server booting yesterday. The table list comes from the plugin's own schema, not from `like '<app>_%'` — that query eventually drops somebody else's table, because the prefix is a convention nothing validates and `music` is a prefix of `music-videos`. The prefix is used as a GUARD instead: a schema module could export `users`, and anything outside the plugin's own namespace is refused rather than dropped. ── Verified as a full loop on this machine ── purge offscale → table dropped, directory deleted, barrel regenerated → the marketplace offers it again → install → cloned, ssh checked, "schema: applied in 2072ms (1 table verified)", dock pinned, mounted, sidecar started. I left offscale INSTALLED here rather than restoring the half-state it was in (no install row, table present, sidecar running). tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8ad5a5563e |
clicking a plugin works again, and offscale gets its icon
Two regressions and one long-standing hole, all found by actually opening the page. ── Clicking a row did nothing. That one is mine ── Rows link to `?selected=<app>`, and they still pointed at `/plugins`. My redirect was `<Navigate to="/marketplace" replace />`, which DROPS the query string — so a click went to `/plugins?selected=music`, redirected to a bare `/marketplace`, and the detail pane sat on "Select a plugin." Both halves worked; the redirect threw away what the link had just set. Rows now point at `/marketplace` directly. The redirect also preserves `search`, because old bookmarks carry the same param and losing it is exactly what made this wrong the first time. ── Offscale's icon 404'd since it shipped one ── Assets were published to `public/plugins/<app>/` at INSTALL and nowhere else. Offscale was installed before it had an `assets/icon.png`, so the directory never existed and the tile drew a 404 permanently — nothing short of uninstalling and reinstalling would have fixed it. "Reinstall the plugin to see its new icon" is the same bad answer as "restart the server to see it", which the route right next to this already refuses to accept. Publishing now happens on every mount. It is a copy of files that still exist in the plugin's own source, so re-running it is free and cannot lose anything — the property that already makes unpublish the one thing uninstall may delete. A failure is logged, never thrown: an icon is not worth refusing to mount over. Published offscale's by hand too, so the fix is visible without waiting for a restart. Verified 200 through the running server for both plugins. ── The third report was not a bug ── "No connection to the marketplace" is the catalogue working correctly: all three plugins are already on disk here, and `router.ts` drops catalogue entries whose plugin is installed, because offering to install something installed is worse than showing nothing. With nothing left to offer, a working store and a broken one look identical on that screen. Nothing in the logs, which is what a reachable store looks like — the client only speaks when it cannot reach one. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ba20155a09 |
the store's README shows in the platform, not just on the website
A plugin's page in the Marketplace screen now renders the README the marketplace holds — the same text the public site shows, in the pane where somebody actually decides whether to install it. Fetched by the SERVER and proxied, though CORS would have allowed the browser to ask directly. An Officer client is routinely somewhere its server is not: a phone on the tailnet, a laptop with the store blocked. The server already had to reach the marketplace for the catalogue, so routing through it means the README appears in exactly the cases the plugin list did — rather than in a strictly smaller set, which is the kind of difference nobody reports and everybody notices. Separate request from the list, because the list endpoint deliberately carries no READMEs: three of them is ~38KB of markdown on the request that draws the tiles. This one fires when a plugin is opened. Absence renders nothing at all — no error, no empty state. A plugin without a README is normal and a store that is down must not put a failure message on a page whose job is installing things. Under `/plugins` so the existing permission covers it: reading the store and installing from it are the same privilege, and a second key would be a second thing to grant for no separate decision. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
08d725bfb3 |
the background job button wears the amber a running job wears
Not white. `toneText.warning` — hsl(43 96% 56%), the same amber a running task chip shows two rows up, so the control and the thing it produces read as one feature rather than two. Taken from `toneText` rather than typed as `text-warning`, so it cannot drift from the chips it is matching. The name is misleading in this one place: nothing here is a warning, it is just where that colour lives in the palette. Dialog title icon follows, same reason as the glyph did. tsgo clean, frontend builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a78541a341 |
circle-play for the background job button, at full strength
White rather than muted, 18px rather than 16. Its neighbours on that row are pickers — they report what is already selected — and this is the only control there that does something, so it should not be wearing the same recessive grey as a label. The glyph was a radio tower, chosen because a play button implies one-shot. That reasoning was too clever: most of what gets started here is a long-running process, and a watcher is still something you press start on. Recognisable beats precise at this size. The dialog's own title icon changes with it. A button and the thing it opens wearing different glyphs is a small lie about them being the same feature. tsgo clean, frontend builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f580d845ac |
running jobs sit ahead of finished ones in the tray
The tray was newest-first, full stop. So: ask for a long-lived job, talk for a while, and every short-lived job an agent starts along the way pushes it one place further right — until the one thing still running is the one thing off the end of a strip that scrolls. Exactly backwards. What falls off the end should be what you are not watching. A finished chip is a receipt: it exists so a notification that landed while you were reading something else is still there when you look up. A running chip is a live thing you may need to open or stop. Ordering them together by age treats those as the same kind of object, and the strip's overflow turns that into the receipts slowly evicting the job. So `orderTasks`: running first, newest-first preserved within each group. Two passes rather than a comparator, so it does not lean on sort stability and reads as the partition it is. Applied after the FINISHED_KEPT cap, not before. The cap asks which finished chips are still worth keeping, which is a question about age; where they then sit is a different question. Pinned chips inherit the same order, so a pinned running job leads its row too. Cosmetic, as asked — no behaviour changes, nothing new is hidden or dropped. 5 tests, tsgo clean, frontend builds, 865 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2b70a5700e |
the code editor stops underlining correct code
Not spell check — Monaco's own TypeScript worker, misconfigured in two
independent ways. Both are visible in `monaco.contribution.js:272`, where the
defaults are `{ allowNonTsExtensions: true, target: Latest }` and
`{ noSemanticValidation: false }`.
── 1. It was reading JSX as TypeScript ──
`tsWorker.js:93` decides a file's script kind from the SUFFIX of its model URI:
`ts`/`tsx`/`js`/`jsx`, and otherwise falls back to TS. `<Editor>` was given no
`path`, so its model was `inmemory://model/1` — no dot, no suffix, TS. JSX is
illegal in a `.ts` file, so every tag in the file was a syntax error. That is
the wall-to-wall squiggling in the screenshot.
Passing the real path gives the model a `.tsx` URI. `jsx: ReactJSX` is set too;
both are needed, since the extension alone still errors without the option.
── 2. It was type-checking against a file system it cannot see ──
Semantic validation is now off, and this is the part worth stating plainly.
Monaco runs in the browser; the files are on the server behind the file API.
There is no `node_modules` to resolve `react` from, no `tsconfig.json` to read
paths out of, no way to follow a relative import. So every import resolves to
nothing — which is where "Cannot find module './logo.svg'" came from. Not a
mistake in the file: a checker with no file system.
Wrong errors are worse than none. They teach you to ignore the colour, and then
the one real problem underlined the same way goes unread. Monaco's own authors
already made this call for JavaScript — `javascriptDefaults` ships with
`noSemanticValidation: true`. This extends it to TypeScript for the same reason.
Syntactic validation stays ON: an unclosed brace needs only the buffer in front
of it, and with the extension finally right it is answered correctly.
Real semantic checking would mean shipping the project's dependency types into
the browser — a language-server-over-websocket feature, not a config change.
tsgo clean, frontend builds, 860 pass / 7 fail unchanged. Not verified in a
browser: I cannot see the editor render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e2f7a3f091 |
the preview reloads for jobs you started, not jobs the agent started
Yesterday's change bumped `background-job:changed` on every `task:started`. That was wrong in a way the commit message admitted and then waved through: agents start background jobs constantly while working — a test run, a build, a long grep — so the reload trigger had moved from "every edit" to "every incidental job" without getting meaningfully rarer. Same churn, new cause. The jobs worth reacting to are the ones started from the background-job dialog, because that is where somebody says "run the dev server". ── Arm and claim, not an id ── The dialog cannot name the task it is about to create: it sends a prompt, the agent works out the command, and the harness assigns the id. Nothing links the two but sequence. So the dialog ARMS, the next `task:started` claims the arm and records its id, and from then on completions are matched by id — however long the job runs. One arm, one claim: a second job in the same turn is not ours. The arm expires after 30 minutes, because the preamble tells the agent to ask before arming anything ambiguous, so a legitimate claim can be minutes late — but an abandoned conversation would otherwise leave the arm sitting there to be taken by whatever an agent started next. A job stopped by hand is adopted regardless of who started it. That is the one place the rule bends, and it bends towards the user having clicked something. Module state rather than a channel: it is read inside the websocket handler to decide whether to bump, and nothing renders from it. `createBackgroundJobOrigin` is exported so tests get a fresh instance instead of a reset that production code could also call. Still over-triggers within that set — any dialog-started job reloads every preview. Nothing observable links a preview's URL to a job, since the parent cannot see inside a cross-origin iframe. 9 new tests, tsgo clean, frontend builds, 860 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4ced2983a6 |
the preview reloads when a job restarts, not when a file changes
Restarting the dev server leaves the iframe holding a dead HMR socket. The page keeps rendering and looks completely alive — it has simply stopped receiving updates. So the agent edits a file, nothing moves, and there is no way to tell whether the edit failed or the connection did. Reported after exactly that sequence: stop, start, ask for a change, watch nothing happen. A stale preview that is indistinguishable from a working one is worse than an obviously broken one, because you debug the wrong thing. ── Which signal, and why not the obvious one ── `preview:refresh` already exists and is bumped on every turn that touched a file. Subscribing to it would fix this and ruin the feature: a reload throws away scroll position, form state, and whatever route you had navigated to inside the app — on every edit. HMR exists precisely so that does not happen. So a new channel, bumped on `task:started` and on task completion. A job coming up may be a dev server coming back; a job going down may be it going away. Both are moments a preview pointed at it is now lying. Neither fires on an edit — this is a few times a session, not once a keystroke. Stated rather than hidden: it OVER-triggers. Any job starting reloads any preview, not only the one serving it. That costs one reload on an unrelated watcher, which is the cheaper of the two errors. The parent process cannot see inside a cross-origin iframe, so there is no way to ask whether its socket is alive. The job lifecycle is the closest observable proxy, and it is exact for the case that prompted this. tsgo clean, frontend builds, 851 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6b21d4b8bd |
duration is one checkbox, because two of the three options were the same thing
"This session" meant nothing. The backend deliberately holds the agent process alive while a background job is running, so closing the browser does not end one — "this session" and "until stopped" were one behaviour wearing two labels, and offering both implied a difference that does not exist. So there is one real choice, and it is a yes/no: bound it by time, or run until stopped. [ ] Stop after [60] minutes Unchecked is the default and the common case. Checked exits when the time is up whether or not it fired, which is the only thing a clock can usefully mean here. This also removes a note that had become false. The dialog used to say "all of these end if the session does" — written from the process tree, where the job is a descendant of the agent process. True as far as it went, and wrong in practice because of the keep-alive. It now says what each choice actually does instead of what the ancestry suggests. Still true, and unstated because it belongs somewhere else: `pm2 restart officer-claude-code` ends every running job. That is a deploy-time fact, not something to put in a composer. tsgo clean, frontend builds, 851 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1a9b162615 |
a stopped job stays stopped, and Stop is a real button when expanded
Two things from stopping one by hand. ── It restarted itself ── Pressing stop produced the exit notification, and the agent read that as a crash: it brought the server back up, then wrote three paragraphs theorising that "something outside this conversation sent that SIGTERM" and counting it as the third external touch on the repo today. It was not wrong about the signal. It could not have been right about the cause, because the stop button did not exist when the preamble was written, and the preamble said only "report" — leaving what to do with an exit entirely open. So it now says: on exit, one line, and STOP. Do not restart. Do not diagnose. Do not theorise about what else on the machine might have touched it. A job I stopped that comes back on its own is the opposite of a stop button — and an exit is at least as likely to be me as a failure now that pressing a button is how you end one. Deliberately not written as "ignore exit code 143": the phrasing is about intent, not signal numbers, so it holds whether the job dies by SIGTERM, SIGKILL or its own hand. ── Stop, labelled, in the expanded panel ── The chip keeps its icon; the panel gets a bordered button that says "Stop". The panel is where you go to decide whether something should keep running, and the control that ends it should not be a glyph you have to hover to identify. Both appear only while the task is running. tsgo clean, frontend builds, 851 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
048e718b21 |
a stop button on running background jobs
Killing a job meant asking the agent in words — a whole turn to say "please stop
that", and only possible while the agent is responsive. Now it is a button on the
chip.
── Finding the process ──
The platform does not own these processes. Claude Code starts them and nothing
records a pid. What it has is the file the job streams into, and the job is still
holding that file open — so `fuser` gives the pid, and the file is resolved
through the SAME scoped lookup the read path already uses: the id is validated
against TASK_ID_RE, then matched only under this caller's own directories. An id
that resolves to nothing kills nothing.
Verified end to end rather than reasoned about: a stand-in job holding its output
file open, `fuser` finding it, `{"stopped":true}`, process gone.
SIGTERM, not SIGKILL — a dev server with children should get the chance to take
them down with it. SIGKILL orphans them and leaves the port bound, which is worse
than a stop that takes a second.
── Two things it deliberately does not do ──
It does not dismiss the chip or write a `stopped` status locally. The real end
arrives the way every other end does — the harness sees the exit and the
transcript gets the row — so faking it would be a guess that usually agrees with
reality and sometimes does not. A stop that fails leaves the chip running, which
is the truth.
It does not offer to stop a SUBAGENT. Those write a `.jsonl` transcript rather
than holding a log open, so there is no fd to trace; it says so instead of
pretending.
`Square`, not `X`. X on the same chip means "close this", and confusing "hide the
chip" with "kill the process" is the one mistake worth designing out — which is
why stop appears only on running tasks and dismiss only on finished ones.
tsgo clean, frontend builds, 851 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|