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
+10 -8
View File
@@ -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ĩ <vinnt@smartgift.vn>
*/
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<HTMLDivElement>(null);
useDialog(true, panel, onClose);
return createPortal(
<div
ref={panel}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-label={alt}
data-testid="image-lightbox"
onClick={onClose}
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8 outline-none"
>
<img
src={src}
+4 -7
View File
@@ -82,6 +82,7 @@ import { api, type ImportResult, type ImportBackupResult } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import type { WSMessage, ImportProgressMessage } from "../lib/types";
import { PathPickButton } from "./PathPickButton";
import { copyText } from "../lib/clipboard";
type Mode = "rescan" | "path" | "upload" | "backup";
@@ -247,13 +248,9 @@ export function ImportHistory() {
const copyArchiveCmd = async () => {
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 = (() => {
+2 -1
View File
@@ -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")}
</MenuItem>
<MenuItem onSelect={pick(openEdit)}>{t("common:edit", "Edit")}</MenuItem>
<MenuItem onSelect={pick((s) => void navigator.clipboard.writeText(s.host))}>
<MenuItem onSelect={pick((s) => void copyText(s.host))}>
{t("rowMenu.copyHost")}
</MenuItem>
<MenuSeparator />
+2 -1
View File
@@ -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") {
+2 -1
View File
@@ -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 {
@@ -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");
@@ -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 (
+2 -1
View File
@@ -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}
@@ -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(() => {
+1 -1
View File
@@ -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]);
@@ -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 (
<div role="alert" className="card flex items-center gap-3 p-4 text-sm text-status-danger">
<span className="flex-1">{t("account.loadFailed", { message: loadError })}</span>
<button type="button" className="btn-ghost text-xs" onClick={load}>
<RefreshCw className="h-3.5 w-3.5" /> {t("common:retry")}
<button type="button" className="btn-ghost text-xs" onClick={retry} disabled={retrying}>
<RefreshCw className={`h-3.5 w-3.5${retrying ? " animate-spin" : ""}`} />{" "}
{t("common:retry")}
</button>
</div>
);
@@ -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() {
<span className="truncate">
{t("agentInstructions.loadFailed", { message: loadError })}
</span>
<button type="button" className="btn-ghost shrink-0 text-xs" onClick={load}>
<button
type="button"
className="btn-ghost shrink-0 text-xs"
onClick={retry}
disabled={retrying}
>
{t("common:retry")}
</button>
</p>
@@ -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 && <MenuSeparator />}
<MenuItem
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
>
<MenuItem onSelect={pick((tool) => void copyText(tool.path ?? ""))}>
{t("rowMenu.copyPath")}
</MenuItem>
</>
@@ -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) {
@@ -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() {
<MenuItem onSelect={pick((s) => navigate(`/snapshot/${s.token}`))}>
{t("snapshots.open")}
</MenuItem>
<MenuItem
onSelect={pick((s) => void navigator.clipboard?.writeText(snapshotUrl(s.token)))}
>
<MenuItem onSelect={pick((s) => void copyText(snapshotUrl(s.token)))}>
{t("snapshots.copyLink")}
</MenuItem>
<MenuSeparator />
@@ -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}