Files
proxy-manager/web/ChatPanel.tsx
T
pastilhasandClaude Opus 5 66383aa9a4 the lists re-read when the agent changes something
an agent deleted two proxy hosts and both stayed on screen. the person had the
answer in the transcript and a stale list six inches above it — which is the
one thing a chat beside a list has to get right, and the reason it is beside
the list at all.

so the chat panel bumps the refresh channel when a turn ran tools, and every
section re-reads. gated on hadToolCalls: a turn that only talked changed
nothing, and re-reading after every sentence would put six requests behind
every reply.

the channel is renamed with it. it was proxy-manager:connection-changed, named
after its only publisher; it has two now and the second is the bigger one, so
it says what it MEANS — something at NPM changed, read it again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 19:03:35 +00:00

78 lines
3.6 KiB
TypeScript

import { useCallback } from 'react';
import { ChatPanelWrapper, ChatHeader } from 'officerdev';
import { useNpmChanged } from './channel';
/**
* A chat, sitting under the two lists.
*
* ── Why this exists instead of naming `officerdev/chat` in the layout ──
*
* It was tried, and it produced two Hosts panels. `PluginScreen` renders the workspace with
* `appTypes={{ allowed, fallback: allowed[0] }}` where `allowed` is **this plugin's own panel keys**, so a
* layout naming anything else silently falls back to the first one. That is deliberate and its comment
* says why: a persisted layout must not be able to render another plugin's panel inside this screen.
*
* The registry being shared is a separate thing from the layout being allowed to name what is in it. Both
* are true; only the second one decides this.
*
* So the panel is ours and the chat inside it is the platform's. `EmbeddableChat` is exported from
* `officerdev` for precisely this — the whole composer, transcript, queue and model picker, with no second
* implementation to fall behind the first.
*
* ── What these chats are told, and where it lives ──
*
* `chat/CLAUDE.md`, in this plugin. The platform copies it into the chats directory whenever a chat starts
* there, and Claude Code reads it from its own cwd at session start.
*
* There WAS a `promptPrefix` here and it is gone. A prefix rides every message: paid per turn forever,
* invisible as a design artefact because it lives in a TypeScript constant, and visible in the one place
* it should not be — the owner sent three words and watched a paragraph of preamble go out attached to
* them. The file costs nothing per turn and can be opened by the person it is about.
*/
const CHAT_CONTEXT = { context: 'plugin', contextId: 'proxy-manager' } as const;
/**
* `ChatPanelWrapper`, not `EmbeddableChat` — one level up, and everything below it comes free.
*
* The wrapper already carries the session persistence (`usePanelSession`, so this panel stays on its own
* conversation across a reload), the agent identity bar, the spawned-by bar, and the resume-the-newest
* behaviour. Mounting the composer directly meant reimplementing all of that or going without it, which is
* what this panel was doing.
*/
export const ChatPanel = ({ panelId }: { panelId: string }) => {
const [, npmChanged] = useNpmChanged();
/**
* A turn that ran tools may have changed NPM, so every list on this screen re-reads.
*
* This is the whole reason the panel is worth having beside the lists rather than in `/chat`. An agent
* deleted two proxy hosts on 2026-08-25 and both stayed on screen afterwards, because nothing told the
* panels — the person had the answer in the transcript and a stale list six inches above it.
*
* Gated on `hadToolCalls`, which is the only discrimination that matters: a turn that only talked
* changed nothing, and re-reading after every sentence would put six requests behind every reply.
*/
const onTurnComplete = useCallback(
(hadToolCalls: boolean) => {
if (hadToolCalls) npmChanged();
},
[npmChanged],
);
return (
<div className="flex h-full min-h-0 flex-col">
<ChatPanelWrapper panelId={panelId} chatContext={CHAT_CONTEXT} onTurnComplete={onTurnComplete} />
</div>
);
};
/**
* The panel's header: the conversation picker, scoped to this plugin's directory.
*
* A registry `header` is mounted with `{ panelId }` only, so the context is bound here rather than passed
* — which is the whole reason this wrapper exists.
*/
export const ChatPanelHeader = ({ panelId }: { panelId: string }) => (
<ChatHeader panelId={panelId} chatContext={CHAT_CONTEXT} />
);