refactor(web): unify form controls, one notification rule, dedupe i18n copy (#54)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Opus 4.8 authored and GitHub committed 2026-07-24 18:43:18 +08:00
1 parent c7465625a4
commit 3f868ac385
50 files changed
+2391 -1190

No files matched your search

+10 -6
View File
@@ -26,6 +26,7 @@ import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { useDocumentTitle } from "../../lib/use-document-title";
import { formatDateTime, formatMoney, humanizeDuration, humanizeTokens } from "../../lib/format";
import { latestConversation } from "../../lib/session-grouping";
import { approvalKey } from "../../lib/omni/stream-model";
import { useTheme } from "../../state/theme";
import { useProject } from "../../state/project";
@@ -203,14 +204,17 @@ export function ChatPage() {
};
}, [draft, routeSessionId, sessionsLoading, sessions, addSession]);
// Auto-select the most recent Session when the route doesn't select one; if there are none at all, fall back to draft state (instead of auto-creating one).
// Auto-select the most recent conversation when the route doesn't select one (newest loaded
// active/schedule Session — archived rows are hidden by choice and subagent Sessions belong
// to their parent, so neither is auto-opened); if there is none, fall back to draft state
// (instead of auto-creating one).
useEffect(() => {
if (sessionsLoading || draft) return;
if (routeSessionId && sessions.some((s) => s.sessionId === routeSessionId)) return;
// A routed id missing from the paged list isn't gone until the direct lookup fails.
if (routeSessionId && probeFailedId !== routeSessionId) return;
const first = sessions[0];
navigate(first ? `/chat/${first.sessionId}` : `/chat/${DRAFT_SESSION_ID}`, { replace: true });
const last = latestConversation(sessions);
navigate(last ? `/chat/${last.sessionId}` : `/chat/${DRAFT_SESSION_ID}`, { replace: true });
}, [sessionsLoading, draft, routeSessionId, probeFailedId, sessions, navigate]);
// Sync task_state to the sidebar list badge.
@@ -574,7 +578,7 @@ export function ChatPage() {
<StatChip
icon={STAT_ICONS.cost}
value={`${formatMoney(sessionCost, currency)}${costUncosted ? " *" : ""}`}
label={`${S.chat.statCost}(${currency})${costUncosted ? ` · ${S.usage.uncostedNote}` : ""}`}
label={`${S.common.cost}(${currency})${costUncosted ? ` · ${S.usage.uncostedNote}` : ""}`}
/>
)}
<StatChip
@@ -660,7 +664,7 @@ export function ChatPage() {
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
{S.models.createdAt}
{S.common.created}
</p>
<p className="font-mono text-xs">{formatDateTime(selected.createdAt)}</p>
</div>
@@ -672,7 +676,7 @@ export function ChatPage() {
<p className="font-mono text-xs">
{S.chat.statTokens} {humanizeTokens(totalTokens)}
{sessionCost != null &&
` · ${S.chat.statCost} ${formatMoney(sessionCost, currency)}`}{" "}
` · ${S.common.cost} ${formatMoney(sessionCost, currency)}`}{" "}
· {S.chat.statElapsed} {humanizeDuration(stream.model.stats.sessionElapsedMs)}
</p>
</div>
@@ -39,7 +39,6 @@ import type {
TaskInputPart,
} from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { useAuth } from "../../state/auth";
@@ -245,7 +244,7 @@ export function DraftView({
})
.catch((e: unknown) => {
setThinkingLevel(rollback);
toastError(e instanceof ApiError ? e.message : S.common.unknownError);
toastError(apiErrorText(e));
});
},
[projectId, agentId],
@@ -810,7 +809,7 @@ function WorkspaceSelect({
api
.listDirs(projectId, abs)
.then(setDir)
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError))
.catch((e: unknown) => setError(apiErrorText(e)))
.finally(() => setLoading(false));
},
[projectId],
@@ -69,7 +69,7 @@ function MessageMeta({
{text !== undefined && (
<button
type="button"
title={copied ? S.chat.copied : S.chat.copyMessage}
title={copied ? S.common.copied : S.chat.copyMessage}
aria-label={S.chat.copyMessage}
onClick={copy}
className="rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
@@ -118,7 +118,7 @@ export function TaskStatsLine({
<StatChip
icon={STAT_ICONS.cost}
value={formatMoney(cost, currency)}
label={`${S.chat.statCost}(${currency})`}
label={`${S.common.cost}(${currency})`}
/>
)}
<StatChip
@@ -130,7 +130,7 @@ export function TaskStatsLine({
)}
<button
type="button"
title={copied ? S.chat.copied : S.chat.copyReply}
title={copied ? S.common.copied : S.chat.copyReply}
aria-label={S.chat.copyReply}
onClick={copy}
className="flex h-5 w-5 items-center justify-center rounded transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
@@ -21,8 +21,11 @@ import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { useAuth } from "../../state/auth";
import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { formatBytes, formatDateTime } from "../../lib/format";
import { Button } from "../../components/ui/button";
import { ConfirmModal } from "../../components/ui/confirm-modal";
import { toastError, toastSuccess } from "../../components/ui/toast";
import { Dropdown } from "../../components/ui/dropdown";
import { SkeletonList } from "../../components/ui/skeleton";
import { CodeBlock } from "./code-block";
@@ -195,8 +198,11 @@ export function WorkspaceBrowser({
const [data, setData] = useState<WorkspaceFilesResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
/** Picked files whose names collide with the loaded listing (non-null shows the overwrite confirm). */
const [pendingUpload, setPendingUpload] = useState<{ files: File[]; clashes: string[] } | null>(
null,
);
const [reloadTick, setReloadTick] = useState(0);
const [showPath, setShowPath] = useState(false);
/** HTML / Markdown preview: rendered view (HTML via sandboxed iframe, Markdown via md-body) / source toggle. */
@@ -223,7 +229,6 @@ export function WorkspaceBrowser({
setPath("");
setPreview(null);
setData(null);
setNotice(null);
}, [session.sessionId]);
// Edge-triggered refresh on the panel's hidden -> visible transition (doesn't count the initial mount: mounting itself already fetches once).
@@ -307,11 +312,8 @@ export function WorkspaceBrowser({
void previewPath(joinPath(path, name));
};
const onUpload = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const doUpload = (files: File[]) => {
setUploading(true);
setNotice(null);
setError(null);
void (async () => {
try {
@@ -327,15 +329,26 @@ export function WorkspaceBrowser({
});
await api.uploadWorkspaceFile(session.sessionId, joinPath(path, file.name), b64);
}
setNotice(S.files.uploaded);
toastSuccess(S.files.uploaded);
setReloadTick((t) => t + 1);
} catch (err) {
setError(err instanceof ApiError ? err.message : S.common.unknownError);
toastError(apiErrorText(err));
} finally {
setUploading(false);
}
})();
};
const onUpload = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files ? [...e.target.files] : [];
e.target.value = "";
if (files.length === 0) return;
// Uploads overwrite same-name files: names already present in the loaded listing
// confirm first (the picker is stashed — confirm continues, cancel drops it).
const existing = new Set((data?.entries ?? []).map((entry) => entry.name));
const clashes = files.filter((f) => existing.has(f.name)).map((f) => f.name);
if (clashes.length > 0) setPendingUpload({ files, clashes });
else doUpload(files);
};
const crumbs = path === "" ? [] : path.split("/");
@@ -693,10 +706,33 @@ export function WorkspaceBrowser({
))}
</ul>
)}
{notice && (
<p className="px-3 py-2 text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>
)}
</div>
{/* Upload-overwrite confirmation: same-name files in this directory get replaced. */}
<ConfirmModal
open={pendingUpload !== null}
title={S.files.overwriteTitle}
tone="primary"
confirmLabel={S.files.upload}
onClose={() => setPendingUpload(null)}
onConfirm={() => {
if (pendingUpload) doUpload(pendingUpload.files);
setPendingUpload(null);
}}
>
<div className="space-y-2">
<p className="text-sm text-gray-600 dark:text-gray-300">
{S.files.overwriteConfirm(pendingUpload?.clashes.length ?? 0)}
</p>
<ul className="max-h-40 overflow-y-auto rounded-md border border-gray-200 px-3 py-1.5 dark:border-gray-800">
{(pendingUpload?.clashes ?? []).map((name) => (
<li key={name} className="truncate py-0.5 font-mono text-xs" title={name}>
{name}
</li>
))}
</ul>
</div>
</ConfirmModal>
</div>
);
}