From 66383aa9a41922573ef113d07b77767475d56c53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 25 Aug 2026 19:03:35 +0000 Subject: [PATCH] the lists re-read when the agent changes something MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- web/CertificatesPanel.tsx | 4 ++-- web/ChatPanel.tsx | 33 ++++++++++++++++++++++++++++----- web/ConnectionPanel.tsx | 10 +++++----- web/DetailPanel.tsx | 4 ++-- web/HostsPanel.tsx | 4 ++-- web/ListSection.tsx | 4 ++-- web/NavPanel.tsx | 4 ++-- web/channel.ts | 23 ++++++++++++++--------- 8 files changed, 57 insertions(+), 29 deletions(-) diff --git a/web/CertificatesPanel.tsx b/web/CertificatesPanel.tsx index 83afa41..908e65d 100644 --- a/web/CertificatesPanel.tsx +++ b/web/CertificatesPanel.tsx @@ -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(null); const [error, setError] = useState(null); diff --git a/web/ChatPanel.tsx b/web/ChatPanel.tsx index f247878..8c12f2a 100644 --- a/web/ChatPanel.tsx +++ b/web/ChatPanel.tsx @@ -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 ( +
+ +
+ ); +}; /** * The panel's header: the conversation picker, scoped to this plugin's directory. diff --git a/web/ConnectionPanel.tsx b/web/ConnectionPanel.tsx index a1816c0..8f494a8 100644 --- a/web/ConnectionPanel.tsx +++ b/web/ConnectionPanel.tsx @@ -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(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 { diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index 5674fa2..a2c8957 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -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(null); - const [connectionSignal] = useConnectionChanged(); + const [connectionSignal] = useNpmChanged(); const check = useCallback(async () => { try { diff --git a/web/HostsPanel.tsx b/web/HostsPanel.tsx index 639f557..01bfc83 100644 --- a/web/HostsPanel.tsx +++ b/web/HostsPanel.tsx @@ -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 { diff --git a/web/ListSection.tsx b/web/ListSection.tsx index 7494423..6d31bad 100644 --- a/web/ListSection.tsx +++ b/web/ListSection.tsx @@ -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(null); const [error, setError] = useState(null); - const [connectionSignal] = useConnectionChanged(); + const [connectionSignal] = useNpmChanged(); const load = useCallback(async () => { try { diff --git a/web/NavPanel.tsx b/web/NavPanel.tsx index 9534333..4e50a10 100644 --- a/web/NavPanel.tsx +++ b/web/NavPanel.tsx @@ -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>>({}); - const [connectionSignal] = useConnectionChanged(); + const [connectionSignal] = useNpmChanged(); const load = useCallback(async () => { const results = await Promise.all( diff --git a/web/channel.ts b/web/channel.ts index 8ce852c..22d2164 100644 --- a/web/channel.ts +++ b/web/channel.ts @@ -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('proxy-manager:connection-changed', 0); +const useNpmChangedChannel = defineChannel('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.