Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/features/composer/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export function Composer({
const internalRef = useRef<HTMLTextAreaElement | null>(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));
Expand Down Expand Up @@ -241,6 +242,8 @@ export function Composer({
disabled={disabled}
sendLabel={sendLabel}
canStop={canStop}
canSend={canSend}
isProcessing={isProcessing}
onStop={onStop}
onSend={handleSend}
dictationEnabled={dictationEnabled}
Expand Down
17 changes: 14 additions & 3 deletions src/features/composer/components/ComposerInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ type ComposerInputProps = {
disabled: boolean;
sendLabel: string;
canStop: boolean;
canSend: boolean;
isProcessing: boolean;
onStop: () => void;
onSend: () => void;
dictationState?: "idle" | "listening" | "processing";
Expand Down Expand Up @@ -42,6 +44,8 @@ export function ComposerInput({
disabled,
sendLabel,
canStop,
canSend,
isProcessing,
onStop,
onSend,
dictationState = "idle",
Expand Down Expand Up @@ -314,13 +318,20 @@ export function ComposerInput({
{isDictating ? <Square aria-hidden /> : <Mic aria-hidden />}
</button>
<button
className={`composer-action${canStop ? " is-stop" : " is-send"}`}
className={`composer-action${canStop ? " is-stop" : " is-send"}${
canStop && isProcessing ? " is-loading" : ""
}`}
onClick={handleActionClick}
disabled={disabled || isDictationBusy}
disabled={disabled || isDictationBusy || (!canStop && !canSend)}
aria-label={canStop ? "Stop" : sendLabel}
>
{canStop ? (
<span className="composer-action-stop-square" aria-hidden />
<>
<span className="composer-action-stop-square" aria-hidden />
{isProcessing && (
<span className="composer-action-spinner" aria-hidden />
)}
</>
) : (
<svg viewBox="0 0 24 24" fill="none" aria-hidden>
<path
Expand Down
21 changes: 20 additions & 1 deletion src/features/messages/components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,30 @@ type MarkdownProps = {
className?: string;
codeBlock?: boolean;
onOpenFileLink?: (path: string) => void;
onOpenFileLinkMenu?: (event: React.MouseEvent, path: string) => void;
};

export function Markdown({
value,
className,
codeBlock,
onOpenFileLink,
onOpenFileLinkMenu,
}: MarkdownProps) {
const content = codeBlock ? `\`\`\`\n${value}\n\`\`\`` : value;
const handleFileLinkClick = (event: React.MouseEvent, path: string) => {
event.preventDefault();
event.stopPropagation();
onOpenFileLink?.(path);
};
const handleFileLinkContextMenu = (
event: React.MouseEvent,
path: string,
) => {
event.preventDefault();
event.stopPropagation();
onOpenFileLinkMenu?.(event, path);
};
return (
<div className={className}>
<ReactMarkdown
Expand Down Expand Up @@ -60,6 +70,9 @@ export function Markdown({
<a
href={href}
onClick={(event) => handleFileLinkClick(event, path)}
onContextMenu={(event) =>
handleFileLinkContextMenu(event, path)
}
>
{children}
</a>
Expand Down Expand Up @@ -102,7 +115,13 @@ export function Markdown({
}
const href = toFileLink(text);
return (
<a href={href} onClick={(event) => handleFileLinkClick(event, text)}>
<a
href={href}
onClick={(event) => handleFileLinkClick(event, text)}
onContextMenu={(event) =>
handleFileLinkContextMenu(event, text)
}
>
<code>{children}</code>
</a>
);
Expand Down
7 changes: 6 additions & 1 deletion src/features/messages/components/Messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ export const Messages = memo(function Messages({
const copyTimeoutRef = useRef<number | null>(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;
Expand Down Expand Up @@ -343,6 +343,7 @@ export const Messages = memo(function Messages({
value={item.text}
className="markdown"
onOpenFileLink={openFileLink}
onOpenFileLinkMenu={showFileLinkMenu}
/>
<button
type="button"
Expand Down Expand Up @@ -414,6 +415,7 @@ export const Messages = memo(function Messages({
isExpanded ? "" : "tool-inline-clamp"
}`}
onOpenFileLink={openFileLink}
onOpenFileLinkMenu={showFileLinkMenu}
/>
)}
</div>
Expand All @@ -440,6 +442,7 @@ export const Messages = memo(function Messages({
value={item.text}
className="item-text markdown"
onOpenFileLink={openFileLink}
onOpenFileLinkMenu={showFileLinkMenu}
/>
)}
</div>
Expand Down Expand Up @@ -575,6 +578,7 @@ export const Messages = memo(function Messages({
value={item.detail}
className="item-text markdown"
onOpenFileLink={openFileLink}
onOpenFileLinkMenu={showFileLinkMenu}
/>
)}
{showToolOutput && summary.output && (
Expand All @@ -583,6 +587,7 @@ export const Messages = memo(function Messages({
className="tool-inline-output markdown"
codeBlock
onOpenFileLink={openFileLink}
onOpenFileLinkMenu={showFileLinkMenu}
/>
)}
</div>
Expand Down
82 changes: 81 additions & 1 deletion src/features/messages/hooks/useFileLinkOpener.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -35,8 +39,22 @@ function stripLineSuffix(path: string) {
return match ? match[1] : path;
}

function revealLabel() {
const platform =
(navigator as Navigator & { userAgentData?: { platform?: string } })
.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;
Expand All @@ -53,4 +71,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);
},
Comment on lines +95 to +99

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Make “Open Link in New Window” actually open a new window

The new context menu item labeled “Open Link in New Window” executes the exact same action as “Open Link” (openFileLink(rawPath)), so users will never get a distinct new-window behavior. Because openFileLink ultimately delegates to open_workspace_in (which just calls open -a <app> <path>), this menu item is misleading and provides no added functionality; consider removing it or wiring a separate command/flag (e.g., open -n -a on macOS) to match the label.

Useful? React with 👍 / 👎.

Comment on lines +95 to +99

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Make “Open Link in New Window” actually open a new window

The new context-menu item labeled “Open Link in New Window” invokes the same openFileLink action as the regular open item, so it performs the exact same operation and does not open a new window. This is user-visible misbehavior: when users rely on opening in a separate window to keep their existing editor state intact, this option won’t do that because it calls the same handler. Consider wiring this to a distinct code path (e.g., a new backend command or different app args) or removing the label until the behavior exists.

Useful? React with 👍 / 👎.

}),
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 };
}
19 changes: 19 additions & 0 deletions src/styles/composer.css
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
}

.composer-action--mic.is-active {
Expand Down Expand Up @@ -312,6 +313,16 @@
background: currentColor;
}

.composer-action-spinner {
position: absolute;
width: 18px;
height: 18px;
border-radius: 999px;
border: 2px solid rgba(255, 120, 120, 0.35);
border-top-color: rgba(255, 120, 120, 0.9);
animation: composer-action-spin 0.8s linear infinite;
}


.composer.is-disabled {
opacity: 0.7;
Expand Down Expand Up @@ -346,6 +357,14 @@
color: rgba(255, 196, 196, 0.4);
}

@keyframes composer-action-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

.composer-select:disabled {
cursor: not-allowed;
Expand Down