feat: add workspace files to chat from Files and Changes

* feat(app): open changed files from context menu

* fix(app): preserve changed-file row behavior

* feat(app): add changed files to open chats

* feat(app): share file actions across explorer and changes

* feat(app): attach workspace files to focused chat

* feat(app): drag workspace files into chat

* refactor(app): use file actions menu for changed files

* fix(app): open changed-file actions on right click

* test(app): cover direct file attachment flow

* fix(app): enable add to chat on native

* fix(app): align workspace file pane rows

* refactor(app): simplify workspace file attachments

* fix(app): preserve uploaded file draft attachments

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
nikuscs
2026-07-22 15:58:24 +02:00
committed by GitHub
co-authored by Mohamed Boudra
parent 42ee5a5949
commit bd937850b3
42 changed files with 1665 additions and 342 deletions
+85 -2
View File
@@ -99,6 +99,7 @@ import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
WorkspaceFileComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { PickedFile } from "@/attachments/picked-file";
@@ -119,6 +120,15 @@ import { getForgePresentation } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon";
import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
import {
appendWorkspaceFileAttachment,
getWorkspaceFileAttachmentKey,
getWorkspaceFileAttachmentSubtitle,
} from "@/attachments/workspace-file";
import {
resolveWorkspaceFileDrop,
type WorkspaceFileDragPayload,
} from "@/attachments/workspace-file-drag";
type QueuedMessage = QueuedComposerMessage;
@@ -399,6 +409,18 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
/>
);
}
if (attachment.kind === "workspace_file") {
return (
<WorkspaceFileAttachmentPill
key={`workspace-file:${getWorkspaceFileAttachmentKey(attachment)}`}
attachment={attachment}
index={index}
disabled={disabled}
onRemove={onRemove}
removeLabel={labels.removeFile}
/>
);
}
if (composerWorkspaceAttachment.is(attachment)) {
return composerWorkspaceAttachment.renderPill({
attachment,
@@ -711,6 +733,43 @@ function FileAttachmentPill({
);
}
interface WorkspaceFileAttachmentPillProps {
attachment: WorkspaceFileComposerAttachment;
index: number;
disabled: boolean;
onRemove: (index: number) => void;
removeLabel: string;
}
function WorkspaceFileAttachmentPill({
attachment,
index,
disabled,
onRemove,
removeLabel,
}: WorkspaceFileAttachmentPillProps) {
const handleRemove = useCallback(() => {
onRemove(index);
}, [index, onRemove]);
const fileName = attachment.path.split("/").pop() ?? attachment.path;
return (
<AttachmentPill
testID="composer-workspace-file-attachment-pill"
onOpen={noopCallback}
onRemove={handleRemove}
openAccessibilityLabel={fileName}
removeAccessibilityLabel={removeLabel}
disabled={disabled}
>
<AttachmentLabel
icon={filePillIcon}
title={fileName}
subtitle={getWorkspaceFileAttachmentSubtitle(attachment)}
/>
</AttachmentPill>
);
}
interface GithubPickerOptionProps {
label: string;
testID: string;
@@ -755,6 +814,7 @@ function GithubPickerOption({
interface ComposerProps {
agentId: string;
serverId: string;
workspaceId?: string | null;
isPaneFocused: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
onClientSlashCommand?: (command: ClientSlashCommand) => Promise<void>;
@@ -786,6 +846,8 @@ interface ComposerProps {
clearDraft: (lifecycle: "sent" | "abandoned") => void;
/** When true, auto-focuses the text input on web. */
autoFocus?: boolean;
/** Changing this value requests focus again while autoFocus remains true. */
autoFocusKey?: string;
/** Callback to expose a focus function to parent components (desktop only). */
onFocusInput?: (focus: () => void) => void;
/** Optional draft context for listing commands before an agent exists. */
@@ -977,6 +1039,7 @@ function ComposerVoiceModeButton({
export function Composer({
agentId,
serverId,
workspaceId,
isPaneFocused,
onSubmitMessage,
onClientSlashCommand,
@@ -1000,6 +1063,7 @@ export function Composer({
cwd,
clearDraft,
autoFocus = false,
autoFocusKey,
onFocusInput,
commandDraftConfig,
onMessageSent,
@@ -1195,6 +1259,21 @@ export function Composer({
});
}, []);
const handleWorkspaceFileDropped = useCallback(
(payload: WorkspaceFileDragPayload) => {
if (!workspaceId) {
return;
}
const attachment = resolveWorkspaceFileDrop({ payload, serverId, workspaceId });
if (!attachment) {
return;
}
setSelectedAttachments((current) => appendWorkspaceFileAttachment(current, attachment));
focusInput();
},
[focusInput, serverId, setSelectedAttachments, workspaceId],
);
useEffect(() => {
onFocusInput?.(focusInput);
}, [focusInput, onFocusInput]);
@@ -1969,7 +2048,11 @@ export function Composer({
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
// backdrop and rejects the drop atomically, instead of accepting a drop with no feedback.
useFileDrop(
{ onFiles: addImages, onGenericFiles: handleGenericFilesDropped },
{
onFiles: addImages,
onGenericFiles: handleGenericFilesDropped,
onWorkspaceFile: handleWorkspaceFileDropped,
},
{ disabled: isSubmitBusy },
);
@@ -2030,7 +2113,7 @@ export function Composer({
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}`}
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
disabled={isSubmitLoading}
isPaneFocused={isPaneFocused}
leftContent={leftContent}