mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-07 14:12:40 +08:00
fix: address code review findings
- validate chat image attachments server-side: only files inside the ~/.cloudcli/assets upload store may reach provider file reads - harden asset serving with nosniff and attachment disposition for SVGs to prevent stored XSS - show the timestamp on image-only user messages - serialize git stage/unstage calls and defer the status re-sync so rapid toggles can't interleave or flicker - use a literal hex fallback for commit ref badges (alpha suffix on a var() string produced invalid CSS) - stop binding a URL session to a guening project instead - add the missing attentionRequiredIndicator key to all sidebar locales - clean up dangling conjunctions left the de/ru/tr/ja/zh-CN/zh-TW READMEs
This commit is contained in:
@@ -94,7 +94,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
||||
projectId={selectedProject?.projectId}
|
||||
/>
|
||||
)}
|
||||
{(userCopyContent.trim().length > 0 || !message.images?.length) && (
|
||||
{userCopyContent.trim().length > 0 || !message.images?.length ? (
|
||||
<div className="group max-w-full rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:px-4">
|
||||
<div dir="auto" className="whitespace-pre-wrap break-words font-serif text-sm">
|
||||
{message.content}
|
||||
@@ -106,6 +106,11 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
|
||||
<span>{formattedTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Image-only turn: no text bubble, but the timestamp still shows */
|
||||
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||
<span>{formattedTime}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isGrouped && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GitBranch, GitCommit, RefreshCw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ConfirmationRequest, FileStatusCode, GitDiffMap, GitStatusResponse } from '../../types/types';
|
||||
import { getAllChangedFiles, hasChangedFiles } from '../../utils/gitPanelUtils';
|
||||
import CommitComposer from './CommitComposer';
|
||||
@@ -49,20 +49,40 @@ export default function ChangesView({
|
||||
}: ChangesViewProps) {
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
// Stage/unstage calls in flight or queued. While > 0, status refreshes must
|
||||
// not overwrite the optimistic selection with a snapshot that predates the
|
||||
// later clicks.
|
||||
const [pendingStageOps, setPendingStageOps] = useState(0);
|
||||
// Serializes stage/unstage requests so rapid toggles cannot interleave on
|
||||
// the server or resolve out of order.
|
||||
const stageOpQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
|
||||
const changedFiles = useMemo(() => getAllChangedFiles(gitStatus), [gitStatus]);
|
||||
const hasExpandedFiles = expandedFiles.size > 0;
|
||||
|
||||
const enqueueStageOp = useCallback((operation: () => Promise<unknown>) => {
|
||||
setPendingStageOps((count) => count + 1);
|
||||
stageOpQueueRef.current = stageOpQueueRef.current
|
||||
.catch(() => {}) // a failed op must not block the queue
|
||||
.then(operation)
|
||||
.finally(() => setPendingStageOps((count) => count - 1));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gitStatus || gitStatus.error) {
|
||||
setSelectedFiles(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingStageOps > 0) {
|
||||
return; // keep the optimistic state until the queued ops settle
|
||||
}
|
||||
|
||||
// The Staged section mirrors the real git index reported by /status, so
|
||||
// files staged outside the app (VSCode, terminal) show up here too.
|
||||
// files staged outside the app (VSCode, terminal) show up here too. Also
|
||||
// re-runs when the queue drains, syncing to the final refreshed status.
|
||||
setSelectedFiles(new Set(gitStatus.staged ?? []));
|
||||
}, [gitStatus]);
|
||||
}, [gitStatus, pendingStageOps]);
|
||||
|
||||
useEffect(() => {
|
||||
onExpandedFilesChange(hasExpandedFiles);
|
||||
@@ -87,8 +107,8 @@ export default function ChangesView({
|
||||
}, []);
|
||||
|
||||
// Staging is real: every toggle runs git add / git reset through the API.
|
||||
// The set is flipped optimistically; the status refresh triggered by the
|
||||
// API call re-syncs it from the actual index afterwards.
|
||||
// The set is flipped optimistically; the queued API call keeps the git
|
||||
// index in sync and the final status refresh re-syncs once the queue drains.
|
||||
const toggleFileSelected = useCallback(
|
||||
(filePath: string) => {
|
||||
const isStaged = selectedFiles.has(filePath);
|
||||
@@ -101,9 +121,9 @@ export default function ChangesView({
|
||||
}
|
||||
return next;
|
||||
});
|
||||
void (isStaged ? onUnstageFiles([filePath]) : onStageFiles([filePath]));
|
||||
enqueueStageOp(() => (isStaged ? onUnstageFiles([filePath]) : onStageFiles([filePath])));
|
||||
},
|
||||
[onStageFiles, onUnstageFiles, selectedFiles],
|
||||
[enqueueStageOp, onStageFiles, onUnstageFiles, selectedFiles],
|
||||
);
|
||||
|
||||
const requestFileAction = useCallback(
|
||||
@@ -209,7 +229,7 @@ export default function ChangesView({
|
||||
onClick={() => {
|
||||
const filesToUnstage = Array.from(selectedFiles);
|
||||
setSelectedFiles(new Set());
|
||||
void onUnstageFiles(filesToUnstage);
|
||||
enqueueStageOp(() => onUnstageFiles(filesToUnstage));
|
||||
}}
|
||||
className="text-xs text-primary transition-colors hover:text-primary/80"
|
||||
>
|
||||
@@ -246,7 +266,7 @@ export default function ChangesView({
|
||||
onClick={() => {
|
||||
const filesToStage = Array.from(unstagedFiles);
|
||||
setSelectedFiles(new Set(changedFiles));
|
||||
void onStageFiles(filesToStage);
|
||||
enqueueStageOp(() => onStageFiles(filesToStage));
|
||||
}}
|
||||
className="text-xs text-primary transition-colors hover:text-primary/80"
|
||||
>
|
||||
|
||||
@@ -62,7 +62,9 @@ export default function CommitHistoryItem({
|
||||
return parseCommitFiles(diff);
|
||||
}, [diff]);
|
||||
|
||||
const badgeColor = graphRow ? laneColor(graphRow.nodeLane) : 'var(--color-primary, #0ea5e9)';
|
||||
// Must stay a literal hex value: RefBadge derives its HEAD tint by
|
||||
// appending an alpha byte (`${color}22`), which breaks for var() strings.
|
||||
const badgeColor = graphRow ? laneColor(graphRow.nodeLane) : '#0ea5e9';
|
||||
|
||||
return (
|
||||
<div className="flex border-b border-border last:border-0">
|
||||
|
||||
@@ -810,19 +810,18 @@ export function useProjectsState({
|
||||
return;
|
||||
}
|
||||
|
||||
const projectForSession = selectedProject || projects.find((p) => (p.sessions?.length ?? 0) > 0);
|
||||
if (!projectForSession) {
|
||||
// Only the currently selected project may host the placeholder. Guessing
|
||||
// another project (e.g. "first one with sessions") could bind the URL
|
||||
// session to the wrong project — better to wait until the owning project
|
||||
// arrives in a later `projects` payload and is matched by the loop above.
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedProject?.projectId !== projectForSession.projectId) {
|
||||
setSelectedProject(projectForSession);
|
||||
}
|
||||
|
||||
setSelectedSession({
|
||||
id: sessionId,
|
||||
__provider: readSelectedProvider(),
|
||||
__projectId: projectForSession.projectId,
|
||||
__projectId: selectedProject.projectId,
|
||||
summary: '',
|
||||
});
|
||||
}, [sessionId, projects, selectedProject, selectedSession?.id, selectedSession?.__provider]);
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"clearSearch": "Suche leeren",
|
||||
"openCommandPalette": "Befehlspalette öffnen"
|
||||
"openCommandPalette": "Befehlspalette öffnen",
|
||||
"attentionRequiredIndicator": "Sitzung erfordert Aufmerksamkeit"
|
||||
},
|
||||
"navigation": {
|
||||
"chat": "Chat",
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"clearSearch": "Clear search",
|
||||
"openCommandPalette": "Open command palette"
|
||||
"openCommandPalette": "Open command palette",
|
||||
"attentionRequiredIndicator": "Session needs attention"
|
||||
},
|
||||
"navigation": {
|
||||
"chat": "Chat",
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"clearSearch": "Effacer la recherche",
|
||||
"openCommandPalette": "Ouvrir la palette de commandes"
|
||||
"openCommandPalette": "Ouvrir la palette de commandes",
|
||||
"attentionRequiredIndicator": "La session nécessite votre attention"
|
||||
},
|
||||
"navigation": {
|
||||
"chat": "Chat",
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla",
|
||||
"clearSearch": "Cancella ricerca",
|
||||
"openCommandPalette": "Apri tavolozza comandi"
|
||||
"openCommandPalette": "Apri tavolozza comandi",
|
||||
"attentionRequiredIndicator": "La sessione richiede attenzione"
|
||||
},
|
||||
"navigation": {
|
||||
"chat": "Chat",
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
"activeSessionIndicator": "最近アクティブなセッション(過去10分以内)",
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"openCommandPalette": "コマンドパレットを開く"
|
||||
"openCommandPalette": "コマンドパレットを開く",
|
||||
"attentionRequiredIndicator": "セッションが対応を必要としています"
|
||||
},
|
||||
"navigation": {
|
||||
"chat": "チャット",
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
"activeSessionIndicator": "최근 활성 세션 (지난 10분)",
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"openCommandPalette": "명령 팔레트 열기"
|
||||
"openCommandPalette": "명령 팔레트 열기",
|
||||
"attentionRequiredIndicator": "세션에 주의가 필요합니다"
|
||||
},
|
||||
"navigation": {
|
||||
"chat": "채팅",
|
||||
@@ -128,4 +129,4 @@
|
||||
"allConversationsDeleted": "프로젝트가 사이드바에서 제거됩니다. 파일, 메모리 및 세션 데이터는 보존됩니다.",
|
||||
"cannotUndo": "나중에 프로젝트를 다시 추가할 수 있습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"clearSearch": "Очистить поиск",
|
||||
"openCommandPalette": "Открыть палитру команд"
|
||||
"openCommandPalette": "Открыть палитру команд",
|
||||
"attentionRequiredIndicator": "Сессия требует внимания"
|
||||
},
|
||||
"navigation": {
|
||||
"chat": "Чат",
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
"save": "Kaydet",
|
||||
"cancel": "İptal",
|
||||
"clearSearch": "Aramayı temizle",
|
||||
"openCommandPalette": "Komut paletini aç"
|
||||
"openCommandPalette": "Komut paletini aç",
|
||||
"attentionRequiredIndicator": "Oturum ilgi bekliyor"
|
||||
},
|
||||
"navigation": {
|
||||
"chat": "Sohbet",
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"clearSearch": "清除搜索",
|
||||
"openCommandPalette": "打开命令面板"
|
||||
"openCommandPalette": "打开命令面板",
|
||||
"attentionRequiredIndicator": "会话需要处理"
|
||||
},
|
||||
"navigation": {
|
||||
"chat": "聊天",
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
"save": "儲存",
|
||||
"cancel": "取消",
|
||||
"clearSearch": "清除搜尋",
|
||||
"openCommandPalette": "開啟指令面板"
|
||||
"openCommandPalette": "開啟指令面板",
|
||||
"attentionRequiredIndicator": "工作階段需要處理"
|
||||
},
|
||||
"navigation": {
|
||||
"chat": "聊天",
|
||||
|
||||
Reference in New Issue
Block a user