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:
1 parent
c7465625a4
commit
3f868ac385
50 files changed
+2391
-1190
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user