diff --git a/plugins/music/MUSIC_API.md b/plugins/music/MUSIC_API.md index 2b1f6172..3f07484c 100644 --- a/plugins/music/MUSIC_API.md +++ b/plugins/music/MUSIC_API.md @@ -3,7 +3,7 @@ Platform API the mobile app uses to **stream music** and **sync a server-built library index**, so the app no longer pre-downloads whole tracks or walks/ID3-parses the library on-device. -- **Source of truth for the code:** `src/servers/sidecar/music/index.ts` (the `officer-music` sidecar owns +- **Source of truth for the code:** `plugins/music/sidecar/index.ts` (the `officer-music` sidecar owns all of this; the platform `/api/music/*` route is a transparent auth-ing proxy). - **Music root:** `~/Music` on the server. All `path` values are **home-relative** (e.g. `Music/Albums/AC-DC/[1980] Back in Black/01 Hells Bells.mp3`), identical to `/api/file-browser/raw`. diff --git a/plugins/music/PLUGIN.md b/plugins/music/PLUGIN.md index 57326994..3216cb6e 100644 --- a/plugins/music/PLUGIN.md +++ b/plugins/music/PLUGIN.md @@ -34,30 +34,62 @@ scripts/ the reindex CLI, which talks to the sidecar port directly Offscale left nothing behind. Music leaves three, and calling them seams rather than loose ends only means each one is written down with what would close it. -### 1. cliamp — out of scope by decision +### 1. cliamp — parked in the plugin, not left in the platform `cliamp` and `cliamp-audio` are a _second_ playback path: the `cliamp` TUI run on the server, with its -terminal and its PulseAudio null sink piped to the browser. The owner's call was that it is the least -important part of music and not worth blocking the extraction on. +terminal and its PulseAudio null sink piped to the browser. Out of scope by the owner's decision. -It was already inert before any of this — the two sockets are declared in `server.tsx`'s route table and -upgrade into `handlers` entries that are commented out. So: +**All of it now lives in `./cliamp/`** — moved 2026-08-15, in two passes on the same day: -- `src/servers/sidecar/music/` still holds `cliamp-ws.ts`, `pulse-audio.ts`, `asoundrc` and - `cliamp-ws.test.ts`. **Untouched.** -- This plugin's sidecar still serves those sockets, so it imports both modules from - `@@/sidecar/music/`. A plugin importing platform code is ordinary; the reverse would not be. -- `src/servers/api/cliamp/relay.ts` stays, and it is what keeps the next item alive. +``` +sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, cliamp-ws.test.ts → cliamp/ +api/cliamp/relay.ts → cliamp/relay.ts +apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx → cliamp/ +``` -### 2. `src/servers/api/music/router.ts` — kept alive by the relay +`src/servers/sidecar/music/`, `src/servers/api/cliamp/` and `src/servers/api/music/` are **gone**, and +`server.tsx` has no cliamp import, provider name, handler entry or route left. -`relay.ts` imports `getMusicServerWsUrl` from it. So the platform's proxy could not move, and this -plugin's `api/router.ts` **re-exports it** rather than building a second one. +Two things went with it that were live rather than inert: -That is not laziness. `createSidecarProxy` learns its port from a one-shot `music:server` event and -subscribes at import. Two proxies would mean two subscribers, both working today, and a `503` on the -first reconnect where only one of them happened to be listening — the same class of failure as the -install-order bug offscale found, and just as invisible from reading. +- **The file browser's `Play` action.** A context-menu item on any audio file or folder set `?play=`, + which rendered a cliamp terminal panel pointed at `/api/cliamp/ws` — a route that upgraded into a + `handlers` entry that was commented out, so `handlers[provider]!.open(ws)` asserted non-null on + `undefined`. **Using that menu item crashed the socket handler.** The action, its layout, its panel and + its two menu entries are removed; the components are parked here. +- **The two socket routes.** They now 404. Verified live. + +That closed the totality drift as a side effect: `server.tsx`'s route table and its `handlers` map agree +again, which they had not since 2026-08-13. `registry.test.ts` keeps an assertion on it. + +**What it takes to bring cliamp back:** a plugin owning a websocket. `server.reload({ routes })` is proven +and never called. A platform gap, not a music one. + +### 2. ~~`src/servers/api/music/router.ts`~~ — deleted, and the reason it existed was nothing + +The proxy was constructed in PLATFORM code that knew the string `'music'`, and this plugin's +`api/router.ts` merely re-exported it. The stated reason: `api/cliamp/relay.ts` imported +`getMusicServerWsUrl` from it, so it could not move. + +That reason was three layers of nothing: + +- `server.tsx:20` imported the relay's two exports — **used only on commented-out lines** +- so the relay's functions were never invoked, and its call to `getMusicServerWsUrl` never ran +- and the file's other export, `getMusicServerUrl`, had **no consumers at all** + +A dead import held a music-named file in the platform. The proxy is now built in +`plugins/music/api/router.ts`; the relay takes its URL from there. + +**And the prefix is derived rather than written.** It was the literal `'/api/music'`, which the proxy uses +to strip characters off the path. That is correct only because `mountPrefix` returns `/music` for a +first-party publisher — the same plugin published by anyone else mounts at `/api/p//music` and +would have forwarded `/alice/music/stream` to a sidecar expecting `/stream`. A latent bug only third +parties would ever hit, and a quiet violation of the rule that `mountPrefix` is the one function allowed +to know about provenance. It now calls `mountPrefix`. + +`[open]` `appName` is still a literal there, because a plugin's router cannot see its own directory name — +the platform imports the module and reads `router`, so there is nowhere to inject it. The fix is +`api/router.ts` exporting a factory the installer calls with the plugin's own identity. ### 3. The player — the one open judgement call, and it is decided diff --git a/plugins/music/api/router.ts b/plugins/music/api/router.ts index 348bbc7d..7259a6d4 100644 --- a/plugins/music/api/router.ts +++ b/plugins/music/api/router.ts @@ -1,18 +1,46 @@ -import { musicRouter } from '@@/api/music/router'; +import { createSidecarProxy } from '@@/sidecar/create-proxy'; +import { mountPrefix } from '@@/plugins/manifest'; +import { manifest } from '../manifest'; -// /api/music/* — auth, then forward to officer-music. +// /api/music/* — auth, then forward to officer-music. No routes of its own and no music knowledge here: +// the whole contract lives in ../sidecar/index.ts, which is where the routes actually are. // -// ── Why this re-exports the platform's proxy instead of creating its own ── +// ── This used to live in the platform, and that was the bug ── // -// `src/servers/api/music/router.ts` has to stay behind: `api/cliamp/relay.ts` imports -// `getMusicServerWsUrl` from it to pipe the cliamp player socket to this sidecar, and cliamp is -// deliberately out of scope — it is a second playback path that the platform still owns. +// Until 2026-08-15 the proxy was constructed in `src/servers/api/music/router.ts` — PLATFORM code that +// knew the string 'music' — and this file merely re-exported it. The justification was that +// `api/cliamp/relay.ts` imported `getMusicServerWsUrl` from it, so it could not move. // -// So the proxy already exists, and building a SECOND `createSidecarProxy({ name: 'music' })` here would -// mean two subscribers to the one-shot `music:server` port announcement. Both would work today, and the -// first reconnect where only one of them was listening would produce a 503 nobody could explain. One -// proxy, one subscription, mounted by whoever needs it. +// That justification was three layers of nothing. The relay's functions were only reachable through +// `handlers` entries in server.tsx that were commented out, and its own import there was unused. A dead +// import held a music-named file in the platform, and the second export on it (`getMusicServerUrl`) had +// no callers at all. The relay now lives in ../cliamp/ and takes its URL from here. // -// The seam is one file, and it is inert when this plugin is not installed: the router is only reachable -// once `mountPrefix()` puts it under `/api/music`, which only happens for an installed, enabled plugin. -export const router = musicRouter; +// ── The prefix is DERIVED, not written ── +// +// It was the literal '/api/music', and that is wrong in a way that only shows up for someone else's +// plugin. The proxy strips `prefix.length` characters to build the sidecar path, so a hardcoded +// '/api/music' (10 chars) is correct only because `mountPrefix` happens to return `/music` for a +// first-party publisher. The same plugin published by anyone else mounts at `/api/p//music` +// and would forward `/alice/music/stream` to a sidecar expecting `/stream`. +// +// `mountPrefix` is the ONE function allowed to know about provenance, so the prefix comes from it. A +// literal here is that rule being broken quietly, which is exactly how first-party and third-party +// become two systems with only one of them tested. +// +// `appName` is passed as a literal because this file cannot see its own directory name. That is a real +// gap — the platform imports `router.ts` and reads `router`, so there is nowhere to inject it — and the +// day a plugin's router needs its own identity for anything else, `api/router.ts` should export a +// factory the installer calls instead. Recorded rather than worked around. +const proxy = createSidecarProxy({ + name: 'music', + prefix: `/api${mountPrefix({ appName: 'music', manifest })}`, + // A from-scratch reindex holds the connection open for minutes with no bytes flowing; the default 60s + // idle drop would kill it. Applied to the whole prefix — the proxy must not know which routes are slow. + timeoutSeconds: 1800, +}); + +export const router = proxy.router; + +/** The sidecar as a `ws://` base. Used by ../cliamp/relay.ts, and by nothing else. */ +export const getMusicServerWsUrl = proxy.getWsUrl; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/AudioStreamPlayer.tsx b/plugins/music/cliamp/AudioStreamPlayer.tsx similarity index 93% rename from src/workspaces/officerdev/src/apps/FileBrowser/AudioStreamPlayer.tsx rename to plugins/music/cliamp/AudioStreamPlayer.tsx index c5c4fd67..8dcc6e0d 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/AudioStreamPlayer.tsx +++ b/plugins/music/cliamp/AudioStreamPlayer.tsx @@ -83,7 +83,10 @@ export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) => ctxRef.current = audioCtx; await audioCtx.audioWorklet.addModule(workletBlobUrl); - if (disposed) { audioCtx.close(); return; } + if (disposed) { + audioCtx.close(); + return; + } const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor', { outputChannelCount: [CHANNELS], @@ -138,11 +141,23 @@ export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) => return () => { disposed = true; - try { wsRef.current?.close(); } catch { /* ignore */ } + try { + wsRef.current?.close(); + } catch { + /* ignore */ + } wsRef.current = null; - try { nodeRef.current?.disconnect(); } catch { /* ignore */ } + try { + nodeRef.current?.disconnect(); + } catch { + /* ignore */ + } nodeRef.current = null; - try { audioCtx?.close(); } catch { /* ignore */ } + try { + audioCtx?.close(); + } catch { + /* ignore */ + } ctxRef.current = null; gainRef.current = null; }; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/CliampPanel.tsx b/plugins/music/cliamp/CliampPanel.tsx similarity index 95% rename from src/workspaces/officerdev/src/apps/FileBrowser/CliampPanel.tsx rename to plugins/music/cliamp/CliampPanel.tsx index 7e59fa24..d59f1732 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/CliampPanel.tsx +++ b/plugins/music/cliamp/CliampPanel.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { useSearchParams } from 'react-router'; import { Music } from 'lucide-react'; -import { TerminalView } from '../Terminal/Terminal'; +import { TerminalView } from 'officerdev'; import { AudioStreamPlayer } from './AudioStreamPlayer'; export const CliampPanelHeader = () => { diff --git a/src/servers/sidecar/music/asoundrc b/plugins/music/cliamp/asoundrc similarity index 100% rename from src/servers/sidecar/music/asoundrc rename to plugins/music/cliamp/asoundrc diff --git a/src/servers/sidecar/music/cliamp-ws.test.ts b/plugins/music/cliamp/cliamp-ws.test.ts similarity index 100% rename from src/servers/sidecar/music/cliamp-ws.test.ts rename to plugins/music/cliamp/cliamp-ws.test.ts diff --git a/src/servers/sidecar/music/cliamp-ws.ts b/plugins/music/cliamp/cliamp-ws.ts similarity index 100% rename from src/servers/sidecar/music/cliamp-ws.ts rename to plugins/music/cliamp/cliamp-ws.ts diff --git a/src/servers/sidecar/music/pulse-audio.ts b/plugins/music/cliamp/pulse-audio.ts similarity index 100% rename from src/servers/sidecar/music/pulse-audio.ts rename to plugins/music/cliamp/pulse-audio.ts diff --git a/src/servers/api/cliamp/relay.ts b/plugins/music/cliamp/relay.ts similarity index 95% rename from src/servers/api/cliamp/relay.ts rename to plugins/music/cliamp/relay.ts index a34d592d..da4a6c3b 100644 --- a/src/servers/api/cliamp/relay.ts +++ b/plugins/music/cliamp/relay.ts @@ -1,9 +1,9 @@ import type { ServerWebSocket } from 'bun'; -import { getMusicServerWsUrl } from '../music/router'; +import { getMusicServerWsUrl } from '../api/router'; // Platform side of the two cliamp sockets. Both used to spawn processes here — the `cliamp` player and a // `parec` capture — which put the whole local-audio pipeline inside the thin proxy. They now live in the -// music sidecar (`sidecar/music/cliamp-ws.ts`), and this is what is left of them: authenticate the browser +// music PLUGIN (`plugins/music/cliamp/cliamp-ws.ts`), and this is what is left of them: authenticate the browser // (done before the upgrade, in server.tsx), then pass frames through in both directions without reading // them. Text or binary, no inspection — same dumb-pipe shape as the vault notifications relay. diff --git a/plugins/music/manifest.ts b/plugins/music/manifest.ts index 8d9f07a3..edba2839 100644 --- a/plugins/music/manifest.ts +++ b/plugins/music/manifest.ts @@ -3,21 +3,21 @@ import type { PluginManifest } from '@@/plugins/manifest'; // Music — the library, the player, and the phone and tablet apps that stream from it. // // The second plugin extracted from the platform, on 2026-08-15. Bigger than offscale and, unlike it, not -// a clean cut: three pieces stay behind deliberately. Each is a documented seam rather than a loose end, -// and each is recorded in ./PLUGIN.md with what would have to change to close it. +// a clean cut. It took two passes: the first left three pieces in the platform, and the second moved two +// of them here after the owner read the code and asked why the platform still had files named for music. +// He was right — one of the three "seams" turned out to be dead code holding the door open. // -// api/router.ts re-exports the platform's music proxy — see that file for why it is not a new one +// api/router.ts the sidecar proxy, built here — thin, and it must never grow music knowledge // sidecar/ the whole /api/music contract: indexing, streaming, per-user state // db/ music_favorites, _playlists, _playlist_items, _now_playing // web/ the library panels; the shell renders the Workspace // -// ── What stayed in the platform, and why ── +// ── What stayed in the platform, and why ── (one item, down from three) // -// 1. cliamp (`/api/cliamp/ws`, `/api/cliamp/audio/ws`, `sidecar/music/cliamp-ws.ts`, `pulse-audio.ts`, -// `asoundrc`). A second playback path — the `cliamp` TUI run on the server with its terminal and its -// PulseAudio null sink piped to the browser. Already inert (the routes upgrade into commented-out -// handlers) and out of scope by the owner's decision. This sidecar still serves those sockets, so it -// imports both modules from `@@/sidecar/music/`. +// 1. ~~cliamp~~ — MOVED HERE, all of it, into `./cliamp/`. The sidecar halves, the relay, the file +// browser's panel and its `Play` action. `src/servers/sidecar/music/`, `src/servers/api/cliamp/` and +// `src/servers/api/music/` no longer exist, and `server.tsx` has no cliamp anything. It is PARKED, not +// working: bringing it back needs a plugin to own a websocket, which is a platform gap. // // 2. The dashboard widget (`src/workspaces/widgets/MusicPlayer/`). Plugins cannot contribute widgets and // the mechanism was not worth inventing for one. diff --git a/plugins/music/sidecar/index.ts b/plugins/music/sidecar/index.ts index eff1cea2..7c879fbb 100644 --- a/plugins/music/sidecar/index.ts +++ b/plugins/music/sidecar/index.ts @@ -3,8 +3,8 @@ import { join, basename } from 'node:path'; import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; import { createSidecarConnector } from '@@/sidecar/connect'; import { streamAudioFile } from './stream-audio'; -import { cliampUpgradeData, musicWebsocket } from '@@/sidecar/music/cliamp-ws'; -import { ensurePulseAudio } from '@@/sidecar/music/pulse-audio'; +import { cliampUpgradeData, musicWebsocket } from '../cliamp/cliamp-ws'; +import { ensurePulseAudio } from '../cliamp/pulse-audio'; import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex'; import { reindexNow, diff --git a/src/server.tsx b/src/server.tsx index cfd8bd5f..ddffddaf 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -17,7 +17,6 @@ import { terminalWebsocket } from './servers/api/terminal/websocket'; import { chatWebsocket } from './servers/api/chat/websocket'; import { taskRunnerWebsocket } from './servers/api/tasks/task-executor'; import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor'; -import { cliampWebsocket, cliampAudioWebsocket } from './servers/api/cliamp/relay'; // import { desktopWebsocket } from './servers/api/desktop/websocket'; // import { vaultWebsocket, upgradeVaultWs } from './servers/api/vault/websocket'; import officerWeb from './apps/officer-web/index.gen.html'; @@ -38,16 +37,7 @@ type WSData = { userId: number; email: string; username: string; - provider: - | 'terminal' - | 'chat' - | 'task-runner' - | 'pipeline' - | 'cliamp' - | 'cliamp-audio' - | 'desktop' - | 'vault' - | 'sidecar'; + provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'desktop' | 'vault' | 'sidecar'; sessionId?: string; cwd?: string; command?: string; @@ -137,8 +127,6 @@ const handlers: Record = { chat: chatWebsocket, 'task-runner': taskRunnerWebsocket, pipeline: pipelineWebsocket, - // cliamp: cliampWebsocket, - // 'cliamp-audio': cliampAudioWebsocket, // desktop: desktopWebsocket, // vault: vaultWebsocket, sidecar: sidecarWebsocket, @@ -264,15 +252,15 @@ console.log( async function upgradeWs( req: Request, server: any, - provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'cliamp' | 'cliamp-audio' | 'desktop', + provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'desktop', ) { const token = new URL(req.url).searchParams.get('token'); if (!token) return new Response('Unauthorized', { status: 401 }); try { - // Same resolver as the two HTTP doors, so a key that works against /api works here too — a music app - // holding one needs cliamp and cliamp-audio, and a socket that only understood JWTs would have made - // "signed in" and "can play audio" two different questions. `jti` is absent on a key, so the + // Same resolver as the two HTTP doors, so a key that works against /api works here too — a mobile app + // holding one gets the same doors as a browser session, and a socket that only understood JWTs would + // have made "signed in" and "can open this" two different questions. `jti` is absent on a key, so the // blacklist below simply does not apply to one; its revocation is a column, checked in the lookup. const user = await resolveAuthToken(token); if (!user) return new Response('Unauthorized', { status: 401 }); @@ -393,8 +381,6 @@ const server = serve({ '/api/tasks/pipeline/ws': (req, server) => upgradeWs(req, server, 'pipeline'), '/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'), '/api/chat/ws': (req, server) => upgradeWs(req, server, 'chat'), - '/api/cliamp/ws': (req, server) => upgradeWs(req, server, 'cliamp'), - '/api/cliamp/audio/ws': (req, server) => upgradeWs(req, server, 'cliamp-audio'), // '/api/desktop/ws': (req, server) => upgradeWs(req, server, 'desktop'), // CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe // fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any @@ -514,7 +500,8 @@ void (async () => { })(); // PulseAudio and the `virtual_out` sink used to be set up here, at every boot of a process that has no -// audio responsibilities. They belong to the music sidecar, which owns both cliamp halves now -// (sidecar/music/pulse-audio.ts). +// audio responsibilities. Every trace of cliamp left on 2026-08-15 — the relay, the two socket routes and +// the provider names — and now lives in `plugins/music/cliamp/`, parked rather than working. Bringing it +// back means a plugin owning a websocket, which is a platform gap, not a music one. // Pi check/install is handled by bootstrap.ts (imported above) diff --git a/src/servers/api/music/router.ts b/src/servers/api/music/router.ts deleted file mode 100644 index 5910a2a6..00000000 --- a/src/servers/api/music/router.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createSidecarProxy } from '../../sidecar/create-proxy'; - -// /api/music/* — auth, then forward to officer-music. No routes of its own and no music knowledge: -// this file must never grow app logic. -// -// The sidecar owns the entire /api/music/* contract: streaming, indexing, AND per-user state (favorites, -// now-playing, playlists) backed by Postgres. The full HTTP contract is documented at the top of the -// sidecar's fetch handler — src/servers/sidecar/music/index.ts. - -const proxy = createSidecarProxy({ - name: 'music', - prefix: '/api/music', - timeoutSeconds: 1800, -}); - -export const musicRouter = proxy.router; - -/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ -export const getMusicServerUrl = proxy.getHttpUrl; - -/** The same server as a `ws://` base — the cliamp relay pipes the player socket to it. */ -export const getMusicServerWsUrl = proxy.getWsUrl; diff --git a/src/servers/capabilities/authorize.ts b/src/servers/capabilities/authorize.ts index b39a7211..276cdd47 100644 --- a/src/servers/capabilities/authorize.ts +++ b/src/servers/capabilities/authorize.ts @@ -154,10 +154,12 @@ export async function isApiRequestAllowed( /** * May this account open this WebSocket provider? * - * There is no method to reason about, so a socket needs the capability at any level. That is deliberate - * for the two that reach here — cliamp and cliamp-audio are the music app's playback transport, which is - * the whole point of a music account. Every other provider belongs to an `execution` capability and is - * refused above, structurally, rather than by being left off a list. + * There is no method to reason about, so a socket needs the capability at any level. + * + * That rule was written for cliamp and cliamp-audio — the music app's playback transport, and the only + * grantable sockets there have ever been. Both left on 2026-08-15 with `plugins/music/cliamp/`, so every + * provider reaching here today belongs to an `execution` capability and is refused above, structurally, + * rather than by being left off a list. The rule stays because the first plugin to own a socket needs it. */ export async function isWsProviderAllowed(userId: number | undefined, provider: string): Promise { const { isOwner, grants } = await getEffectiveCapabilities(userId); diff --git a/src/servers/capabilities/registry.test.ts b/src/servers/capabilities/registry.test.ts index fb14eecd..3ed91c47 100644 --- a/src/servers/capabilities/registry.test.ts +++ b/src/servers/capabilities/registry.test.ts @@ -14,17 +14,9 @@ import { assertCapabilityTotality, isExemptApiPath } from './totality'; // The database-backed half (authorize.ts) is exercised against the live schema; this file covers the pure // half, which is where the rules actually live. Everything here runs without a database. -const REAL_WS = [ - 'terminal', - 'chat', - 'task-runner', - 'pipeline', - 'cliamp', - 'cliamp-audio', - 'desktop', - 'vault', - 'sidecar', -]; +// Mirrors the `handlers` map in server.tsx by hand, which is itself the drift this file keeps catching. +// `cliamp` and `cliamp-audio` left on 2026-08-15 with `plugins/music/cliamp/` — see below. +const REAL_WS = ['terminal', 'chat', 'task-runner', 'pipeline', 'desktop', 'vault', 'sidecar']; const realApi = () => [...new Set(CAPABILITIES.flatMap((c) => c.api))]; const surface = () => ({ apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/vault', '/sidecar'], @@ -106,16 +98,19 @@ describe('path → capability', () => { expect(capabilityForWsProvider('nope')).toBeNull(); }); - // `cliamp` and `cliamp-audio` are SERVED in server.tsx's route table and claimed by nothing, because - // music's `ws` list was commented out on 2026-08-13 and the capability itself left with the plugin on - // 2026-08-15. They upgrade into handlers that are commented out too, so nothing is reachable — but the - // boot check cannot see the drift, since it reads `Object.keys(handlers)` rather than the route table. + // This pinned a real hole for one day: `/api/cliamp/ws` and `/api/cliamp/audio/ws` were SERVED in + // server.tsx's route table while no capability claimed them and their `handlers` entries were commented + // out — so connecting crashed on a non-null assertion, and the boot check could not see any of it + // because it reads `Object.keys(handlers)` rather than the route table. // - // Pinned here so the hole is a documented fact with a test on it rather than something to rediscover. - // Closing it is the totality work in plugins/EXTRACTING-A-PLUGIN.md, not this file's. - test('the cliamp sockets are claimed by nothing — known drift, see server.tsx', () => { + // Closed on 2026-08-15 by cliamp leaving for `plugins/music/cliamp/`: the routes, the relay and the + // provider names all went with it, and the two lists agree again. Kept as an assertion rather than + // deleted, because "the route table serves nothing the handlers map lacks" is the invariant the + // original incident was about, and this is the cheapest place to notice it breaking again. + test('no cliamp socket is served or claimed — the route table and handlers agree', () => { expect(capabilityForWsProvider('cliamp')).toBeNull(); expect(capabilityForWsProvider('cliamp-audio')).toBeNull(); + expect(REAL_WS).not.toContain('cliamp'); }); }); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx index 892c7f3f..24761ce0 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx @@ -66,7 +66,6 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { setRenamingName, handleReadAloud, handleExtract, - handlePlay, getMatchingTaskGroups, getMatchingAgentGroups, handleRunTask, @@ -244,7 +243,6 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { onRenamingChange={setRenamingName} onReadAloud={handleReadAloud} onExtract={handleExtract} - onPlay={handlePlay} taskGroups={getMatchingTaskGroups(entry.name, entry.type)} onRunTask={handleRunTask} agentGroups={getMatchingAgentGroups(entry.name, entry.type)} diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx index 02359821..3c7cc116 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx @@ -14,7 +14,6 @@ import { Volume2, FolderArchive, ClipboardCopy, - Music, Bot, } from 'lucide-react'; import { getIcon } from 'material-file-icons'; @@ -64,7 +63,6 @@ export type FileItemProps = { onRenamingChange: (name: string | null) => void; onReadAloud: (entry: DirEntry) => void; onExtract: (entry: DirEntry) => void; - onPlay: (entry: DirEntry) => void; taskGroups: TaskGroup[]; onRunTask: (task: TaskSummary, entry: DirEntry) => void; agentGroups: AgentGroup[]; @@ -96,7 +94,6 @@ type MenuItemsProps = { onDownload: (e: DirEntry) => void; onReadAloud: (e: DirEntry) => void; onExtract: (e: DirEntry) => void; - onPlay: (e: DirEntry) => void; onCut: () => void; onCopy: () => void; taskGroups: TaskGroup[]; @@ -116,7 +113,6 @@ const DropdownMenuItems = ({ onDownload, onReadAloud, onExtract, - onPlay, onCut, onCopy, taskGroups, @@ -128,11 +124,10 @@ const DropdownMenuItems = ({ const fileType = entry.type === 'file' ? getFileType(entry.name) : null; const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text'; const showExtract = fileType === 'archive'; - const showPlay = fileType === 'audio' || entry.type === 'directory'; const hasTasks = taskGroups.length > 0; const hasAgents = agentGroups.length > 0; - const hasActions = showPlay || showReadAloud || showExtract || hasTasks || hasAgents; + const hasActions = showReadAloud || showExtract || hasTasks || hasAgents; // Only nest when more than one category matched — a file usually matches a single category, and // Run Task > Video > Convert would just add a hop. const nestTasks = taskGroups.length > 1; @@ -142,12 +137,6 @@ const DropdownMenuItems = ({ return ( <> - {showPlay && ( - onPlay(entry)} className="cursor-pointer"> - - Play - - )} {showReadAloud && ( onReadAloud(entry)} className="cursor-pointer"> @@ -285,7 +274,6 @@ const ContextMenuItems = ({ onDownload, onReadAloud, onExtract, - onPlay, onCut, onCopy, taskGroups, @@ -297,11 +285,10 @@ const ContextMenuItems = ({ const fileType = entry.type === 'file' ? getFileType(entry.name) : null; const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text'; const showExtract = fileType === 'archive'; - const showPlay = fileType === 'audio' || entry.type === 'directory'; const hasTasks = taskGroups.length > 0; const hasAgents = agentGroups.length > 0; - const hasActions = showPlay || showReadAloud || showExtract || hasTasks || hasAgents; + const hasActions = showReadAloud || showExtract || hasTasks || hasAgents; // Only nest when more than one category matched — a file usually matches a single category, and // Run Task > Video > Convert would just add a hop. const nestTasks = taskGroups.length > 1; @@ -311,12 +298,6 @@ const ContextMenuItems = ({ return ( <> - {showPlay && ( - onPlay(entry)} className="cursor-pointer"> - - Play - - )} {showReadAloud && ( onReadAloud(entry)} className="cursor-pointer"> @@ -563,7 +544,6 @@ export const FileItem = ({ onRenamingChange, onReadAloud, onExtract, - onPlay, taskGroups, onRunTask, agentGroups, @@ -645,7 +625,6 @@ export const FileItem = ({ onDownload, onReadAloud, onExtract, - onPlay, onCut, onCopy, taskGroups, diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx index ec965e77..9107fd07 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx @@ -1,5 +1,17 @@ import { useRef } from 'react'; -import { Loader2, Folder, ClipboardPaste, FolderPlus, FolderUp, LayoutGrid, Upload, ClipboardCopy, MessageSquare, Download, Mic } from 'lucide-react'; +import { + Loader2, + Folder, + ClipboardPaste, + FolderPlus, + FolderUp, + LayoutGrid, + Upload, + ClipboardCopy, + MessageSquare, + Download, + Mic, +} from 'lucide-react'; import { getIcon } from 'material-file-icons'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'; import type { UseFileBrowserAppType } from '../useFileBrowserApp'; @@ -92,9 +104,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps })} ) : searchResults ? ( -
- No results found -
+
No results found
) : null} ) : ( diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/SelectionActions.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/SelectionActions.tsx index ac6415c9..b6308148 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/SelectionActions.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/SelectionActions.tsx @@ -6,8 +6,16 @@ type SelectionActionsProps = { }; export const SelectionActions = ({ fileBrowserManager }: SelectionActionsProps) => { - const { selected, clipboard, handleCut, handleCopy, handlePaste, handleDownloadSelected, handleDeleteSelected, setSelected } = - fileBrowserManager; + const { + selected, + clipboard, + handleCut, + handleCopy, + handlePaste, + handleDownloadSelected, + handleDeleteSelected, + setSelected, + } = fileBrowserManager; return ( <> diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/usePipelineRunner.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/usePipelineRunner.ts index 63e4244d..f2552d17 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/usePipelineRunner.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/usePipelineRunner.ts @@ -33,19 +33,61 @@ type ParallelStep = { type ServerMessage = | { jobId: string; type: 'pipeline:init'; steps: StepDef[] } - | { jobId: string; type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } } - | { jobId: string; type: 'step:complete'; stepIndex: number; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } } + | { + jobId: string; + type: 'step:start'; + stepIndex: number; + taskName: string; + iteration?: { current: number; total: number; label: string }; + } + | { + jobId: string; + type: 'step:complete'; + stepIndex: number; + cost?: { inputTokens: number; outputTokens: number; totalUSD: number }; + } | { jobId: string; type: 'step:skip'; stepIndex: number; label: string; reason: string } - | { jobId: string; type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number } + | { + jobId: string; + type: 'step:parallel'; + stepIndex: number; + taskName: string; + iterations: string[]; + concurrency: number; + } | { jobId: string; type: 'step:waiting'; stepIndex: number; iterationLabel?: string; elapsed: number } | { jobId: string; type: 'iteration:start'; stepIndex: number; label: string } - | { jobId: string; type: 'iteration:complete'; stepIndex: number; label: string; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } } + | { + jobId: string; + type: 'iteration:complete'; + stepIndex: number; + label: string; + cost?: { inputTokens: number; outputTokens: number; totalUSD: number }; + } | { jobId: string; type: 'iteration:error'; stepIndex: number; label: string; error: string } | { jobId: string; type: 'assistant:delta'; text: string; iterationLabel?: string } | { jobId: string; type: 'assistant:text'; text: string; iterationLabel?: string } - | { jobId: string; type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record; iterationLabel?: string } - | { jobId: string; type: 'tool:result'; toolCallId: string; output: string; isError: boolean; iterationLabel?: string } - | { jobId: string; type: 'pipeline:complete'; totalCost: { inputTokens: number; outputTokens: number; totalUSD: number } } + | { + jobId: string; + type: 'tool:start'; + toolCallId: string; + toolName: string; + toolInput: Record; + iterationLabel?: string; + } + | { + jobId: string; + type: 'tool:result'; + toolCallId: string; + output: string; + isError: boolean; + iterationLabel?: string; + } + | { + jobId: string; + type: 'pipeline:complete'; + totalCost: { inputTokens: number; outputTokens: number; totalUSD: number }; + } | { jobId: string; type: 'error'; message: string } | { jobId: string; type: 'stopped' } | { type: 'job:created'; jobId: string } @@ -61,12 +103,18 @@ export function usePipelineRunner() { const [parallelStep, setParallelStep] = useState(null); const [messages, setMessages] = useState([]); const [streamingText, setStreamingText] = useState(''); - const [totalCost, setTotalCost] = useState<{ inputTokens: number; outputTokens: number; totalUSD: number } | null>(null); + const [totalCost, setTotalCost] = useState<{ inputTokens: number; outputTokens: number; totalUSD: number } | null>( + null, + ); const [runningCost, setRunningCost] = useState({ inputTokens: 0, outputTokens: 0, totalUSD: 0 }); const [hasError, setHasError] = useState(false); const [skippedItems, setSkippedItems] = useState>([]); const [elapsed, setElapsed] = useState(0); - const [waitingStatus, setWaitingStatus] = useState<{ stepIndex: number; elapsed: number; iterationLabel?: string } | null>(null); + const [waitingStatus, setWaitingStatus] = useState<{ + stepIndex: number; + elapsed: number; + iterationLabel?: string; + } | null>(null); const wsRef = useRef(null); const streamBufferRef = useRef(''); const startTimeRef = useRef(0); @@ -84,7 +132,10 @@ export function usePipelineRunner() { }, []); const stopTimer = useCallback(() => { - if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } }, []); const addCost = useCallback((cost: { inputTokens: number; outputTokens: number; totalUSD: number }) => { @@ -95,178 +146,184 @@ export function usePipelineRunner() { })); }, []); - const handleEvent = useCallback((msg: ServerMessage) => { - // Filter events by jobId (ignore events from other jobs) - if ('jobId' in msg && msg.jobId && jobIdRef.current && msg.jobId !== jobIdRef.current) return; + const handleEvent = useCallback( + (msg: ServerMessage) => { + // Filter events by jobId (ignore events from other jobs) + if ('jobId' in msg && msg.jobId && jobIdRef.current && msg.jobId !== jobIdRef.current) return; - switch (msg.type) { - case 'job:created': - jobIdRef.current = msg.jobId; - setJobId(msg.jobId); - break; + switch (msg.type) { + case 'job:created': + jobIdRef.current = msg.jobId; + setJobId(msg.jobId); + break; - case 'job:state': - // Reconnection to a completed/failed job - if (msg.status === 'completed' || msg.status === 'failed' || msg.status === 'stopped' || msg.status === 'interrupted') { + case 'job:state': + // Reconnection to a completed/failed job + if ( + msg.status === 'completed' || + msg.status === 'failed' || + msg.status === 'stopped' || + msg.status === 'interrupted' + ) { + setPhase('done'); + if (msg.cost) setTotalCost(msg.cost as { inputTokens: number; outputTokens: number; totalUSD: number }); + if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true); + stopTimer(); + } + break; + + case 'pipeline:init': + setSteps(msg.steps); + break; + + case 'step:start': + flushStream(); + setMessages([]); + setParallelStep(null); + setWaitingStatus(null); + inParallelRef.current = false; + setCurrentStep({ + taskName: msg.taskName, + iteration: msg.iteration, + status: 'running', + }); + break; + + case 'step:complete': + flushStream(); + setWaitingStatus(null); + setCurrentStep((prev) => (prev ? { ...prev, status: 'complete', cost: msg.cost } : null)); + if (msg.cost) addCost(msg.cost); + break; + + case 'step:skip': + setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]); + break; + + case 'step:waiting': + setWaitingStatus({ stepIndex: msg.stepIndex, elapsed: msg.elapsed, iterationLabel: msg.iterationLabel }); + break; + + case 'step:parallel': + flushStream(); + setMessages([]); + setCurrentStep(null); + inParallelRef.current = true; + setParallelStep({ + stepIndex: msg.stepIndex, + taskName: msg.taskName, + concurrency: msg.concurrency, + iterations: msg.iterations.map((label) => ({ label, status: 'pending' })), + }); + break; + + case 'iteration:start': + setParallelStep((prev) => { + if (!prev) return prev; + return { + ...prev, + iterations: prev.iterations.map((it) => (it.label === msg.label ? { ...it, status: 'running' } : it)), + }; + }); + break; + + case 'iteration:complete': + setParallelStep((prev) => { + if (!prev) return prev; + return { + ...prev, + iterations: prev.iterations.map((it) => + it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it, + ), + }; + }); + if (msg.cost) addCost(msg.cost); + break; + + case 'iteration:error': + setParallelStep((prev) => { + if (!prev) return prev; + return { + ...prev, + iterations: prev.iterations.map((it) => + it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it, + ), + }; + }); + break; + + case 'assistant:delta': + // Skip messages from parallel sub-agents (shown in iteration grid instead) + if (inParallelRef.current && msg.iterationLabel) break; + setWaitingStatus(null); + streamBufferRef.current += msg.text; + setStreamingText(streamBufferRef.current); + break; + + case 'assistant:text': { + if (inParallelRef.current && msg.iterationLabel) break; + const text = msg.text || streamBufferRef.current; + if (text) { + setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]); + } + streamBufferRef.current = ''; + setStreamingText(''); + break; + } + + case 'tool:start': + if (inParallelRef.current && msg.iterationLabel) break; + setWaitingStatus(null); + flushStream(); + setMessages((prev) => [ + ...prev, + { + role: 'tool' as const, + id: randomId(), + toolCallId: msg.toolCallId, + toolName: msg.toolName, + toolInput: msg.toolInput, + output: undefined, + isError: false, + }, + ]); + break; + + case 'tool:result': + if (inParallelRef.current && msg.iterationLabel) break; + setMessages((prev) => + prev.map((m) => + m.role === 'tool' && 'toolCallId' in m && m.toolCallId === msg.toolCallId + ? { ...m, output: msg.output, isError: msg.isError } + : m, + ), + ); + break; + + case 'pipeline:complete': + flushStream(); + setTotalCost(msg.totalCost); setPhase('done'); - if (msg.cost) setTotalCost(msg.cost as { inputTokens: number; outputTokens: number; totalUSD: number }); - if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true); stopTimer(); - } - break; + break; - case 'pipeline:init': - setSteps(msg.steps); - break; + case 'error': + flushStream(); + setMessages((prev) => [...prev, { role: 'error' as const, id: randomId(), text: msg.message }]); + setHasError(true); + setPhase('done'); + stopTimer(); + break; - case 'step:start': - flushStream(); - setMessages([]); - setParallelStep(null); - setWaitingStatus(null); - inParallelRef.current = false; - setCurrentStep({ - taskName: msg.taskName, - iteration: msg.iteration, - status: 'running', - }); - break; - - case 'step:complete': - flushStream(); - setWaitingStatus(null); - setCurrentStep((prev) => prev ? { ...prev, status: 'complete', cost: msg.cost } : null); - if (msg.cost) addCost(msg.cost); - break; - - case 'step:skip': - setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]); - break; - - case 'step:waiting': - setWaitingStatus({ stepIndex: msg.stepIndex, elapsed: msg.elapsed, iterationLabel: msg.iterationLabel }); - break; - - case 'step:parallel': - flushStream(); - setMessages([]); - setCurrentStep(null); - inParallelRef.current = true; - setParallelStep({ - stepIndex: msg.stepIndex, - taskName: msg.taskName, - concurrency: msg.concurrency, - iterations: msg.iterations.map((label) => ({ label, status: 'pending' })), - }); - break; - - case 'iteration:start': - setParallelStep((prev) => { - if (!prev) return prev; - return { - ...prev, - iterations: prev.iterations.map((it) => - it.label === msg.label ? { ...it, status: 'running' } : it, - ), - }; - }); - break; - - case 'iteration:complete': - setParallelStep((prev) => { - if (!prev) return prev; - return { - ...prev, - iterations: prev.iterations.map((it) => - it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it, - ), - }; - }); - if (msg.cost) addCost(msg.cost); - break; - - case 'iteration:error': - setParallelStep((prev) => { - if (!prev) return prev; - return { - ...prev, - iterations: prev.iterations.map((it) => - it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it, - ), - }; - }); - break; - - case 'assistant:delta': - // Skip messages from parallel sub-agents (shown in iteration grid instead) - if (inParallelRef.current && msg.iterationLabel) break; - setWaitingStatus(null); - streamBufferRef.current += msg.text; - setStreamingText(streamBufferRef.current); - break; - - case 'assistant:text': { - if (inParallelRef.current && msg.iterationLabel) break; - const text = msg.text || streamBufferRef.current; - if (text) { - setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]); - } - streamBufferRef.current = ''; - setStreamingText(''); - break; + case 'stopped': + flushStream(); + setPhase('done'); + stopTimer(); + break; } - - case 'tool:start': - if (inParallelRef.current && msg.iterationLabel) break; - setWaitingStatus(null); - flushStream(); - setMessages((prev) => [ - ...prev, - { - role: 'tool' as const, - id: randomId(), - toolCallId: msg.toolCallId, - toolName: msg.toolName, - toolInput: msg.toolInput, - output: undefined, - isError: false, - }, - ]); - break; - - case 'tool:result': - if (inParallelRef.current && msg.iterationLabel) break; - setMessages((prev) => - prev.map((m) => - m.role === 'tool' && 'toolCallId' in m && m.toolCallId === msg.toolCallId - ? { ...m, output: msg.output, isError: msg.isError } - : m, - ), - ); - break; - - case 'pipeline:complete': - flushStream(); - setTotalCost(msg.totalCost); - setPhase('done'); - stopTimer(); - break; - - case 'error': - flushStream(); - setMessages((prev) => [...prev, { role: 'error' as const, id: randomId(), text: msg.message }]); - setHasError(true); - setPhase('done'); - stopTimer(); - break; - - case 'stopped': - flushStream(); - setPhase('done'); - stopTimer(); - break; - } - }, [flushStream, stopTimer, addCost]); + }, + [flushStream, stopTimer, addCost], + ); // The socket below is opened once and must stay open, so its listener is registered once too — and would // hold the first render's `handleEvent` forever. That closure carries `flushStream`'s captured @@ -308,32 +365,35 @@ export function usePipelineRunner() { }; }, []); - const run = useCallback((taskDirName: string, inputs: Record, cwd?: string, model?: string, startAt?: number) => { - if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + const run = useCallback( + (taskDirName: string, inputs: Record, cwd?: string, model?: string, startAt?: number) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; - setPhase('running'); - setMessages([]); - setStreamingText(''); - setTotalCost(null); - setRunningCost({ inputTokens: 0, outputTokens: 0, totalUSD: 0 }); - setHasError(false); - setSkippedItems([]); - setCurrentStep(null); - setParallelStep(null); - setWaitingStatus(null); - setElapsed(0); - setJobId(null); - jobIdRef.current = null; - streamBufferRef.current = ''; + setPhase('running'); + setMessages([]); + setStreamingText(''); + setTotalCost(null); + setRunningCost({ inputTokens: 0, outputTokens: 0, totalUSD: 0 }); + setHasError(false); + setSkippedItems([]); + setCurrentStep(null); + setParallelStep(null); + setWaitingStatus(null); + setElapsed(0); + setJobId(null); + jobIdRef.current = null; + streamBufferRef.current = ''; - startTimeRef.current = Date.now(); - if (timerRef.current) clearInterval(timerRef.current); - timerRef.current = setInterval(() => { - setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000)); - }, 1000); + startTimeRef.current = Date.now(); + if (timerRef.current) clearInterval(timerRef.current); + timerRef.current = setInterval(() => { + setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000)); + }, 1000); - wsRef.current.send(JSON.stringify({ type: 'run', taskDirName, inputs, cwd, model, startAt })); - }, []); + wsRef.current.send(JSON.stringify({ type: 'run', taskDirName, inputs, cwd, model, startAt })); + }, + [], + ); const stop = useCallback(() => { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !jobIdRef.current) return; @@ -341,7 +401,21 @@ export function usePipelineRunner() { }, []); return { - phase, isConnected, jobId, steps, currentStep, parallelStep, messages, streamingText, - totalCost, runningCost, hasError, skippedItems, elapsed, waitingStatus, run, stop, + phase, + isConnected, + jobId, + steps, + currentStep, + parallelStep, + messages, + streamingText, + totalCost, + runningCost, + hasError, + skippedItems, + elapsed, + waitingStatus, + run, + stop, }; } diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index e3445745..c9ad8a39 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -470,11 +470,6 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa } }; - const handlePlay = (entry: DirEntry) => { - const filePath = entryPath(entry.name); - setViewerParams({ play: filePath }); - }; - const handleExtract = async (entry: DirEntry) => { const filePath = entryPath(entry.name); const toastId = toast.loading('Extracting archive...'); @@ -737,7 +732,6 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa handleCreateDashboardHere, handleReadAloud, handleExtract, - handlePlay, handleGitClone, handleCut, handleCopy, diff --git a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/Providers.tsx b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/Providers.tsx index 3b652093..dc097f1d 100644 --- a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/Providers.tsx +++ b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/Providers.tsx @@ -7,7 +7,8 @@ import { EmbeddableChat } from '../../apps/Chat/EmbeddableChat'; type SetSearchParams = ReturnType[1]; -const onReplaceView = (setSearchParams: SetSearchParams) => +const onReplaceView = + (setSearchParams: SetSearchParams) => (viewPath: string, viewRoot: string, ephemeralPath: string, ephemeralRoot: string) => { setSearchParams((prev) => { const next = new URLSearchParams(prev); @@ -36,7 +37,12 @@ export function ViewerProvider({ children }: { children: ReactNode }) { }; return ( - + {children} ); @@ -59,7 +65,13 @@ export function EphemeralProvider({ children }: { children: ReactNode }) { }; return ( - + {children} ); @@ -73,7 +85,13 @@ export function Ephemeral2Provider({ children }: { children: ReactNode }) { const fileName = ephemeral2Path.split('/').pop() ?? ''; return ( - + {children} ); @@ -85,15 +103,14 @@ export const ChatEphemeralBody = () => { const chatContext = searchParams.get('chatContext') ?? ''; const chatType = searchParams.get('chatType') as 'file' | 'folder' | null; - const cwdPath = chatType === 'file' - ? chatContext.substring(0, chatContext.lastIndexOf('/')) || '/' - : chatContext; + const cwdPath = chatType === 'file' ? chatContext.substring(0, chatContext.lastIndexOf('/')) || '/' : chatContext; const tag = chatType === 'file' ? 'file' : 'folder'; const path = chatContext.replace(/^\//, ''); - const message = chatType === 'file' - ? `[${tag}: ${path}] Let's talk about this file` - : `[${tag}: ${path || '/'}] consider, for this session, this directory as your current working directory`; + const message = + chatType === 'file' + ? `[${tag}: ${path}] Let's talk about this file` + : `[${tag}: ${path || '/'}] consider, for this session, this directory as your current working directory`; const handleMessageComplete = useCallback(() => { bumpFilesRefresh(); diff --git a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/layouts.ts b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/layouts.ts index ed10067e..bf046719 100644 --- a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/layouts.ts +++ b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/layouts.ts @@ -6,12 +6,6 @@ export const singleViewerLayout: LayoutNode = { appType: null, }; -export const singleCliampLayout: LayoutNode = { - type: 'panel', - id: 'files-cliamp', - appType: null, -}; - export const viewerWithEphemeralLayout: LayoutNode = { type: 'group', id: 'files-viewer-group', diff --git a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/useFileViewerPanels.tsx b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/useFileViewerPanels.tsx index d79b640c..883f1e57 100644 --- a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/useFileViewerPanels.tsx +++ b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/useFileViewerPanels.tsx @@ -2,11 +2,24 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useSearchParams } from 'react-router'; import type { EphemeralPanels } from '../../components/Workspace'; import { FileViewerHeader, FileViewerBody } from '../../apps/FileViewer'; -import { singleViewerLayout, singleCliampLayout, viewerWithEphemeralLayout, viewerWithEphemeralSplitLayout, singleChatLayout } from './layouts'; +import { + singleViewerLayout, + viewerWithEphemeralLayout, + viewerWithEphemeralSplitLayout, + singleChatLayout, +} from './layouts'; import { ViewerProvider, EphemeralProvider, Ephemeral2Provider, ChatEphemeralBody } from './Providers'; -import { CliampPanelHeader, CliampPanelBody } from '../../apps/FileBrowser/CliampPanel'; -const EPHEMERAL_KEYS = ['view', 'ephemeral', 'ephemeralRoot', 'ephemeral2', 'ephemeral2Root', 'ephemeral2Auto', 'chatContext', 'chatType', 'play']; +const EPHEMERAL_KEYS = [ + 'view', + 'ephemeral', + 'ephemeralRoot', + 'ephemeral2', + 'ephemeral2Root', + 'ephemeral2Auto', + 'chatContext', + 'chatType', +]; export const useFileViewerPanels = (): EphemeralPanels | null => { const [searchParams, setSearchParams] = useSearchParams(); @@ -29,17 +42,14 @@ export const useFileViewerPanels = (): EphemeralPanels | null => { const ephemeralPath = searchParams.get('ephemeral'); const ephemeral2Path = searchParams.get('ephemeral2'); const chatContext = searchParams.get('chatContext'); - const playPath = searchParams.get('play'); - const layout = playPath - ? singleCliampLayout - : chatContext - ? singleChatLayout - : viewPath && ephemeralPath && ephemeral2Path - ? viewerWithEphemeralSplitLayout - : viewPath && ephemeralPath - ? viewerWithEphemeralLayout - : singleViewerLayout; + const layout = chatContext + ? singleChatLayout + : viewPath && ephemeralPath && ephemeral2Path + ? viewerWithEphemeralSplitLayout + : viewPath && ephemeralPath + ? viewerWithEphemeralLayout + : singleViewerLayout; const onCloseViewer = useCallback(() => setSearchParams({}), [setSearchParams]); @@ -80,23 +90,8 @@ export const useFileViewerPanels = (): EphemeralPanels | null => { [setSearchParams], ); - const onClosePlay = useCallback( - () => - setSearchParams((prev) => { - const next = new URLSearchParams(prev); - next.delete('play'); - return next; - }), - [setSearchParams], - ); - const components = useMemo( () => ({ - 'files-cliamp': { - header: CliampPanelHeader, - component: CliampPanelBody, - onClose: onClosePlay, - }, 'files-viewer': { provider: ViewerProvider, header: FileViewerHeader, @@ -120,11 +115,11 @@ export const useFileViewerPanels = (): EphemeralPanels | null => { onClose: onCloseChat, }, }), - [onCloseViewer, onCloseEphemeral, onCloseEphemeral2, onCloseChat, onClosePlay], + [onCloseViewer, onCloseEphemeral, onCloseEphemeral2, onCloseChat], ); - if (!viewPath && !chatContext && !playPath) return null; - const onClose = playPath ? onClosePlay : onCloseViewer; + if (!viewPath && !chatContext) return null; + const onClose = onCloseViewer; return { layout, components, defaultBaseSize: 40, onClose }; };