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
This commit is contained in:
2026-08-25 19:03:35 +00:00
co-authored by Claude Opus 5
parent 417e091dfe
commit 66383aa9a4
8 changed files with 57 additions and 29 deletions
+2 -2
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { RefreshCw, ShieldAlert, ShieldCheck } from "lucide-react";
import { useClient } from "hooks/useClient";
import { readableError } from "./errors";
import { useConnectionChanged } from "./channel";
import { useNpmChanged } from "./channel";
/**
* Certificates, ordered by how soon they expire.
@@ -30,7 +30,7 @@ export const CertificatesPanel = () => {
const clientRef = useRef(client);
clientRef.current = client;
const [connectionSignal] = useConnectionChanged();
const [connectionSignal] = useNpmChanged();
const [certificates, setCertificates] = useState<Certificate[] | null>(null);
const [error, setError] = useState<string | null>(null);
+25 -2
View File
@@ -1,4 +1,6 @@
import { useCallback } from 'react';
import { ChatPanelWrapper, ChatHeader } from 'officerdev';
import { useNpmChanged } from './channel';
/**
* A chat, sitting under the two lists.
@@ -37,11 +39,32 @@ const CHAT_CONTEXT = { context: 'plugin', contextId: 'proxy-manager' } as const;
* 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 }) => (
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} />
<ChatPanelWrapper panelId={panelId} chatContext={CHAT_CONTEXT} onTurnComplete={onTurnComplete} />
</div>
);
};
/**
* The panel's header: the conversation picker, scoped to this plugin's directory.
+5 -5
View File
@@ -4,7 +4,7 @@ import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { readableError } from './errors';
import { useConnectionChanged } from './channel';
import { useNpmChanged } from './channel';
import { useSearchParams } from 'react-router';
/**
@@ -117,7 +117,7 @@ export const ConnectionPanel = () => {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<'test' | 'save' | 'delete' | null>(null);
const [confirming, setConfirming] = useState(false);
const [, connectionChanged] = useConnectionChanged();
const [, npmChanged] = useNpmChanged();
const [, setParams] = useSearchParams();
/**
@@ -135,8 +135,8 @@ export const ConnectionPanel = () => {
},
{ replace: true },
);
connectionChanged();
}, [setParams, connectionChanged]);
npmChanged();
}, [setParams, npmChanged]);
const load = useCallback(async () => {
try {
@@ -208,7 +208,7 @@ export const ConnectionPanel = () => {
setSecret('');
await load();
// Disconnecting is a change too — the panels must stop showing hosts that are no longer reachable.
connectionChanged();
npmChanged();
} catch (err) {
setError(errorText(err));
} finally {
+2 -2
View File
@@ -6,7 +6,7 @@ import { HostsPanel } from './HostsPanel';
import { CertificatesPanel } from './CertificatesPanel';
import { ConnectionPanel } from './ConnectionPanel';
import { ListSection, REDIRECTIONS, STREAMS, DEAD_HOSTS, ACCESS_LISTS } from './ListSection';
import { useConnectionChanged } from './channel';
import { useNpmChanged } from './channel';
/**
* The right column: whichever section the nav has selected.
@@ -35,7 +35,7 @@ export const DetailPanel = () => {
clientRef.current = client;
const [configured, setConfigured] = useState<boolean | null>(null);
const [connectionSignal] = useConnectionChanged();
const [connectionSignal] = useNpmChanged();
const check = useCallback(async () => {
try {
+2 -2
View File
@@ -3,7 +3,7 @@ import { AlertTriangle, Globe, Lock, LockOpen, Power, RefreshCw, ShieldCheck, Us
import { useClient } from "hooks/useClient";
import { useSearchParams } from "react-router";
import { readableError } from "./errors";
import { useConnectionChanged } from "./channel";
import { useNpmChanged } from "./channel";
/**
* The proxy hosts, which is the thing anybody opens this plugin to see.
@@ -65,7 +65,7 @@ export const HostsPanel = () => {
const selected = params.get("host");
// Re-read when the connection is saved or removed. Named as an effect dependency below — that IS the
// subscription; there is nothing to unsubscribe from.
const [connectionSignal] = useConnectionChanged();
const [connectionSignal] = useNpmChanged();
const load = useCallback(async () => {
try {
+2 -2
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { readableError } from './errors';
import { useConnectionChanged } from './channel';
import { useNpmChanged } from './channel';
/**
* The four sections that are a list and nothing more — redirections, streams, 404 hosts, access lists.
@@ -44,7 +44,7 @@ export const ListSection = ({ path, emptyText, primary, secondary, isEnabled }:
const [rows, setRows] = useState<Row[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [connectionSignal] = useConnectionChanged();
const [connectionSignal] = useNpmChanged();
const load = useCallback(async () => {
try {
+2 -2
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useClient } from 'hooks/useClient';
import { SECTIONS, DEFAULT_SECTION, isSectionKey, type SectionKey } from './sections';
import { useConnectionChanged } from './channel';
import { useNpmChanged } from './channel';
/**
* The left column: which part of Nginx Proxy Manager the detail panel is showing.
@@ -34,7 +34,7 @@ const useSectionCounts = () => {
clientRef.current = client;
const [counts, setCounts] = useState<Partial<Record<SectionKey, number>>>({});
const [connectionSignal] = useConnectionChanged();
const [connectionSignal] = useNpmChanged();
const load = useCallback(async () => {
const results = await Promise.all(
+14 -9
View File
@@ -2,7 +2,12 @@ import { useCallback, useRef } from 'react';
import { defineChannel } from 'hooks/usePanelChannel';
/**
* "The connection changed — anyone reading NPM should read it again."
* "Something at NPM changed — anyone reading it should read it again."
*
* Two publishers, and naming it after the second one would have been wrong the other way round. The
* connection being saved or removed is one. **A chat turn that ran tools is the other**, and it is the
* bigger one: an agent that deletes a proxy host leaves every list on the screen describing a world that
* no longer exists, and nothing else would ever tell them.
*
* ── Why a channel and not the URL ──
*
@@ -12,11 +17,11 @@ import { defineChannel } from 'hooks/usePanelChannel';
*
* ── Why it is needed at all ──
*
* The panels are siblings in a workspace layout. `ConnectionPanel` cannot call into `CertificatesPanel`
* and should not try — they are mounted independently and neither owns the other. Before this, saving a
* connection left both panels showing whatever they had failed to load a minute earlier, and the only fix
* a person had was a manual refresh. Reported on 2026-08-25 the first time a connection was saved through
* the form.
* The panels are siblings in a workspace layout. None of them can call into another and none should try —
* they are mounted independently and no one owns the rest. Before this, saving a connection left every
* panel showing whatever it had failed to load a minute earlier, and the only fix a person had was a
* manual refresh. Reported on 2026-08-25 the first time a connection was saved through the form; the chat
* publisher was added the same day, after an agent deleted two hosts that stayed on screen.
*
* ── The nonce is a plain counter, and that is not an accident ──
*
@@ -26,12 +31,12 @@ import { defineChannel } from 'hooks/usePanelChannel';
* re-reads. `Date.now()` has the same flaw in miniature, two bumps in one millisecond being one number.
* A module counter never reads React state, so it cannot be wrong however often it is called.
*/
const useConnectionChangedChannel = defineChannel<number>('proxy-manager:connection-changed', 0);
const useNpmChangedChannel = defineChannel<number>('proxy-manager:npm-changed', 0);
let nonce = 0;
export const useConnectionChanged = () => {
const [signal, setSignal] = useConnectionChangedChannel();
export const useNpmChanged = () => {
const [signal, setSignal] = useNpmChangedChannel();
// The setter is a fresh closure every render and this `bump` goes into dependency lists, so it is read
// through a ref — stable identity without freezing the first render's setter.