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:
Haileyesus
2026-07-06 21:08:22 +03:00
parent 4e423f5fa2
commit 138371525a
23 changed files with 197 additions and 70 deletions

View File

@@ -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 && (

View File

@@ -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"
>

View File

@@ -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">

View File

@@ -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]);

View File

@@ -49,7 +49,8 @@
"save": "Speichern",
"cancel": "Abbrechen",
"clearSearch": "Suche leeren",
"openCommandPalette": "Befehlspalette öffnen"
"openCommandPalette": "Befehlspalette öffnen",
"attentionRequiredIndicator": "Sitzung erfordert Aufmerksamkeit"
},
"navigation": {
"chat": "Chat",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -48,7 +48,8 @@
"activeSessionIndicator": "最近アクティブなセッション過去10分以内",
"save": "保存",
"cancel": "キャンセル",
"openCommandPalette": "コマンドパレットを開く"
"openCommandPalette": "コマンドパレットを開く",
"attentionRequiredIndicator": "セッションが対応を必要としています"
},
"navigation": {
"chat": "チャット",

View File

@@ -48,7 +48,8 @@
"activeSessionIndicator": "최근 활성 세션 (지난 10분)",
"save": "저장",
"cancel": "취소",
"openCommandPalette": "명령 팔레트 열기"
"openCommandPalette": "명령 팔레트 열기",
"attentionRequiredIndicator": "세션에 주의가 필요합니다"
},
"navigation": {
"chat": "채팅",
@@ -128,4 +129,4 @@
"allConversationsDeleted": "프로젝트가 사이드바에서 제거됩니다. 파일, 메모리 및 세션 데이터는 보존됩니다.",
"cannotUndo": "나중에 프로젝트를 다시 추가할 수 있습니다."
}
}
}

View File

@@ -49,7 +49,8 @@
"save": "Сохранить",
"cancel": "Отмена",
"clearSearch": "Очистить поиск",
"openCommandPalette": "Открыть палитру команд"
"openCommandPalette": "Открыть палитру команд",
"attentionRequiredIndicator": "Сессия требует внимания"
},
"navigation": {
"chat": "Чат",

View File

@@ -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",

View File

@@ -49,7 +49,8 @@
"save": "保存",
"cancel": "取消",
"clearSearch": "清除搜索",
"openCommandPalette": "打开命令面板"
"openCommandPalette": "打开命令面板",
"attentionRequiredIndicator": "会话需要处理"
},
"navigation": {
"chat": "聊天",

View File

@@ -48,7 +48,8 @@
"save": "儲存",
"cancel": "取消",
"clearSearch": "清除搜尋",
"openCommandPalette": "開啟指令面板"
"openCommandPalette": "開啟指令面板",
"attentionRequiredIndicator": "工作階段需要處理"
},
"navigation": {
"chat": "聊天",