From 20b0087c26923e4102a716f72b4045af78e410cf Mon Sep 17 00:00:00 2001 From: liangbin Date: Sun, 18 Jan 2026 18:22:55 +0800 Subject: [PATCH 1/4] feat(messages): add file-link context menu with reveal --- src/features/messages/components/Markdown.tsx | 21 ++++- src/features/messages/components/Messages.tsx | 7 +- .../messages/hooks/useFileLinkOpener.ts | 81 ++++++++++++++++++- 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index d71992ba4..66d743e75 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -14,6 +14,7 @@ type MarkdownProps = { className?: string; codeBlock?: boolean; onOpenFileLink?: (path: string) => void; + onOpenFileLinkMenu?: (event: React.MouseEvent, path: string) => void; }; export function Markdown({ @@ -21,6 +22,7 @@ export function Markdown({ className, codeBlock, onOpenFileLink, + onOpenFileLinkMenu, }: MarkdownProps) { const content = codeBlock ? `\`\`\`\n${value}\n\`\`\`` : value; const handleFileLinkClick = (event: React.MouseEvent, path: string) => { @@ -28,6 +30,14 @@ export function Markdown({ event.stopPropagation(); onOpenFileLink?.(path); }; + const handleFileLinkContextMenu = ( + event: React.MouseEvent, + path: string, + ) => { + event.preventDefault(); + event.stopPropagation(); + onOpenFileLinkMenu?.(event, path); + }; return (
handleFileLinkClick(event, path)} + onContextMenu={(event) => + handleFileLinkContextMenu(event, path) + } > {children} @@ -102,7 +115,13 @@ export function Markdown({ } const href = toFileLink(text); return ( - handleFileLinkClick(event, text)}> + handleFileLinkClick(event, text)} + onContextMenu={(event) => + handleFileLinkContextMenu(event, text) + } + > {children} ); diff --git a/src/features/messages/components/Messages.tsx b/src/features/messages/components/Messages.tsx index 3dc36587f..6aa59bc3d 100644 --- a/src/features/messages/components/Messages.tsx +++ b/src/features/messages/components/Messages.tsx @@ -221,7 +221,7 @@ export const Messages = memo(function Messages({ const copyTimeoutRef = useRef(null); const [elapsedMs, setElapsedMs] = useState(0); const scrollKey = scrollKeyForItems(items); - const openFileLink = useFileLinkOpener(workspacePath); + const { openFileLink, showFileLinkMenu } = useFileLinkOpener(workspacePath); const isNearBottom = (node: HTMLDivElement) => node.scrollHeight - node.scrollTop - node.clientHeight <= SCROLL_THRESHOLD_PX; @@ -343,6 +343,7 @@ export const Messages = memo(function Messages({ value={item.text} className="markdown" onOpenFileLink={openFileLink} + onOpenFileLinkMenu={showFileLinkMenu} />
@@ -440,6 +442,7 @@ export const Messages = memo(function Messages({ value={item.text} className="item-text markdown" onOpenFileLink={openFileLink} + onOpenFileLinkMenu={showFileLinkMenu} /> )} @@ -575,6 +578,7 @@ export const Messages = memo(function Messages({ value={item.detail} className="item-text markdown" onOpenFileLink={openFileLink} + onOpenFileLinkMenu={showFileLinkMenu} /> )} {showToolOutput && summary.output && ( @@ -583,6 +587,7 @@ export const Messages = memo(function Messages({ className="tool-inline-output markdown" codeBlock onOpenFileLink={openFileLink} + onOpenFileLinkMenu={showFileLinkMenu} /> )} diff --git a/src/features/messages/hooks/useFileLinkOpener.ts b/src/features/messages/hooks/useFileLinkOpener.ts index d4a983aa2..01a4a3cd6 100644 --- a/src/features/messages/hooks/useFileLinkOpener.ts +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -1,4 +1,8 @@ import { useCallback } from "react"; +import type { MouseEvent } from "react"; +import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu"; +import { LogicalPosition } from "@tauri-apps/api/dpi"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { openWorkspaceIn } from "../../../services/tauri"; import { getStoredOpenAppId } from "../../app/utils/openApp"; @@ -35,8 +39,21 @@ function stripLineSuffix(path: string) { return match ? match[1] : path; } +function revealLabel() { + const platform = + navigator.userAgentData?.platform ?? navigator.platform ?? ""; + const normalized = platform.toLowerCase(); + if (normalized.includes("mac")) { + return "Reveal in Finder"; + } + if (normalized.includes("win")) { + return "Show in Explorer"; + } + return "Reveal in File Manager"; +} + export function useFileLinkOpener(workspacePath?: string | null) { - return useCallback( + const openFileLink = useCallback( async (rawPath: string) => { const openAppId = getStoredOpenAppId(); const target = OPEN_TARGETS[openAppId] ?? OPEN_TARGETS.vscode; @@ -53,4 +70,66 @@ export function useFileLinkOpener(workspacePath?: string | null) { }, [workspacePath], ); + + const showFileLinkMenu = useCallback( + async (event: MouseEvent, rawPath: string) => { + event.preventDefault(); + event.stopPropagation(); + const openAppId = getStoredOpenAppId(); + const target = OPEN_TARGETS[openAppId] ?? OPEN_TARGETS.vscode; + const resolvedPath = resolveFilePath(stripLineSuffix(rawPath), workspacePath); + const openLabel = + target.id === "finder" + ? "Open in Finder" + : target.appName + ? `Open in ${target.appName}` + : "Open Link"; + const items = [ + await MenuItem.new({ + text: openLabel, + action: async () => { + await openFileLink(rawPath); + }, + }), + await MenuItem.new({ + text: "Open Link in New Window", + action: async () => { + await openFileLink(rawPath); + }, + }), + await MenuItem.new({ + text: revealLabel(), + action: async () => { + await revealItemInDir(resolvedPath); + }, + }), + await MenuItem.new({ + text: "Download Linked File", + enabled: false, + }), + await MenuItem.new({ + text: "Copy Link", + action: async () => { + const link = + resolvedPath.startsWith("/") ? `file://${resolvedPath}` : resolvedPath; + try { + await navigator.clipboard.writeText(link); + } catch { + // Clipboard failures are non-fatal here. + } + }, + }), + await PredefinedMenuItem.new({ item: "Separator" }), + await PredefinedMenuItem.new({ item: "Services" }), + ]; + + const menu = await Menu.new({ items }); + const window = getCurrentWindow(); + const position = new LogicalPosition(event.clientX, event.clientY); + await menu.popup(position, window); + }, + [openFileLink, workspacePath], + ); + + return { openFileLink, showFileLinkMenu }; } From 69dcc5228eeccd24395f5000beee84fdf8f995ed Mon Sep 17 00:00:00 2001 From: liangbin Date: Sun, 18 Jan 2026 18:29:59 +0800 Subject: [PATCH 2/4] fix(messages): handle userAgentData typing --- src/features/messages/hooks/useFileLinkOpener.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/messages/hooks/useFileLinkOpener.ts b/src/features/messages/hooks/useFileLinkOpener.ts index 01a4a3cd6..c6e7de0f1 100644 --- a/src/features/messages/hooks/useFileLinkOpener.ts +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -41,7 +41,8 @@ function stripLineSuffix(path: string) { function revealLabel() { const platform = - navigator.userAgentData?.platform ?? navigator.platform ?? ""; + (navigator as Navigator & { userAgentData?: { platform?: string } }) + .userAgentData?.platform ?? navigator.platform ?? ""; const normalized = platform.toLowerCase(); if (normalized.includes("mac")) { return "Reveal in Finder"; From ccb923bdc7defcecf730915482c37c6efb03e1b4 Mon Sep 17 00:00:00 2001 From: liangbin Date: Sun, 18 Jan 2026 19:38:15 +0800 Subject: [PATCH 3/4] feat(composer): add empty-state disable and stop loading --- src/features/composer/components/Composer.tsx | 3 +++ .../composer/components/ComposerInput.tsx | 17 ++++++++++++++--- src/styles/composer.css | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/features/composer/components/Composer.tsx b/src/features/composer/components/Composer.tsx index d8953ed71..574a7baf0 100644 --- a/src/features/composer/components/Composer.tsx +++ b/src/features/composer/components/Composer.tsx @@ -117,6 +117,7 @@ export function Composer({ const internalRef = useRef(null); const textareaRef = externalTextareaRef ?? internalRef; const isDictationBusy = dictationState !== "idle"; + const canSend = text.trim().length > 0 || attachedImages.length > 0; useEffect(() => { setText((prev) => (prev === draftText ? prev : draftText)); @@ -241,6 +242,8 @@ export function Composer({ disabled={disabled} sendLabel={sendLabel} canStop={canStop} + canSend={canSend} + isProcessing={isProcessing} onStop={onStop} onSend={handleSend} dictationEnabled={dictationEnabled} diff --git a/src/features/composer/components/ComposerInput.tsx b/src/features/composer/components/ComposerInput.tsx index db3514d27..13cd983e9 100644 --- a/src/features/composer/components/ComposerInput.tsx +++ b/src/features/composer/components/ComposerInput.tsx @@ -11,6 +11,8 @@ type ComposerInputProps = { disabled: boolean; sendLabel: string; canStop: boolean; + canSend: boolean; + isProcessing: boolean; onStop: () => void; onSend: () => void; dictationState?: "idle" | "listening" | "processing"; @@ -42,6 +44,8 @@ export function ComposerInput({ disabled, sendLabel, canStop, + canSend, + isProcessing, onStop, onSend, dictationState = "idle", @@ -314,13 +318,20 @@ export function ComposerInput({ {isDictating ? : } - + + ); } diff --git a/src/styles/composer.css b/src/styles/composer.css index 95ba08f57..311f15f51 100644 --- a/src/styles/composer.css +++ b/src/styles/composer.css @@ -198,6 +198,35 @@ position: relative; } +.composer-action-wrap { + display: inline-flex; + position: relative; +} + +.composer-action-wrap::after { + content: attr(data-tooltip); + position: absolute; + left: 50%; + bottom: calc(100% + 6px); + transform: translateX(-50%) translateY(4px); + padding: 4px 8px; + border-radius: 999px; + font-size: 10px; + color: var(--text-emphasis); + background: var(--surface-command); + border: 1px solid var(--border-subtle); + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease, transform 120ms ease; + z-index: 2; +} + +.composer-action-wrap:hover::after { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + .composer-action--mic.is-active { border-color: rgba(120, 235, 190, 0.6); background: rgba(120, 235, 190, 0.12);