Skip to content

Commit f0acc36

Browse files
committed
fix(gui): address CodeRabbit follow-ups on dense workspaces
1 parent 27e3762 commit f0acc36

14 files changed

Lines changed: 152 additions & 82 deletions

gui/src/App.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,8 @@ export default function App() {
300300
{page === "startup" && <Startup apiBase={API_BASE} />}
301301
{page === "providers" && <Providers apiBase={API_BASE} />}
302302
{page === "models" && <Models apiBase={API_BASE} />}
303-
{page === "combos" && <Combos apiBase={API_BASE} />}
304-
{page === "subagents" && <Subagents apiBase={API_BASE} />}
303+
{page === "combos" && <Combos key={API_BASE} apiBase={API_BASE} />}
304+
{page === "subagents" && <Subagents key={API_BASE} apiBase={API_BASE} />}
305305
{page === "logs" && <Logs apiBase={API_BASE} />}
306306
{page === "usage" && <Usage apiBase={API_BASE} />}
307307
{page === "storage" && <Storage apiBase={API_BASE} />}

gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* ApiKeysWorkspace — rail + main for the API tab. Overview hosts the existing
33
* endpoint/auth/generate/models/usage panels; selecting a key opens detail.
44
*/
5-
import { useState } from "react";
5+
import { useEffect, useState } from "react";
66
import { IconChevron, IconTrash } from "../../icons";
77
import { useT } from "../../i18n/shared";
88
import type { ExternalModelRow } from "../../api-access-models";
@@ -79,22 +79,39 @@ export default function ApiKeysWorkspace({
7979
const t = useT();
8080
const [selectedId, setSelectedId] = useState<string | null>(null);
8181
const [confirmDelete, setConfirmDelete] = useState(false);
82+
/** Armed after a short delay so a double-click / retained focus cannot confirm immediately. */
83+
const [confirmArmed, setConfirmArmed] = useState(false);
8284

8385
const selected = selectedId ? (keys.find(k => k.id === selectedId) ?? null) : null;
8486

87+
const clearDeleteConfirm = () => {
88+
setConfirmDelete(false);
89+
setConfirmArmed(false);
90+
};
91+
8592
const showOverview = () => {
8693
setSelectedId(null);
87-
setConfirmDelete(false);
94+
clearDeleteConfirm();
8895
};
8996

90-
const handleDeleteClick = () => {
91-
if (!selected) return;
97+
useEffect(() => {
9298
if (!confirmDelete) {
93-
setConfirmDelete(true);
99+
setConfirmArmed(false);
94100
return;
95101
}
102+
const timer = window.setTimeout(() => setConfirmArmed(true), 300);
103+
return () => window.clearTimeout(timer);
104+
}, [confirmDelete]);
105+
106+
const handleRequestDelete = () => {
107+
if (!selected) return;
108+
setConfirmDelete(true);
109+
};
110+
111+
const handleConfirmDelete = () => {
112+
if (!selected || !confirmArmed) return;
96113
onDelete(selected.id);
97-
setConfirmDelete(false);
114+
clearDeleteConfirm();
98115
setSelectedId(null);
99116
};
100117

@@ -128,7 +145,7 @@ export default function ApiKeysWorkspace({
128145
key={k.id}
129146
type="button"
130147
className={`apikeys-workspace-rail-row${selectedId === k.id ? " apikeys-workspace-rail-row--selected" : ""}`}
131-
onClick={() => { setSelectedId(k.id); setConfirmDelete(false); }}
148+
onClick={() => { setSelectedId(k.id); clearDeleteConfirm(); }}
132149
aria-current={selectedId === k.id ? "page" : undefined}
133150
>
134151
<span className="apikeys-workspace-rail-name">{k.name}</span>
@@ -156,18 +173,25 @@ export default function ApiKeysWorkspace({
156173
<span className="awi-detail-actions">
157174
{confirmDelete ? (
158175
<>
159-
<button type="button" className="btn btn-danger btn-sm" onClick={handleDeleteClick}>
176+
<button
177+
key="confirm-delete"
178+
type="button"
179+
className="btn btn-danger btn-sm awi-confirm-delete"
180+
onClick={handleConfirmDelete}
181+
disabled={!confirmArmed}
182+
>
160183
<IconTrash /> {t("api.confirm")}
161184
</button>
162-
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setConfirmDelete(false)}>
185+
<button type="button" className="btn btn-ghost btn-sm" onClick={clearDeleteConfirm}>
163186
{t("common.cancel")}
164187
</button>
165188
</>
166189
) : (
167190
<button
191+
key="request-delete"
168192
type="button"
169193
className="btn btn-danger btn-sm"
170-
onClick={handleDeleteClick}
194+
onClick={handleRequestDelete}
171195
aria-label={t("api.deleteAria")}
172196
>
173197
<IconTrash /> {t("api.workspace.deleteKey")}

gui/src/components/subagents-workspace/SubagentsWorkspace.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export interface SubagentsWorkspaceProps {
2828
onSave: () => void;
2929
}
3030

31-
const FEATURED_MAX = 5;
31+
export const FEATURED_MAX = 5;
3232

3333
export default function SubagentsWorkspace({
3434
available,
@@ -156,7 +156,7 @@ export default function SubagentsWorkspace({
156156
</button>
157157
<div className="swi-detail-head">
158158
<span className="swi-detail-icon"><IconBot style={{ width: 24, height: 24 }} /></span>
159-
<h2 className="swi-detail-title">{selected}</h2>
159+
<h2 className="swi-detail-title">{modelLabel(selected)}</h2>
160160
</div>
161161

162162
<div className="swi-detail-section">
@@ -184,6 +184,9 @@ export default function SubagentsWorkspace({
184184
<IconPlus /> {full ? t("sub.workspace.featuredFull") : t("sub.workspace.addToFeatured", { m: selected })}
185185
</button>
186186
)}
187+
<button type="button" className="btn btn-primary" onClick={onSave} disabled={busy}>
188+
{t("common.save")}
189+
</button>
187190
</div>
188191
</div>
189192
</div>

gui/src/pages/Claude.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,22 +62,22 @@ export default function Claude({ apiBase }: { apiBase: string }) {
6262
</button>
6363
</div>
6464

65-
{/* Code stays mounted; Desktop mounts only when selected so its fetch is not forever-stale. */}
65+
{/* Both stay mounted so draft/UI state survives tab switches; Desktop pauses polls while hidden. */}
6666
<div
6767
id="claude-code-panel"
6868
role="tabpanel"
6969
aria-labelledby="claude-code-tab"
7070
hidden={tab !== "code"}
7171
>
72-
<ClaudeCode apiBase={apiBase} />
72+
<ClaudeCode key={apiBase} apiBase={apiBase} />
7373
</div>
7474
<div
7575
id="claude-desktop-panel"
7676
role="tabpanel"
7777
aria-labelledby="claude-desktop-tab"
7878
hidden={tab !== "desktop"}
7979
>
80-
{tab === "desktop" ? <ClaudeDesktop apiBase={apiBase} /> : null}
80+
<ClaudeDesktop key={apiBase} apiBase={apiBase} active={tab === "desktop"} />
8181
</div>
8282
</section>
8383
);

gui/src/pages/ClaudeCode.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,22 @@ export { AutoConnectSetting, SmallFastModelSetting } from "./claude-code-setting
2020

2121
type CachedClaudeCode = { state: ClaudeCodeState; rows: MapRow[] };
2222

23+
function seedClaudeCode(cacheKey: string): CachedClaudeCode | null {
24+
return readSessionListCache<CachedClaudeCode>(cacheKey);
25+
}
26+
2327
export default function ClaudeCode({ apiBase }: { apiBase: string }) {
2428
const t = useT();
2529
const { locale } = useI18n();
2630
const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang ?? "en";
2731
const cacheKey = `ocx.claude-code.v1:${apiBase}`;
28-
const cached = readSessionListCache<CachedClaudeCode>(cacheKey);
29-
const [state, setState] = useState<ClaudeCodeState | null>(() => cached?.state ?? null);
30-
const [rows, setRows] = useState<MapRow[]>(() => cached?.rows ?? []);
32+
const [state, setState] = useState<ClaudeCodeState | null>(() => seedClaudeCode(cacheKey)?.state ?? null);
33+
const [rows, setRows] = useState<MapRow[]>(() => seedClaudeCode(cacheKey)?.rows ?? []);
3134
const [status, setStatus] = useState("");
3235
const [ok, setOk] = useState(false);
33-
const [loading, setLoading] = useState(() => !cached?.state);
36+
const [loading, setLoading] = useState(() => !seedClaudeCode(cacheKey)?.state);
3437
const [selectedSection, setSelectedSection] = useState("settings");
35-
const hasCacheRef = useRef(Boolean(cached?.state));
38+
const hasCacheRef = useRef(Boolean(seedClaudeCode(cacheKey)?.state));
3639

3740
const load = useCallback(async () => {
3841
if (!hasCacheRef.current) setLoading(true);

gui/src/pages/ClaudeDesktop.tsx

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -121,22 +121,36 @@ function formatContextWindow(value: number | undefined, t: TFn): string | null {
121121
: t("claudeDesktop.contextK", { n: Math.round(value / 1_000) });
122122
}
123123

124-
export default function ClaudeDesktop({ apiBase }: { apiBase: string }) {
124+
type CachedDesktop = { data: DesktopResponse; profile: DesktopProfile };
125+
126+
function readDesktopCache(cacheKey: string): CachedDesktop | null {
127+
return readSessionListCache<CachedDesktop>(cacheKey);
128+
}
129+
130+
function seedDesktop(cacheKey: string) {
131+
const cached = readDesktopCache(cacheKey);
132+
return {
133+
data: cached?.data ?? null,
134+
profile: cached?.profile ?? null,
135+
savedProfile: cached?.profile ? cloneProfile(cached.profile) : null,
136+
destinations: cached?.data
137+
? Object.fromEntries(
138+
cached.data.models.map(model => [model.route, cached.profile.assignments[model.route]?.family ?? "opus"]),
139+
)
140+
: {} as Record<string, Family>,
141+
hasCache: Boolean(cached?.data),
142+
};
143+
}
144+
145+
export default function ClaudeDesktop({ apiBase, active = true }: { apiBase: string; active?: boolean }) {
125146
const t = useT();
126147
const cacheKey = `ocx.claude-desktop.v1:${apiBase}`;
127-
const cached = readSessionListCache<{ data: DesktopResponse; profile: DesktopProfile }>(cacheKey);
148+
const [data, setData] = useState<DesktopResponse | null>(() => seedDesktop(cacheKey).data);
149+
const [profile, setProfile] = useState<DesktopProfile | null>(() => seedDesktop(cacheKey).profile);
150+
const [savedProfile, setSavedProfile] = useState<DesktopProfile | null>(() => seedDesktop(cacheKey).savedProfile);
151+
const [destinations, setDestinations] = useState<Record<string, Family>>(() => seedDesktop(cacheKey).destinations);
128152
const [status, setStatus] = useState<DesktopStatus | null>(null);
129-
const [data, setData] = useState<DesktopResponse | null>(() => cached?.data ?? null);
130-
const [profile, setProfile] = useState<DesktopProfile | null>(() => cached?.profile ?? null);
131-
const [savedProfile, setSavedProfile] = useState<DesktopProfile | null>(() => (
132-
cached?.profile ? cloneProfile(cached.profile) : null
133-
));
134-
const [destinations, setDestinations] = useState<Record<string, Family>>(() => (
135-
cached?.data
136-
? Object.fromEntries(cached.data.models.map(model => [model.route, cached.profile.assignments[model.route]?.family ?? "opus"]))
137-
: {}
138-
));
139-
const [loading, setLoading] = useState(() => !cached?.data);
153+
const [loading, setLoading] = useState(() => !seedDesktop(cacheKey).hasCache);
140154
const [loadError, setLoadError] = useState("");
141155
const [message, setMessage] = useState<{ tone: "ok" | "err"; text: string } | null>(null);
142156
const [announcement, setAnnouncement] = useState("");
@@ -155,7 +169,7 @@ export default function ClaudeDesktop({ apiBase }: { apiBase: string }) {
155169
// is not, and restoring five open rows on reload would rebuild the wall this removes.
156170
const [openRows, setOpenRows] = useState<Record<string, boolean>>({});
157171
const importRef = useRef<HTMLInputElement>(null);
158-
const hasCacheRef = useRef(Boolean(cached?.data));
172+
const hasCacheRef = useRef(seedDesktop(cacheKey).hasCache);
159173

160174
const load = useCallback(async () => {
161175
if (!hasCacheRef.current) setLoading(true);
@@ -220,16 +234,17 @@ export default function ClaudeDesktop({ apiBase }: { apiBase: string }) {
220234
return result;
221235
}, [modelsByFamily, profile]);
222236

223-
// Poll Desktop status every 5s for applied-state + health.
237+
// Poll Desktop status every 5s for applied-state + health (paused while tab is hidden).
224238
useEffect(() => {
239+
if (!active) return;
225240
let cancelled = false;
226241
let inFlight = false;
227-
let active: ReturnType<typeof createBoundedFetch> | null = null;
242+
let activeFetch: ReturnType<typeof createBoundedFetch> | null = null;
228243
const poll = () => {
229244
if (inFlight) return;
230245
inFlight = true;
231246
const bounded = createBoundedFetch(10_000);
232-
active = bounded;
247+
activeFetch = bounded;
233248
void fetch(`${apiBase}/api/claude-desktop/status`, { signal: bounded.signal })
234249
.then((response) => readJsonIfOk<DesktopStatus>(response))
235250
.then((data) => {
@@ -239,7 +254,7 @@ export default function ClaudeDesktop({ apiBase }: { apiBase: string }) {
239254
.catch(() => { /* offline / older proxy / aborted */ })
240255
.finally(() => {
241256
bounded.clear();
242-
if (active === bounded) active = null;
257+
if (activeFetch === bounded) activeFetch = null;
243258
inFlight = false;
244259
});
245260
};
@@ -248,10 +263,10 @@ export default function ClaudeDesktop({ apiBase }: { apiBase: string }) {
248263
return () => {
249264
cancelled = true;
250265
clearInterval(timer);
251-
active?.controller.abort();
252-
active?.clear();
266+
activeFetch?.controller.abort();
267+
activeFetch?.clear();
253268
};
254-
}, [apiBase]);
269+
}, [apiBase, active]);
255270

256271
const moveModel = (route: string, family: Family) => {
257272
if (!profile || profile.assignments[route]?.family === family) return;

gui/src/pages/Combos.tsx

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,21 +46,24 @@ function responseSucceeded(data: unknown): boolean {
4646
&& (data as { success?: unknown }).success === true;
4747
}
4848

49+
function seedCombos(cacheKey: string): CachedCombosPage | null {
50+
return readSessionListCache<CachedCombosPage>(cacheKey);
51+
}
52+
4953
export default function Combos({ apiBase }: { apiBase: string }) {
5054
const t = useT();
5155
const cacheKey = `ocx.combos.workspace.v1:${apiBase}`;
52-
const cached = readSessionListCache<CachedCombosPage>(cacheKey);
53-
const hasCacheRef = useRef(Boolean(cached));
54-
const [combos, setCombos] = useState<ComboItem[]>(() => cached?.combos ?? []);
55-
const [providers, setProviders] = useState<ProviderOption[]>(() => cached?.providers ?? []);
56-
const [models, setModels] = useState<ModelOption[]>(() => cached?.models ?? []);
56+
const [combos, setCombos] = useState<ComboItem[]>(() => seedCombos(cacheKey)?.combos ?? []);
57+
const [providers, setProviders] = useState<ProviderOption[]>(() => seedCombos(cacheKey)?.providers ?? []);
58+
const [models, setModels] = useState<ModelOption[]>(() => seedCombos(cacheKey)?.models ?? []);
5759
const [cataloguedComboIds, setCataloguedComboIds] = useState<ReadonlySet<string>>(
58-
() => new Set(cached?.cataloguedComboIds ?? []),
60+
() => new Set(seedCombos(cacheKey)?.cataloguedComboIds ?? []),
5961
);
60-
const [loading, setLoading] = useState(() => !cached);
62+
const [loading, setLoading] = useState(() => !seedCombos(cacheKey));
6163
const [status, setStatus] = useState("");
6264
const [statusOk, setStatusOk] = useState(false);
6365
const [adding, setAdding] = useState(false);
66+
const hasCacheRef = useRef(Boolean(seedCombos(cacheKey)));
6467

6568
const notify = (msg: string, ok: boolean) => {
6669
setStatus(msg);
@@ -77,7 +80,7 @@ export default function Combos({ apiBase }: { apiBase: string }) {
7780
return () => window.clearTimeout(timer);
7881
}, [status, statusOk]);
7982

80-
const fetchAll = useCallback(async () => {
83+
const fetchAll = useCallback(async (opts?: { notifyOnFail?: boolean }) => {
8184
// Soft refresh: keep last-good workspace painted while revalidating.
8285
if (!hasCacheRef.current) setLoading(true);
8386
try {
@@ -166,7 +169,7 @@ export default function Combos({ apiBase }: { apiBase: string }) {
166169
cataloguedComboIds: [...catalogued],
167170
} satisfies CachedCombosPage);
168171
} catch {
169-
if (!hasCacheRef.current) notify(t("cws.loadFailed"), false);
172+
if (!hasCacheRef.current || opts?.notifyOnFail) notify(t("cws.loadFailed"), false);
170173
} finally {
171174
setLoading(false);
172175
}
@@ -262,7 +265,7 @@ export default function Combos({ apiBase }: { apiBase: string }) {
262265
models={models}
263266
cataloguedComboIds={cataloguedComboIds}
264267
loading={loading}
265-
onRefresh={() => { void fetchAll(); }}
268+
onRefresh={() => { void fetchAll({ notifyOnFail: true }); }}
266269
onSave={saveCombo}
267270
onRemove={removeCombo}
268271
onAdd={() => setAdding(true)}

0 commit comments

Comments
 (0)