cliamp moves into the plugin, and the platform loses its last music file
The owner read the code and asked why `plugins/music/api/router.ts` was three
lines importing `@@/api/music/router` — platform code that knows the string
'music'. He was right, and tracing it found the justification was hollow.
The chain: server.tsx:20 imported the cliamp relay's two exports, which are
used only on commented-out lines; so the relay's functions were never invoked;
so 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, and I documented that as a "seam" last night after
checking the import existed and stopping there.
Everything cliamp now lives in plugins/music/cliamp/:
sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, the test
api/cliamp/relay.ts
apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx
src/servers/sidecar/music/, src/servers/api/cliamp/ and src/servers/api/music/
are gone. server.tsx has no cliamp import, provider name, handler entry or
route. The platform contains no file named for music or cliamp.
Two of the things that moved were live, not inert.
The file browser's `Play` context-menu item, on any audio file or folder, set
?play= and rendered a cliamp terminal pointed at /api/cliamp/ws — a route that
upgraded into a handlers entry that was commented out, so handlers[provider]!
asserted non-null on undefined. Using that menu item crashed the socket
handler. Removed: the action, the layout, the panel wiring and both menu
entries. Verified the routes now 404 rather than crash.
That closed the totality drift as a side effect. server.tsx's route table and
its handlers map agree again for the first time since 2026-08-13, and
registry.test.ts now asserts it rather than pinning the hole.
The proxy is built in the plugin now, and its prefix is DERIVED. It was the
literal '/api/music', which the proxy uses to strip characters off the path —
correct only because mountPrefix returns /music for a first-party publisher.
The same plugin published by anyone else mounts at /api/p/<publisher>/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. Offscale
has the identical hardcode and still needs it.
Still open there: appName is passed as a literal, 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 a factory the installer
calls with the plugin's identity.
Plugin backend coupling is down to 7 imports, all of them "a plugin talks to
its host": data-path, sidecar/connect, sidecar/protocol, officer-url, the
manifest type, officerdb/db and the users.id FK. Nothing music-shaped left.
bunx tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures. Verified
live: manifest 200, favorites 200, stream 206, /api/cliamp/ws 404.
This commit is contained in:
@@ -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`.
|
||||
|
||||
+49
-17
@@ -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/<publisher>/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
|
||||
|
||||
|
||||
+41
-13
@@ -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/<publisher>/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;
|
||||
|
||||
+19
-4
@@ -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;
|
||||
};
|
||||
+1
-1
@@ -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 = () => {
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+8
-21
@@ -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<string, any> = {
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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<boolean> {
|
||||
const { isOwner, grants } = await getEffectiveCapabilities(userId);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
+2
-23
@@ -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 && (
|
||||
<DropdownMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
|
||||
<Music className="mr-2 h-4 w-4" />
|
||||
Play
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showReadAloud && (
|
||||
<DropdownMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
|
||||
<Volume2 className="mr-2 h-4 w-4" />
|
||||
@@ -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 && (
|
||||
<ContextMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
|
||||
<Music className="mr-2 h-4 w-4" />
|
||||
Play
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showReadAloud && (
|
||||
<ContextMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
|
||||
<Volume2 className="mr-2 h-4 w-4" />
|
||||
@@ -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,
|
||||
|
||||
+14
-4
@@ -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
|
||||
})}
|
||||
</div>
|
||||
) : searchResults ? (
|
||||
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">
|
||||
No results found
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">No results found</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
+10
-2
@@ -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 (
|
||||
<>
|
||||
|
||||
+95
-21
@@ -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<string, unknown>; 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<string, unknown>;
|
||||
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<ParallelStep | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
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<Array<{ label: string; reason: string }>>([]);
|
||||
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<WebSocket | null>(null);
|
||||
const streamBufferRef = useRef('');
|
||||
const startTimeRef = useRef<number>(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,7 +146,8 @@ export function usePipelineRunner() {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleEvent = useCallback((msg: ServerMessage) => {
|
||||
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;
|
||||
|
||||
@@ -107,7 +159,12 @@ export function usePipelineRunner() {
|
||||
|
||||
case 'job:state':
|
||||
// Reconnection to a completed/failed job
|
||||
if (msg.status === 'completed' || msg.status === 'failed' || msg.status === 'stopped' || msg.status === 'interrupted') {
|
||||
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);
|
||||
@@ -135,7 +192,7 @@ export function usePipelineRunner() {
|
||||
case 'step:complete':
|
||||
flushStream();
|
||||
setWaitingStatus(null);
|
||||
setCurrentStep((prev) => prev ? { ...prev, status: 'complete', cost: msg.cost } : null);
|
||||
setCurrentStep((prev) => (prev ? { ...prev, status: 'complete', cost: msg.cost } : null));
|
||||
if (msg.cost) addCost(msg.cost);
|
||||
break;
|
||||
|
||||
@@ -165,9 +222,7 @@ export function usePipelineRunner() {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
iterations: prev.iterations.map((it) =>
|
||||
it.label === msg.label ? { ...it, status: 'running' } : it,
|
||||
),
|
||||
iterations: prev.iterations.map((it) => (it.label === msg.label ? { ...it, status: 'running' } : it)),
|
||||
};
|
||||
});
|
||||
break;
|
||||
@@ -266,7 +321,9 @@ export function usePipelineRunner() {
|
||||
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,7 +365,8 @@ export function usePipelineRunner() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const run = useCallback((taskDirName: string, inputs: Record<string, string>, cwd?: string, model?: string, startAt?: number) => {
|
||||
const run = useCallback(
|
||||
(taskDirName: string, inputs: Record<string, string>, cwd?: string, model?: string, startAt?: number) => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
setPhase('running');
|
||||
@@ -333,7 +391,9 @@ export function usePipelineRunner() {
|
||||
}, 1000);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,7 +7,8 @@ import { EmbeddableChat } from '../../apps/Chat/EmbeddableChat';
|
||||
|
||||
type SetSearchParams = ReturnType<typeof useSearchParams>[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 (
|
||||
<FileViewerProvider filePath={viewPath} fileName={fileName} onOpenFile={onOpenFile} onReplaceView={onReplaceView(setSearchParams)}>
|
||||
<FileViewerProvider
|
||||
filePath={viewPath}
|
||||
fileName={fileName}
|
||||
onOpenFile={onOpenFile}
|
||||
onReplaceView={onReplaceView(setSearchParams)}
|
||||
>
|
||||
{children}
|
||||
</FileViewerProvider>
|
||||
);
|
||||
@@ -59,7 +65,13 @@ export function EphemeralProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<FileViewerProvider filePath={ephemeralPath} fileName={fileName} root={ephemeralRoot} onOpenFile={onOpenFile} onReplaceView={onReplaceView(setSearchParams)}>
|
||||
<FileViewerProvider
|
||||
filePath={ephemeralPath}
|
||||
fileName={fileName}
|
||||
root={ephemeralRoot}
|
||||
onOpenFile={onOpenFile}
|
||||
onReplaceView={onReplaceView(setSearchParams)}
|
||||
>
|
||||
{children}
|
||||
</FileViewerProvider>
|
||||
);
|
||||
@@ -73,7 +85,13 @@ export function Ephemeral2Provider({ children }: { children: ReactNode }) {
|
||||
const fileName = ephemeral2Path.split('/').pop() ?? '';
|
||||
|
||||
return (
|
||||
<FileViewerProvider filePath={ephemeral2Path} fileName={fileName} root={ephemeral2Root} autoPlay={ephemeral2Auto} onReplaceView={onReplaceView(setSearchParams)}>
|
||||
<FileViewerProvider
|
||||
filePath={ephemeral2Path}
|
||||
fileName={fileName}
|
||||
root={ephemeral2Root}
|
||||
autoPlay={ephemeral2Auto}
|
||||
onReplaceView={onReplaceView(setSearchParams)}
|
||||
>
|
||||
{children}
|
||||
</FileViewerProvider>
|
||||
);
|
||||
@@ -85,13 +103,12 @@ 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'
|
||||
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`;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,11 +42,8 @@ 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
|
||||
const layout = chatContext
|
||||
? singleChatLayout
|
||||
: viewPath && ephemeralPath && ephemeral2Path
|
||||
? viewerWithEphemeralSplitLayout
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user