feat(decodes): offer the panel as a Main-view pane

It joins the left/right dropdowns in Settings -> General alongside the maps,
the cluster and the rest, so decodes can sit beside the entry strip instead
of only behind a tab. Per-profile like the other pane choices.

The panel itself is now built in ONE place and rendered from both: two
copies of that call would be two sets of props to keep in step, and the
click handler in particular is not something to duplicate.

The status refresh follows. Its "is anything showing a spot status?" test
decides whether a logged QSO refreshes the NEW badges now or only marks
them dirty, and a panel that had become a pane would have gone on wearing
stale badges whenever it was shown that way rather than as a tab.
This commit is contained in:
2026-08-18 10:01:30 +02:00
parent 6da30f91c4
commit 0881c72c0f
3 changed files with 43 additions and 28 deletions
+40 -25
View File
@@ -1656,12 +1656,12 @@ export default function App() {
// map ("map1"), the locator street map ("map2"), the cluster grid or the
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
// so it's loaded async on mount and re-read on profile:changed below.
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'netcontrol';
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'netcontrol' | 'decodes';
const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
const loadMainPanes = useCallback(async () => {
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'netcontrol';
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'netcontrol' || v === 'decodes';
const [l, r] = await Promise.all([
GetUIPref('mainPaneLeft').catch(() => ''),
GetUIPref('mainPaneRight').catch(() => ''),
@@ -1786,6 +1786,7 @@ export default function App() {
const spotsDirtyRef = useRef(false);
useEffect(() => {
const vis = mainPaneLeft === 'cluster' || mainPaneRight === 'cluster'
|| mainPaneLeft === 'decodes' || mainPaneRight === 'decodes'
|| activeTab === 'cluster' || activeTab === 'bandmap' || activeTab === 'decodes' || showBandMap;
if (vis && !spotsVisibleRef.current && spotsDirtyRef.current) {
spotsDirtyRef.current = false;
@@ -5446,6 +5447,34 @@ export default function App() {
</button>
);
// The FT decodes panel, built in ONE place: it is offered both as a tab and as
// a Main-view pane, and two copies of this call would be two sets of props to
// keep in step.
const renderDecodesPanel = () => (
<DecodesPanel
decodes={decodes}
txMsgs={txMsgs}
txState={txState}
spotStatus={spotStatus as any}
myCall={station.callsign}
// A click ANSWERS the station: it hands the decode back to WSJT-X/MSHV as
// a Reply, which is the same thing as double-clicking the line in their
// own window.
//
// Deliberately NOT a rig tune, unlike a cluster spot. On FT8 the whole
// band is inside one passband, so moving the dial changes nothing about
// who gets answered — and it would only fight the digital application
// for the VFO. The entry is still filled, so the QSO can be logged here.
onCall={(d) => {
onCallsignInput(d.call, { force: true });
AnswerDecode(
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
d.audio_hz ?? 0, d.mode ?? '', d.msg ?? '', !!d.low_conf,
).catch((e: any) => setError(String(e?.message ?? e)));
}}
/>
);
// Render one Main-view pane. The two sides (mainPaneLeft/Right) each pick from
// the same four choices, configured per-profile in Settings → Main view.
const renderMainPane = (kind: MainPaneKind) => {
@@ -5464,6 +5493,14 @@ export default function App() {
);
case 'map2':
return <LocatorMap toGrid={grid} toLabel={callsign} />;
case 'decodes':
// Same panel as the tab, in a pane. It brings its own filter bar and
// column header, so it needs no frame of its own here.
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
{renderDecodesPanel()}
</div>
);
case 'cluster':
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
@@ -7205,29 +7242,7 @@ export default function App() {
{decodesTabOpen && (
<TabsContent value="decodes" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
<DecodesPanel
decodes={decodes}
txMsgs={txMsgs}
txState={txState}
spotStatus={spotStatus as any}
myCall={station.callsign}
// A click ANSWERS the station: it hands the decode back to
// WSJT-X/MSHV as a Reply, which is the same thing as
// double-clicking the line in their own window.
//
// Deliberately NOT a rig tune, unlike a cluster spot. On FT8
// the whole band is inside one passband, so moving the dial
// changes nothing about who gets answered — and it would only
// fight the digital application for the VFO. The entry is
// still filled, so the QSO can be logged here.
onCall={(d) => {
onCallsignInput(d.call, { force: true });
AnswerDecode(
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
d.audio_hz ?? 0, d.mode ?? '', d.msg ?? '', !!d.low_conf,
).catch((e: any) => setError(String(e?.message ?? e)));
}}
/>
{renderDecodesPanel()}
</TabsContent>
)}