diff --git a/client/package-lock.json b/client/package-lock.json index adc50ce7..687d2fa0 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -29,7 +29,7 @@ "react-dom": "^18.3.1", "react-i18next": "^17.0.4", "react-markdown": "^10.1.0", - "react-router-dom": "^6.28.2", + "react-router-dom": "^7.18.4", "remark-gfm": "^4.0.1" }, "devDependencies": { @@ -1251,15 +1251,6 @@ "node": ">= 8" } }, - "node_modules/@remix-run/router": { - "version": "1.23.4", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", - "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2740,6 +2731,18 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/crelt": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", @@ -5472,35 +5475,39 @@ } }, "node_modules/react-router": { - "version": "6.30.6", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", - "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", - "license": "MIT", + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", "dependencies": { - "@remix-run/router": "1.23.4" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, "node_modules/react-router-dom": { - "version": "6.30.6", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", - "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", - "license": "MIT", + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", "dependencies": { - "@remix-run/router": "1.23.4", - "react-router": "6.30.6" + "react-router": "7.18.4" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=18", + "react-dom": ">=18" } }, "node_modules/read-cache": { @@ -5766,6 +5773,11 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", diff --git a/client/package.json b/client/package.json index 2b812afd..f0e459aa 100644 --- a/client/package.json +++ b/client/package.json @@ -32,7 +32,7 @@ "react-dom": "^18.3.1", "react-i18next": "^17.0.4", "react-markdown": "^10.1.0", - "react-router-dom": "^6.28.2", + "react-router-dom": "^7.18.4", "remark-gfm": "^4.0.1" }, "devDependencies": { diff --git a/client/src/components/ImageLightbox.tsx b/client/src/components/ImageLightbox.tsx index 14fb92d0..823c4952 100644 --- a/client/src/components/ImageLightbox.tsx +++ b/client/src/components/ImageLightbox.tsx @@ -4,12 +4,14 @@ * Escape closes it. Rendered into `document.body`, so no pane, its stacking * layer or a header drawn over a pane can cover it — opened from inside a * pane's Chat view, a `fixed` overlay would otherwise stay in that pane's - * layer, under anything stacked above it. + * layer, under anything stacked above it. `useDialog` takes the focus while it + * is open and gives it back, and its Escape closes only this, the top dialog. * @author Nguyễn Ngọc Trí Vĩ */ -import { useEffect } from "react"; +import { useRef } from "react"; import { createPortal } from "react-dom"; +import { useDialog } from "../hooks/useDialog"; export function ImageLightbox({ src, @@ -20,18 +22,18 @@ export function ImageLightbox({ alt: string; onClose: () => void; }) { - useEffect(() => { - const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [onClose]); + const panel = useRef(null); + useDialog(true, panel, onClose); return createPortal(
{ if (!guide) return; - try { - await navigator.clipboard.writeText(guide.archive_command); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - /* clipboard unavailable */ - } + if (!(await copyText(guide.archive_command, true))) return; + setCopied(true); + setTimeout(() => setCopied(false), 1500); }; const progressText = (() => { diff --git a/client/src/components/RemoteSources.tsx b/client/src/components/RemoteSources.tsx index 4857525f..7376fb46 100644 --- a/client/src/components/RemoteSources.tsx +++ b/client/src/components/RemoteSources.tsx @@ -88,6 +88,7 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { useDataScope } from "../lib/dataScope"; import type { ScopeMode } from "../lib/dataScope"; import { PathPickButton } from "./PathPickButton"; +import { copyText } from "../lib/clipboard"; /** Data-scope choices in display order (the radio group's arrow-key order). */ const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"]; @@ -863,7 +864,7 @@ export function RemoteSources() { : t("remoteSources.enable", "Enable")} {t("common:edit", "Edit")} - void navigator.clipboard.writeText(s.host))}> + void copyText(s.host))}> {t("rowMenu.copyHost")} diff --git a/client/src/components/Sagi/SagiInfo.tsx b/client/src/components/Sagi/SagiInfo.tsx index 68dff124..2f62e8eb 100644 --- a/client/src/components/Sagi/SagiInfo.tsx +++ b/client/src/components/Sagi/SagiInfo.tsx @@ -24,6 +24,7 @@ import { import { useTranslation } from "react-i18next"; import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain"; import { PET_NAME_MAX } from "./prefs"; +import { isSubmitEnter } from "../../lib/keys"; /** How many sessions the "Needs you" list shows before pointing at Sessions. */ const ATTENTION_SHOWN = 4; @@ -70,7 +71,7 @@ export function PetNameButton({ onChange={(e) => setRenaming(e.target.value)} onBlur={save} onKeyDown={(e) => { - if (e.key === "Enter") { + if (isSubmitEnter(e)) { e.preventDefault(); save(); } else if (e.key === "Escape") { diff --git a/client/src/components/SnapshotButton.tsx b/client/src/components/SnapshotButton.tsx index 3890fb3d..3da8363b 100644 --- a/client/src/components/SnapshotButton.tsx +++ b/client/src/components/SnapshotButton.tsx @@ -14,6 +14,7 @@ import { api } from "../lib/api"; import { ConfirmModal } from "./ConfirmModal"; import { Checkbox } from "./Checkbox"; import { CopyButton } from "./event-views/primitives"; +import { copyText } from "../lib/clipboard"; export function snapshotUrl(token: string): string { return `${window.location.origin}/snapshot/${token}`; @@ -33,7 +34,7 @@ export function SnapshotButton({ sessionId }: { sessionId: string }) { try { const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact }); setToken(snapshot.token); - navigator.clipboard?.writeText(snapshotUrl(snapshot.token)).catch(() => undefined); + void copyText(snapshotUrl(snapshot.token)); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { diff --git a/client/src/components/__tests__/StatusBar.test.tsx b/client/src/components/__tests__/StatusBar.test.tsx index 9ceb5cdc..a764beee 100644 --- a/client/src/components/__tests__/StatusBar.test.tsx +++ b/client/src/components/__tests__/StatusBar.test.tsx @@ -238,7 +238,7 @@ describe("StatusBar", () => { expect(work).toHaveTextContent("5h95%Resets in 41m"); expect(work).toHaveTextContent("Week32%"); expect(personal).toHaveTextContent("home@example.com · Sign-in expired"); - expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles"); + expect(personal).toHaveTextContent("Check quota in Settings → AI tools → Profiles"); expect(personal).not.toHaveTextContent("in use"); expect(codex).toHaveTextContent("dev@example.com"); expect(codex).toHaveTextContent("Sign-in expired"); diff --git a/client/src/components/event-views/primitives.tsx b/client/src/components/event-views/primitives.tsx index 46d95ade..353e1990 100644 --- a/client/src/components/event-views/primitives.tsx +++ b/client/src/components/event-views/primitives.tsx @@ -103,6 +103,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Copy, Check } from "lucide-react"; +import { copyText } from "../../lib/clipboard"; // ───────────────────────── Copy button ───────────────────────── @@ -111,13 +112,9 @@ export function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); async function copy() { - try { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - // Clipboard API can fail in insecure contexts - silently ignore. - } + if (!(await copyText(text, true))) return; + setCopied(true); + setTimeout(() => setCopied(false), 1500); } return ( diff --git a/client/src/components/lanes/FileExplorer.tsx b/client/src/components/lanes/FileExplorer.tsx index 9898f7f7..d2a7e685 100644 --- a/client/src/components/lanes/FileExplorer.tsx +++ b/client/src/components/lanes/FileExplorer.tsx @@ -92,6 +92,7 @@ import { formatBytes } from "../../lib/format"; import { copyText } from "../../lib/clipboard"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { CloneHereDialog } from "./CloneHereDialog"; +import { isSubmitEnter } from "../../lib/keys"; /** One upload batch at most (the server caps one file the same). */ export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024; @@ -1987,7 +1988,7 @@ function InlineName({ onDoubleClick={(e) => e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation(); - if (e.key === "Enter") onDone(); + if (isSubmitEnter(e)) onDone(); if (e.key === "Escape") onCancel(); }} onBlur={onDone} diff --git a/client/src/components/lanes/LaneChangesPanel.tsx b/client/src/components/lanes/LaneChangesPanel.tsx index d0e92aad..2a6a6b9b 100644 --- a/client/src/components/lanes/LaneChangesPanel.tsx +++ b/client/src/components/lanes/LaneChangesPanel.tsx @@ -1063,6 +1063,9 @@ export function LaneChangesPanel({ const [historyLoading, setHistoryLoading] = useState(false); useEffect(() => { if (mode !== "history") return; + // A reply for the previous ref/file/page lands after the next request + // started: drop it, or it overwrites the list the user is looking at. + let live = true; setHistoryLoading(true); void api.lanes .gitLog(target, { @@ -1070,16 +1073,23 @@ export function LaneChangesPanel({ ...(historyRef ? { ref: historyRef } : {}), ...(historyFile ? { file: historyFile } : {}), }) - .then(forLane(laneId, (r) => setCommits(r.commits))) - .catch((err) => setError(err instanceof Error ? err.message : String(err))) - .finally(() => laneNow.current === laneId && setHistoryLoading(false)); + .then(forLane(laneId, (r) => live && setCommits(r.commits))) + .catch((err) => live && setError(err instanceof Error ? err.message : String(err))) + .finally(() => live && laneNow.current === laneId && setHistoryLoading(false)); + return () => { + live = false; + }; }, [mode, laneId, reread, historyLimit, historyRef, historyFile]); useEffect(() => { if (mode !== "history") return; + let live = true; void api.lanes .gitBranches(target) - .then(forLane(laneId, (r) => setHistoryBranches(r.branches))) - .catch(() => setHistoryBranches([])); + .then(forLane(laneId, (r) => live && setHistoryBranches(r.branches))) + .catch(() => live && setHistoryBranches([])); + return () => { + live = false; + }; }, [mode, laneId, reread]); // Re-run when a lane switch empties the cache too: the full-width sidebar @@ -1173,24 +1183,33 @@ export function LaneChangesPanel({ // own fetch — and only while the viewer is actually showing it. useEffect(() => { if (mode !== "file" || !(whole || editable) || !openFile) return; + // Opening file B before file A's read came back must not show (and let the + // editor save) A's text under B's header. + let live = true; void api.lanes .gitFile(target, openFile, range === "commit" && sha ? sha : "work") .then( - forLane(laneId, (r) => - setText( - r.available - ? { - path: r.path, - lines: r.lines, - truncated: r.truncated, - binary: r.binary, - sha: r.sha, - } - : null - ) + forLane( + laneId, + (r) => + live && + setText( + r.available + ? { + path: r.path, + lines: r.lines, + truncated: r.truncated, + binary: r.binary, + sha: r.sha, + } + : null + ) ) ) - .catch((err) => setError(err instanceof Error ? err.message : String(err))); + .catch((err) => live && setError(err instanceof Error ? err.message : String(err))); + return () => { + live = false; + }; }, [mode, whole, editable, openFile, laneId, range, sha]); // The editor's original side, read beside the working file. A file that is // not there (new since) diffs against nothing: every line is added. @@ -1198,10 +1217,17 @@ export function LaneChangesPanel({ useEffect(() => { if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return; const rev = range === "branch" && mergeBase ? mergeBase : "HEAD"; + let live = true; void api.lanes.gitFile(target, openFile, rev).then( - forLane(laneId, (r) => setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")), - forLane(laneId, () => setBaseText("")) + forLane( + laneId, + (r) => live && setBaseText(r.available && !r.binary ? r.lines.join("\n") : "") + ), + forLane(laneId, () => live && setBaseText("")) ); + return () => { + live = false; + }; }, [mode, whole, editable, openFile, laneId, range, mergeBase]); useEffect(() => { diff --git a/client/src/components/run/RunSetup.tsx b/client/src/components/run/RunSetup.tsx index a4a37f9d..3a28ac5a 100644 --- a/client/src/components/run/RunSetup.tsx +++ b/client/src/components/run/RunSetup.tsx @@ -863,7 +863,7 @@ export function CwdAutocomplete({ } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); - } else if (e.key === "Enter") { + } else if (isSubmitEnter(e)) { if (open && flat[active]) { e.preventDefault(); choose(flat[active]); diff --git a/client/src/components/settings/AccountSettings.tsx b/client/src/components/settings/AccountSettings.tsx index 2b4e3ad3..5940b376 100644 --- a/client/src/components/settings/AccountSettings.tsx +++ b/client/src/components/settings/AccountSettings.tsx @@ -31,6 +31,7 @@ import { formatDateTime } from "../../lib/format"; import { toast } from "../../lib/toast"; import { confirmDialog } from "../../lib/confirm"; import { isSubmitEnter } from "../../lib/keys"; +import { useSpin } from "../../hooks/useSpin"; import { useAuth } from "../AuthGate"; import { ROLE_BADGE } from "../users/UserEditDialog"; import { UserAvatar } from "../users/UserAvatar"; @@ -68,19 +69,23 @@ export function AccountSettings() { ); const load = useCallback(() => { setLoadError(null); - api.me + return api.me .account() .then(apply) .catch((e: Error) => setLoadError(e.message)); }, [apply]); - useEffect(load, [load]); + useEffect(() => { + void load(); + }, [load]); + const [retrying, retry] = useSpin(load); if (loadError) { return (
{t("account.loadFailed", { message: loadError })} -
); diff --git a/client/src/components/settings/AgentInstructionsSettings.tsx b/client/src/components/settings/AgentInstructionsSettings.tsx index a8818225..7b669adb 100644 --- a/client/src/components/settings/AgentInstructionsSettings.tsx +++ b/client/src/components/settings/AgentInstructionsSettings.tsx @@ -17,6 +17,7 @@ import { FileUp } from "lucide-react"; import { useTranslation } from "react-i18next"; import { api, type AgentInstructionsView } from "../../lib/api"; import { useAiTools } from "../../hooks/useAiTools"; +import { useSpin } from "../../hooks/useSpin"; import { AiToolIcon } from "../AiToolIcon"; import { toast } from "../../lib/toast"; import { SettingsSection } from "./SettingsRow"; @@ -41,13 +42,16 @@ export function AgentInstructionsSettings() { const load = () => { setLoadError(null); - api.me + return api.me .instructions() .then(apply) .catch((err) => setLoadError(err instanceof Error ? err.message : String(err))); }; - useEffect(load, []); + useEffect(() => { + void load(); + }, []); + const [retrying, retry] = useSpin(load); async function save() { setSaving(true); @@ -123,7 +127,12 @@ export function AgentInstructionsSettings() { {t("agentInstructions.loadFailed", { message: loadError })} -

diff --git a/client/src/components/settings/AiToolsSettings.tsx b/client/src/components/settings/AiToolsSettings.tsx index ff0f2378..d975ba56 100644 --- a/client/src/components/settings/AiToolsSettings.tsx +++ b/client/src/components/settings/AiToolsSettings.tsx @@ -29,6 +29,7 @@ import { AiToolIcon } from "../AiToolIcon"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { api, type AiTool, type AiToolJob } from "../../lib/api"; import { usePermissions } from "../AuthGate"; +import { copyText } from "../../lib/clipboard"; type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" }; @@ -374,9 +375,7 @@ export function AiToolsSettings({ {ctx.menu.item.path && ( <> {isAdmin && } - void navigator.clipboard.writeText(tool.path ?? ""))} - > + void copyText(tool.path ?? ""))}> {t("rowMenu.copyPath")} diff --git a/client/src/components/settings/PushIngestSettings.tsx b/client/src/components/settings/PushIngestSettings.tsx index 3a594f47..c046cffd 100644 --- a/client/src/components/settings/PushIngestSettings.tsx +++ b/client/src/components/settings/PushIngestSettings.tsx @@ -16,8 +16,7 @@ import { api } from "../../lib/api"; import type { PushTokenStatus } from "../../lib/api"; import { ConfirmModal } from "../ConfirmModal"; import { SettingsSection } from "./SettingsRow"; - -const copyText = (text: string) => navigator.clipboard?.writeText(text).catch(() => undefined); +import { copyText } from "../../lib/clipboard"; /** The forwarder for the pushing machine; `origin` is how it reaches this dashboard. */ function forwarder(origin: string) { diff --git a/client/src/components/settings/SnapshotSettings.tsx b/client/src/components/settings/SnapshotSettings.tsx index cc77f8b1..01c1f600 100644 --- a/client/src/components/settings/SnapshotSettings.tsx +++ b/client/src/components/settings/SnapshotSettings.tsx @@ -19,6 +19,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context import { CopyButton } from "../event-views/primitives"; import { snapshotUrl } from "../SnapshotButton"; import { SettingsSection } from "./SettingsRow"; +import { copyText } from "../../lib/clipboard"; export function SnapshotSettings() { const { t } = useTranslation("settings"); @@ -116,9 +117,7 @@ export function SnapshotSettings() { navigate(`/snapshot/${s.token}`))}> {t("snapshots.open")} - void navigator.clipboard?.writeText(snapshotUrl(s.token)))} - > + void copyText(snapshotUrl(s.token)))}> {t("snapshots.copyLink")} diff --git a/client/src/components/workflows/LanePipelinePanel.tsx b/client/src/components/workflows/LanePipelinePanel.tsx index 8af24e85..15b2c918 100644 --- a/client/src/components/workflows/LanePipelinePanel.tsx +++ b/client/src/components/workflows/LanePipelinePanel.tsx @@ -17,6 +17,7 @@ import { api } from "../../lib/api"; import { useLiveRefresh } from "../../hooks/useLiveRefresh"; import { laneChangeMatcher } from "../../lib/laneChange"; import type { PipelineStats } from "../../lib/types"; +import { copyText } from "../../lib/clipboard"; /** Percentage of `n` out of `total`, 0 when nothing was recorded. */ function pct(n: number, total: number): number { @@ -160,7 +161,7 @@ export function LanePipelinePanel({ type="button" data-testid={`pipeline-stat-command-${n.id}`} title={t("lanes.copyCommand", { command: n.command })} - onClick={() => void navigator.clipboard?.writeText(n.command || "")} + onClick={() => void copyText(n.command || "")} className="block max-w-full truncate font-mono text-[10px] text-fg-muted hover:text-accent" > {n.command} diff --git a/client/src/pages/AiToolConfig.tsx b/client/src/pages/AiToolConfig.tsx index 2aeab1ca..cbb618b0 100644 --- a/client/src/pages/AiToolConfig.tsx +++ b/client/src/pages/AiToolConfig.tsx @@ -243,14 +243,19 @@ export function AiToolConfig({ toolId }: { toolId: string }) { const [lanes, setLanes] = useState([]); 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; diff --git a/client/src/pages/CcConfig.tsx b/client/src/pages/CcConfig.tsx index 7d683b24..7e821af8 100644 --- a/client/src/pages/CcConfig.tsx +++ b/client/src/pages/CcConfig.tsx @@ -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")} diff --git a/client/src/pages/CodexConfig.tsx b/client/src/pages/CodexConfig.tsx index bcaa6ef4..3b0612df 100644 --- a/client/src/pages/CodexConfig.tsx +++ b/client/src/pages/CodexConfig.tsx @@ -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")} diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 9a558fec..ff9e29db 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -1092,7 +1092,7 @@ function AnalyticsTab() { > {loadError} -
@@ -3133,7 +3133,7 @@ export function Dashboard() { {t("failedLoad")}: {error} - diff --git a/client/src/pages/Sessions.tsx b/client/src/pages/Sessions.tsx index 24cb886d..4c415189 100644 --- a/client/src/pages/Sessions.tsx +++ b/client/src/pages/Sessions.tsx @@ -561,7 +561,7 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) { {t("failedList")}: {error} - diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index 1a6cf034..c4b12019 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -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" diff --git a/client/src/pages/SettingsHub.tsx b/client/src/pages/SettingsHub.tsx index fe309974..c5f01802 100644 --- a/client/src/pages/SettingsHub.tsx +++ b/client/src/pages/SettingsHub.tsx @@ -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")} diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx index 7304c74a..4a6c4722 100644 --- a/client/src/pages/Workspace.tsx +++ b/client/src/pages/Workspace.tsx @@ -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 diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index 653e4fe5..e6cdddd2 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -798,6 +798,7 @@ exports[`screen snapshots > Claude Config 1`] = ` > Claude Config 1`] = ` Open Pricing @@ -3222,6 +3224,7 @@ exports[`screen snapshots > Dashboard 1`] = ` >
Dashboard 1`] = `
Dashboard 1`] = ` Add one in Settings → Notifications @@ -11059,6 +11064,7 @@ exports[`screen snapshots > Workflows 1`] = `