Files
proxy-manager/web/NavPanel.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

104 lines
3.8 KiB
TypeScript

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 { useNpmChanged } from './channel';
/**
* The left column: which part of Nginx Proxy Manager the detail panel is showing.
*
* ── Selection is in the URL ──
*
* `?section=…`, per `docs/navigation-audit.md`: what is on screen is addressable, so it survives a reload,
* can be linked, and the detail panel reads it directly rather than being handed it through a channel. The
* two panels never talk to each other.
*
* ── The counts ──
*
* Each section fetches its own, which is one request per section on mount. That is affordable here — they
* are small JSON lists from a local NPM — and it buys the thing a bare nav cannot give you: whether a
* section is worth opening. "Streams 0" answers a question without a click.
*
* A count that fails is simply absent. A nav is not the place to report that NPM is unreachable; the
* detail panel says so properly, once, with the reason.
*/
const rowClass = (active: boolean) =>
`flex w-full items-start gap-2.5 border-b px-3 py-2.5 text-left transition-colors ${
active ? 'bg-muted' : 'hover:bg-muted/50'
}`;
const useSectionCounts = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [counts, setCounts] = useState<Partial<Record<SectionKey, number>>>({});
const [connectionSignal] = useNpmChanged();
const load = useCallback(async () => {
const results = await Promise.all(
SECTIONS.filter((s) => s.path).map(async (section) => {
try {
const rows = await clientRef.current.get<unknown[]>(section.path!);
return [section.key, Array.isArray(rows) ? rows.length : undefined] as const;
} catch {
// Absent, not zero. Zero is a fact about NPM; a failure is a fact about the request.
return [section.key, undefined] as const;
}
}),
);
setCounts(Object.fromEntries(results.filter(([, n]) => n !== undefined)));
}, []);
useEffect(() => {
void load();
}, [load, connectionSignal]);
return counts;
};
export const NavPanel = () => {
const [params, setParams] = useSearchParams();
const raw = params.get('section');
const active: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION;
const counts = useSectionCounts();
return (
<nav className="flex size-full flex-col overflow-auto">
{SECTIONS.map((section) => {
const Icon = section.icon;
const count = counts[section.key];
return (
<button
key={section.key}
type="button"
onClick={() =>
setParams(
(p) => {
p.set('section', section.key);
// The selected host belongs to the section that was open. Carrying it across would
// point the detail panel at a row from a different list.
p.delete('host');
return p;
},
{ replace: true },
)
}
className={rowClass(active === section.key)}
>
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
<span className="truncate text-sm">{section.label}</span>
{count !== undefined && <span className="text-xs text-muted-foreground">{count}</span>}
</span>
<span className="block truncate text-xs text-muted-foreground">{section.hint}</span>
</span>
</button>
);
})}
</nav>
);
};