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:
2026-08-15 13:52:53 +00:00
parent 8bfcd40bd2
commit a9bf51407e
25 changed files with 504 additions and 398 deletions
@@ -1,170 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Volume2, VolumeX } from 'lucide-react';
type AudioStreamPlayerProps = {
wsUrl: string;
onError?: (message: string) => void;
};
const SAMPLE_RATE = 44100;
const CHANNELS = 2;
const buildWsUrl = (wsPath: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
const separator = wsPath.includes('?') ? '&' : '?';
return `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
};
const WORKLET_CODE = `
class PCMProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.buffer = new Float32Array(0);
this.port.onmessage = (e) => {
const incoming = e.data;
const merged = new Float32Array(this.buffer.length + incoming.length);
merged.set(this.buffer);
merged.set(incoming, this.buffer.length);
this.buffer = merged;
const max = ${SAMPLE_RATE * CHANNELS * 2};
if (this.buffer.length > max) {
this.buffer = this.buffer.slice(this.buffer.length - max);
}
};
}
process(inputs, outputs) {
const output = outputs[0];
if (!output || output.length === 0) return true;
const channels = output.length;
const frameSize = output[0].length;
const samplesNeeded = frameSize * channels;
if (this.buffer.length >= samplesNeeded) {
for (let i = 0; i < frameSize; i++) {
for (let ch = 0; ch < channels; ch++) {
output[ch][i] = this.buffer[i * channels + ch];
}
}
this.buffer = this.buffer.slice(samplesNeeded);
} else {
for (let ch = 0; ch < channels; ch++) {
output[ch].fill(0);
}
}
return true;
}
}
registerProcessor('pcm-processor', PCMProcessor);
`;
const workletBlobUrl = URL.createObjectURL(new Blob([WORKLET_CODE], { type: 'application/javascript' }));
export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) => {
const [muted, setMuted] = useState(false);
const [started, setStarted] = useState(false);
const ctxRef = useRef<AudioContext | null>(null);
const nodeRef = useRef<AudioWorkletNode | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const gainRef = useRef<GainNode | null>(null);
// The effect below runs once per `wsUrl` and registers listeners that outlive every render after it, so a
// named `onError` dependency would either tear the stream down on each render or freeze the first render's
// callback. A ref is the third option: one stream, current callback.
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
useEffect(() => {
let disposed = false;
let audioCtx: AudioContext | null = null;
const init = async () => {
try {
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
ctxRef.current = audioCtx;
await audioCtx.audioWorklet.addModule(workletBlobUrl);
if (disposed) { audioCtx.close(); return; }
const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor', {
outputChannelCount: [CHANNELS],
});
nodeRef.current = workletNode;
const gainNode = audioCtx.createGain();
gainRef.current = gainNode;
workletNode.connect(gainNode);
gainNode.connect(audioCtx.destination);
const ws = new WebSocket(buildWsUrl(wsUrl));
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.addEventListener('open', () => {
if (!disposed) setStarted(true);
});
ws.addEventListener('message', (ev) => {
if (disposed || !(ev.data instanceof ArrayBuffer)) return;
// Resume context if suspended (autoplay policy — will unlock on user gesture)
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume();
}
const int16 = new Int16Array(ev.data);
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) {
float32[i] = int16[i]! / 32768;
}
workletNode.port.postMessage(float32);
});
ws.addEventListener('error', () => {
if (!disposed) onErrorRef.current?.('Audio stream connection failed');
});
ws.addEventListener('close', () => {
if (!disposed) setStarted(false);
});
} catch (err) {
if (!disposed) {
onErrorRef.current?.(err instanceof Error ? err.message : 'Audio playback failed');
}
}
};
init();
return () => {
disposed = true;
try { wsRef.current?.close(); } catch { /* ignore */ }
wsRef.current = null;
try { nodeRef.current?.disconnect(); } catch { /* ignore */ }
nodeRef.current = null;
try { audioCtx?.close(); } catch { /* ignore */ }
ctxRef.current = null;
gainRef.current = null;
};
}, [wsUrl]);
useEffect(() => {
if (gainRef.current) {
gainRef.current.gain.value = muted ? 0 : 1;
}
}, [muted]);
return (
<button
onClick={() => setMuted((m) => !m)}
className="p-1.5 rounded hover:bg-duck-dark/10 transition-colors cursor-pointer"
title={muted ? 'Unmute' : 'Mute'}
>
{muted ? (
<VolumeX className={`h-4 w-4 ${started ? 'text-red-500' : 'text-duck-dark/40'}`} />
) : (
<Volume2 className={`h-4 w-4 ${started ? 'text-duck-teal' : 'text-duck-dark/40'}`} />
)}
</button>
);
};
@@ -1,36 +0,0 @@
import { useCallback } from 'react';
import { useSearchParams } from 'react-router';
import { Music } from 'lucide-react';
import { TerminalView } from '../Terminal/Terminal';
import { AudioStreamPlayer } from './AudioStreamPlayer';
export const CliampPanelHeader = () => {
const [searchParams] = useSearchParams();
const playPath = searchParams.get('play') ?? '';
const fileName = playPath.split('/').pop() ?? 'cliamp';
return (
<>
<Music className="h-4 w-4 shrink-0 opacity-60" />
<span className="text-xs font-medium truncate flex-1">{fileName}</span>
<AudioStreamPlayer wsUrl="/api/cliamp/audio/ws" />
</>
);
};
export const CliampPanelBody = () => {
const [searchParams, setSearchParams] = useSearchParams();
const playPath = searchParams.get('play') ?? '';
const wsPath = `/api/cliamp/ws?files=${encodeURIComponent(playPath)}`;
const handleExit = useCallback(() => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('play');
return next;
});
}, [setSearchParams]);
return <TerminalView className="h-full w-full" wsPath={wsPath} onExit={handleExit} autoFocus />;
};
@@ -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)}
@@ -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,
@@ -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>
) : (
@@ -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 (
<>
@@ -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,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<string, string>, cwd?: string, model?: string, startAt?: number) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
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');
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,
};
}
@@ -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,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();
@@ -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,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 };
};