diff --git a/Cargo.lock b/Cargo.lock index 0cf50979..a5bd52c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3261,6 +3261,7 @@ dependencies = [ "uuid", "wait-timeout", "walkdir", + "windows-sys 0.61.2", "zip 0.6.6", ] diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 2a6f78ef..9ffb730c 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -297,6 +297,11 @@ export default function GatewayApp() { // Per-conversation runtime workdir (drafts have no persisted summary yet). const conversationWorkdirsRef = useRef>(new Map()); const displayedConversationWorkdirRef = useRef(""); + const pendingUploadContextRef = useRef<{ + conversationId: string; + workdir: string; + executionMode: string; + } | null>(null); const displayedConversationBusyRef = useRef(false); const historyLoadSequenceRef = useRef(0); const visibleConversationRevisionRef = useRef(0); @@ -3070,6 +3075,34 @@ export default function GatewayApp() { currentConversationRuntimeWorkdir || (isAgentMode ? activeWorkspaceProjectPath || settings.system.workdir.trim() : ""); displayedConversationWorkdirRef.current = displayedConversationWorkdir; + // Pending uploads live under their conversation's workdir uploads/ tree. + // Switching conversations keeps every conversation's uploads, but a workdir + // change within the same conversation (a draft switching projects) makes its + // relative paths stale, and a mode flip away from tools invalidates all of + // them — mirroring the GUI-side rule in usePendingUploads. + useEffect(() => { + const executionMode = settings.system.executionMode; + const previous = pendingUploadContextRef.current; + pendingUploadContextRef.current = { + conversationId: displayedConversationId, + workdir: displayedConversationWorkdir, + executionMode, + }; + if (!previous) return; + if (previous.executionMode !== executionMode) { + clearPendingUploads(); + return; + } + if (previous.conversationId !== displayedConversationId) return; + if (previous.workdir === displayedConversationWorkdir) return; + setPendingUploadsForConversation(displayedConversationId, []); + }, [ + clearPendingUploads, + displayedConversationId, + displayedConversationWorkdir, + settings.system.executionMode, + setPendingUploadsForConversation, + ]); useEffect(() => { if (!api || !displayedConversationId) { queuedChatTurnsRef.current = []; @@ -3517,6 +3550,7 @@ export default function GatewayApp() { ref={fileInputRef} type="file" multiple + aria-label={translate("chat.upload.selectFiles", settings.locale)} className="gateway-hidden-file-input" onChange={(event) => { const files = Array.from(event.currentTarget.files ?? []); diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index 9797eca3..28fe0cd8 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -1373,10 +1373,6 @@ export function GatewayTranscript({ return (
-
-
-
-
0 ? (
-
- - -
-
-
    2 ? "true" : "false"} - className={cn( - "chat-queue-scroll flex min-w-0 flex-col gap-1 overflow-x-hidden", - queuedTurns.length > 2 - ? "h-[76px] overflow-y-scroll pr-3" - : "max-h-[76px] overflow-y-hidden pr-1", - )} - > - {queuedTurns.map((item, index) => ( -
  • +
    +
    +
      2 ? "true" : "false"} + className={cn( + "chat-queue-scroll flex min-w-0 flex-col gap-1 overflow-x-hidden", + queuedTurns.length > 2 + ? "h-[76px] overflow-y-scroll pr-3" + : "max-h-[76px] overflow-y-hidden pr-1", + )} > -
      - {index > 0 ? ( - - ) : ( - - )} - -
      -
      - - {item.previewText || t("chat.queue.emptyMessage")} - - {item.fileCount > 0 ? ( - - {t("chat.queue.fileCount").replace("{count}", String(item.fileCount))} - - ) : null} -
      -
      - - - - - - - - - + {queuedTurns.map((item, index) => ( +
    • +
      + {index > 0 ? ( + + ) : ( + + )} + +
      +
      + + {item.previewText || t("chat.queue.emptyMessage")} + + {item.fileCount > 0 ? ( + + {t("chat.queue.fileCount").replace( + "{count}", + String(item.fileCount), + )} + + ) : null} +
      +
      + + + + + + + + + +
      +
    • + ))} +
    + {shouldShowQueueScrollbar ? ( +
    +
    -
  • - ))} -
- {shouldShowQueueScrollbar ? ( -
-
+ ) : null}
- ) : null} +
+
) : null} diff --git a/crates/agent-gui/src-tauri/Cargo.toml b/crates/agent-gui/src-tauri/Cargo.toml index ef176b54..348896e2 100644 --- a/crates/agent-gui/src-tauri/Cargo.toml +++ b/crates/agent-gui/src-tauri/Cargo.toml @@ -61,3 +61,6 @@ russh-sftp = "2.3.0" [target.'cfg(target_os = "macos")'.dependencies] objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSButton", "NSControl", "NSView", "NSWindow"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs index f297df70..6f16de29 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs @@ -233,12 +233,33 @@ fn file_identity(meta: &fs::Metadata, _canon: &Path) -> String { } #[cfg(windows)] -fn file_identity(meta: &fs::Metadata, canon: &Path) -> String { - use std::os::windows::fs::MetadataExt; - match (meta.volume_serial_number(), meta.file_index()) { - (Some(volume), Some(index)) => format!("{volume}:{index}"), - _ => format!("path:{}", display_path(canon).to_lowercase()), +fn file_identity(_meta: &fs::Metadata, canon: &Path) -> String { + // std's volume_serial_number()/file_index() are unstable (windows_by_handle, + // rust-lang/rust#63010); query GetFileInformationByHandle directly instead. + fn identity_by_handle(path: &Path) -> Option { + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS, + }; + + // access_mode(0): metadata-only handle, no read permission required. + // FILE_FLAG_BACKUP_SEMANTICS is required to open directories. + let file = fs::OpenOptions::new() + .access_mode(0) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(path) + .ok()?; + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, &mut info) } == 0 { + return None; + } + let volume = info.dwVolumeSerialNumber; + let index = (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow); + Some(format!("{volume}:{index}")) } + identity_by_handle(canon) + .unwrap_or_else(|| format!("path:{}", display_path(canon).to_lowercase())) } fn levenshtein_at_most(a: &str, b: &str, max: usize) -> bool { diff --git a/crates/agent-gui/src/index.css b/crates/agent-gui/src/index.css index b11206a1..eeeedc76 100644 --- a/crates/agent-gui/src/index.css +++ b/crates/agent-gui/src/index.css @@ -1093,34 +1093,6 @@ } } - /* Glow pulse animation */ - .hero-glow-pulse { - animation: - heroGlowPulse 5s ease-in-out infinite, - heroFadeIn 1.2s ease-out both; - } - - @keyframes heroGlowPulse { - 0%, - 100% { - opacity: 0.7; - transform: scale(1); - } - 50% { - opacity: 1; - transform: scale(1.12); - } - } - - @keyframes heroFadeIn { - from { - opacity: 0; - } - to { - opacity: 0.7; - } - } - /* Mention composer placeholder */ .mention-composer.is-empty::before { content: attr(data-placeholder); diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 293bfa61..68dbcbad 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -1723,6 +1723,7 @@ export function ChatPage(props: ChatPageProps) { } = usePendingUploads({ isAgentMode, workdir: displayedConversationWorkdir, + conversationId: currentConversationId, currentConversationIdRef, composerRef, setErrorMessage, @@ -3314,9 +3315,8 @@ export function ChatPage(props: ChatPageProps) { useEffect(() => { currentConversationIdRef.current = currentConversationId; - setPendingUploadedFiles( - pendingUploadsByConversationRef.current.get(currentConversationId) ?? [], - ); + // Per-conversation pending uploads are restored inside usePendingUploads + // when its conversationId param changes. }, [currentConversationId]); useEffect(() => { diff --git a/crates/agent-gui/src/pages/chat/components/ChatComposerBar.tsx b/crates/agent-gui/src/pages/chat/components/ChatComposerBar.tsx index 2ce8cc65..241c9a3a 100644 --- a/crates/agent-gui/src/pages/chat/components/ChatComposerBar.tsx +++ b/crates/agent-gui/src/pages/chat/components/ChatComposerBar.tsx @@ -499,142 +499,145 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { {queuedTurns.length > 0 ? (
-
- - -
-
-
    2 ? "true" : "false"} - className={cn( - "chat-queue-scroll flex min-w-0 flex-col gap-1 overflow-x-hidden", - queuedTurns.length > 2 - ? "h-[76px] overflow-y-scroll pr-3" - : "max-h-[76px] overflow-y-hidden pr-1", - )} - > - {queuedTurns.map((item, index) => { - return ( -
  • +
    +
    +
      2 ? "true" : "false"} + className={cn( + "chat-queue-scroll flex min-w-0 flex-col gap-1 overflow-x-hidden", + queuedTurns.length > 2 + ? "h-[76px] overflow-y-scroll pr-3" + : "max-h-[76px] overflow-y-hidden pr-1", + )} + > + {queuedTurns.map((item, index) => { + return ( +
    • +
      + {index > 0 ? ( + + ) : ( + + )} + +
      +
      + + {item.previewText || t("chat.queue.emptyMessage")} + + {item.fileCount > 0 ? ( + + {t("chat.queue.fileCount").replace( + "{count}", + String(item.fileCount), + )} + + ) : null} +
      +
      + + + + + + + + + +
      +
    • + ); + })} +
    + {shouldShowQueueScrollbar ? ( +
    -
    - {index > 0 ? ( - - ) : ( - - )} - -
    -
    - - {item.previewText || t("chat.queue.emptyMessage")} - - {item.fileCount > 0 ? ( - - {t("chat.queue.fileCount").replace("{count}", String(item.fileCount))} - - ) : null} -
    -
    - - - - - - - - - -
    -
  • - ); - })} -
- {shouldShowQueueScrollbar ? ( -
-
+
+
+ ) : null}
- ) : null} +
+
) : null} diff --git a/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts b/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts index 565f45e0..ec02b9bf 100644 --- a/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts +++ b/crates/agent-gui/src/pages/chat/hooks/usePendingUploads.ts @@ -22,6 +22,7 @@ type SystemUploadedReadableFileInput = { type UsePendingUploadsParams = { isAgentMode: boolean; workdir: string; + conversationId: string; currentConversationIdRef: MutableRefObject; composerRef: MutableRefObject; setErrorMessage: (message: string | null) => void; @@ -53,6 +54,7 @@ export function usePendingUploads(params: UsePendingUploadsParams) { const { isAgentMode, workdir, + conversationId, currentConversationIdRef, composerRef, setErrorMessage, @@ -61,7 +63,11 @@ export function usePendingUploads(params: UsePendingUploadsParams) { const [pendingUploadedFiles, setPendingUploadedFiles] = useState([]); const [isUploadingFiles, setIsUploadingFiles] = useState(false); const activeUploadTasksRef = useRef(0); - const uploadContextRef = useRef<{ isAgentMode: boolean; workdir: string } | null>(null); + const uploadContextRef = useRef<{ + isAgentMode: boolean; + workdir: string; + conversationId: string; + } | null>(null); const pendingUploadsByConversationRef = useRef(new Map()); const pendingUploadedFilesRef = useRef(pendingUploadedFiles); @@ -80,16 +86,6 @@ export function usePendingUploads(params: UsePendingUploadsParams) { }; }, []); - useEffect(() => { - const previous = uploadContextRef.current; - uploadContextRef.current = { isAgentMode, workdir }; - if (!previous) return; - if (previous.isAgentMode === isAgentMode && previous.workdir === workdir) return; - pendingUploadsByConversationRef.current.clear(); - pendingUploadedFilesRef.current = []; - setPendingUploadedFiles([]); - }, [isAgentMode, workdir]); - const getPendingUploadsForConversation = useCallback( (conversationId: string) => { const targetConversationId = conversationId.trim(); @@ -126,6 +122,36 @@ export function usePendingUploads(params: UsePendingUploadsParams) { [currentConversationIdRef], ); + useEffect(() => { + const targetConversationId = conversationId.trim(); + const nextFiles = targetConversationId + ? (pendingUploadsByConversationRef.current.get(targetConversationId) ?? []) + : []; + pendingUploadedFilesRef.current = nextFiles; + setPendingUploadedFiles(nextFiles); + }, [conversationId]); + + useEffect(() => { + const previous = uploadContextRef.current; + uploadContextRef.current = { isAgentMode, workdir, conversationId }; + if (!previous) return; + if (previous.isAgentMode !== isAgentMode) { + // Attachments are only usable in tools mode; a mode flip invalidates + // every conversation's pending uploads. + pendingUploadsByConversationRef.current.clear(); + pendingUploadedFilesRef.current = []; + setPendingUploadedFiles([]); + return; + } + // Switching conversations must not invalidate any conversation's + // uploads — each entry is relative to its own workdir. Only a workdir + // change within the same conversation (a draft switching projects) + // makes that conversation's relative paths stale. + if (previous.conversationId !== conversationId) return; + if (previous.workdir === workdir) return; + setPendingUploadsForConversation(conversationId, []); + }, [isAgentMode, workdir, conversationId, setPendingUploadsForConversation]); + const captureUploadTarget = useCallback(() => { const targetConversationId = currentConversationIdRef.current.trim(); if (!targetConversationId) { diff --git a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx index f3680dd3..e5c2068e 100644 --- a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx @@ -157,10 +157,6 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript
{showNoModelsState ? (
-
-
-
-
) : showStartChatState ? (
-
-
-
-