fix(client): copies over plain HTTP, Vietnamese IME Enter, stale replies; react-router 7

- Every Copy goes through copyText: on http://<lan-ip> navigator.clipboard
  is undefined, so the push token (shown once), snapshot links, tool paths and
  hosts copied nothing, silently or with a TypeError.
- Inputs that submit on Enter use isSubmitEnter, so committing an IME
  composition (Vietnamese) no longer saves a half-typed pet name, jumps the
  settings search, or submits a cut-off plugin/MCP/marketplace spec.
- LaneChangesPanel, Workspace features/proof and AiToolConfig drop a reply
  that lands after the next request started: opening file B before file A's
  read returned showed (and let the editor save) A's text under B's header.
- Error-state Retry buttons spin through useSpin; ImageLightbox uses useDialog.
- react-router-dom 6.30 -> 7.18.4 (GHSA-wrjc-x8rr-h8h6, GHSA-337j-9hxr-rhxg);
  screen snapshots gain only Link's data-discover attribute.
- StatusBar test follows v2.8.17's "Settings → AI tools → Profiles" text.
This commit is contained in:
2026-10-22 12:17:18 +07:00
parent fa74cce3fc
commit 50ac0f0daf
27 changed files with 191 additions and 110 deletions
+9 -4
View File
@@ -243,14 +243,19 @@ export function AiToolConfig({ toolId }: { toolId: string }) {
const [lanes, setLanes] = useState<Lane[]>([]);
useEffect(() => {
// Switching tabs quickly must not show the previous tool's numbers.
let cancelled = false;
api.aiTools
.usage(toolId)
.then(setUsage)
.catch(() => setUsage(null));
.then((u) => !cancelled && setUsage(u))
.catch(() => !cancelled && setUsage(null));
api.lanes
.list()
.then((r) => setLanes(r.lanes.filter((l) => l.tool === toolId)))
.catch(() => setLanes([]));
.then((r) => !cancelled && setLanes(r.lanes.filter((l) => l.tool === toolId)))
.catch(() => !cancelled && setLanes([]));
return () => {
cancelled = true;
};
}, [toolId]);
if (!tool) return null;
+5 -4
View File
@@ -159,6 +159,7 @@ import type {
CcSettingsSource,
CcStatusline,
} from "../lib/api";
import { isSubmitEnter } from "../lib/keys";
/** How an inline editor reports unsaved edits to the page. */
type OnDirty = (id: string, dirty: boolean) => void;
@@ -1795,7 +1796,7 @@ function PluginsPanel({
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
if (isSubmitEnter(e)) void submit();
}}
placeholder={t("manage.installPlaceholder")}
aria-label={t("manage.installTitle")}
@@ -2328,7 +2329,7 @@ function McpPanel({
value={commandLine}
onChange={(e) => setCommandLine(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
if (isSubmitEnter(e)) void submit();
}}
placeholder={t("manage.addMcpCommandPlaceholder")}
aria-label={t("mcp.command")}
@@ -2339,7 +2340,7 @@ function McpPanel({
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
if (isSubmitEnter(e)) void submit();
}}
placeholder={t("manage.addMcpUrlPlaceholder")}
aria-label={t("mcp.url")}
@@ -4214,7 +4215,7 @@ function MarketplacesPanel({
value={source}
onChange={(e) => setSource(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void add();
if (isSubmitEnter(e)) void add();
}}
placeholder={t("manage.addMarketplacePlaceholder")}
aria-label={t("manage.addMarketplaceTitle")}
+3 -2
View File
@@ -95,6 +95,7 @@ import type {
CodexScope,
CodexSkill,
} from "../lib/api";
import { isSubmitEnter } from "../lib/keys";
type TabKey = "overview" | "skills" | "agents" | "rules" | "memory" | "config" | "mcp" | "plugins";
@@ -1630,7 +1631,7 @@ function PluginsPanel({
value={spec}
onChange={(e) => setSpec(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submitPlugin();
if (isSubmitEnter(e)) void submitPlugin();
}}
placeholder={t("plugins.installPlaceholder")}
aria-label={t("plugins.installTitle")}
@@ -1710,7 +1711,7 @@ function PluginsPanel({
value={mktUrl}
onChange={(e) => setMktUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submitMarketplace();
if (isSubmitEnter(e)) void submitMarketplace();
}}
placeholder={t("plugins.addMarketplacePlaceholder")}
aria-label={t("plugins.addMarketplaceTitle")}
+2 -2
View File
@@ -1092,7 +1092,7 @@ function AnalyticsTab() {
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">{loadError}</span>
<button onClick={load} className="btn-ghost text-xs">
<button onClick={refresh} disabled={spinning} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
@@ -3133,7 +3133,7 @@ export function Dashboard() {
<span className="flex-1 min-w-0">
{t("failedLoad")}: {error}
</span>
<button onClick={load} className="btn-ghost text-xs">
<button onClick={refresh} disabled={spinning} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
+1 -1
View File
@@ -561,7 +561,7 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
<span className="flex-1 min-w-0">
{t("failedList")}: {error}
</span>
<button onClick={load} className="btn-ghost text-xs">
<button onClick={refresh} disabled={spinning} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
+2 -1
View File
@@ -120,6 +120,7 @@ import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog";
import { useSpin } from "../hooks/useSpin";
import { exportPrefs, importPrefs } from "../lib/prefsBackup";
import { isSubmitEnter } from "../lib/keys";
/** The fewest milliseconds between two activity-driven `/settings/info` reads:
* it COUNTs whole tables, and hooks fire many times a second while sessions run. */
@@ -1335,7 +1336,7 @@ function ClearDataDialog({
value={typed}
onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && ok && !busy) onConfirm();
if (isSubmitEnter(e) && ok && !busy) onConfirm();
}}
autoComplete="off"
className="input mt-1 w-full font-mono text-sm"
+2 -1
View File
@@ -85,6 +85,7 @@ import { AiToolIcon } from "../components/AiToolIcon";
import { toast } from "../lib/toast";
import { useAuth, usePermissions } from "../components/AuthGate";
import { PageTabs } from "../components/PageTabs";
import { isSubmitEnter } from "../lib/keys";
const GROUPS = ["general", "agents", "data"] as const;
type Group = (typeof GROUPS)[number];
@@ -515,7 +516,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]), nav);
if (isSubmitEnter(e) && results[0]) navigate(hrefOf(results[0]), nav);
if (e.key === "Escape") setQuery("");
}}
placeholder={t("search.placeholder")}
+20 -4
View File
@@ -1105,10 +1105,18 @@ export function Workspace() {
setFeatures([]);
return;
}
let cancelled = false;
api.lanes.features
.list(currentLane.id)
.then((data) => setFeatures(data.features))
.catch(() => setFeatures([]));
.then((data) => {
if (!cancelled) setFeatures(data.features);
})
.catch(() => {
if (!cancelled) setFeatures([]);
});
return () => {
cancelled = true;
};
}, [currentLane?.id]);
// Fetch the archived snapshot when the picker selects one — read-only, never
@@ -1138,10 +1146,18 @@ export function Workspace() {
setProofFeatures([]);
return;
}
let cancelled = false;
api.lanes.proof
.list(currentLane.id)
.then((data) => setProofFeatures(data.features))
.catch(() => setProofFeatures([]));
.then((data) => {
if (!cancelled) setProofFeatures(data.features);
})
.catch(() => {
if (!cancelled) setProofFeatures([]);
});
return () => {
cancelled = true;
};
}, [currentLane?.id, viewedFeatureSlug]);
// Resolve the active feature slug: either the user-selected one, or the lane's active_feature_id
@@ -798,6 +798,7 @@ exports[`screen snapshots > Claude Config 1`] = `
>
<a
class="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
data-discover="true"
href="/settings/agents#tool-profiles"
>
<svg
@@ -2806,6 +2807,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</div>
<a
class="btn-ghost shrink-0 text-xs"
data-discover="true"
href="/settings/pricing#pricing-claude"
>
Open Pricing
@@ -3222,6 +3224,7 @@ exports[`screen snapshots > Dashboard 1`] = `
>
<a
class="card p-5 block hover:bg-surface-4/40 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
data-discover="true"
href="/sessions"
>
<div
@@ -3475,6 +3478,7 @@ exports[`screen snapshots > Dashboard 1`] = `
</div>
<a
class="card p-5 block hover:bg-surface-4/40 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
data-discover="true"
href="/sessions?view=events"
>
<div
@@ -5408,6 +5412,7 @@ exports[`screen snapshots > Dashboard 1`] = `
<a
class="text-accent hover:underline"
data-discover="true"
href="/settings/notifications"
>
Add one in Settings → Notifications
@@ -11059,6 +11064,7 @@ exports[`screen snapshots > Workflows 1`] = `
</div>
<a
class="text-fg-muted hover:text-violet-700 dark:hover:text-violet-400 transition-colors flex-shrink-0"
data-discover="true"
href="/sessions/sess-1"
title="Open parent session"
>