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
104 lines
3.8 KiB
TypeScript
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>
|
|
);
|
|
};
|