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 14:58:24 +01:00
committed by GitHub
parent 42ee5a5949
commit bd937850b3
42 changed files with 1665 additions and 342 deletions

View File

@@ -0,0 +1,49 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { seedMockAgentWorkspace, openAgentRoute } from "./helpers/mock-agent";
function visibleComposer(page: Page) {
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
}
test("adds a changed file to the focused chat without replacing its composer draft", async ({
page,
}) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "add-file-to-chat-",
title: "Target chat",
});
const relativePath = "src/changed file.ts";
try {
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
await workspace.client.checkoutRefresh(workspace.cwd);
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const agentComposer = visibleComposer(page);
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
await agentComposer.fill("Preserve this thought");
await page.getByRole("button", { name: "Open explorer" }).click();
await page.getByTestId("explorer-tab-changes").click();
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await page.getByTestId("diff-file-0-add-to-chat").click();
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
await expect(attachment).toContainText("changed file.ts");
await expect(attachment).toContainText(relativePath);
await expect(agentComposer).toHaveValue("Preserve this thought");
await expect(agentComposer).toBeFocused();
} finally {
await workspace.cleanup();
}
});

View File

@@ -1,4 +1,4 @@
import { writeFile } from "node:fs/promises";
import { unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
@@ -12,6 +12,10 @@ interface DirtyWorkspace {
id: string;
}
interface WorkspaceFixtureOptions {
includeDeletedFile?: boolean;
}
interface CleanupTask {
run: () => Promise<void>;
}
@@ -218,6 +222,25 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
});
});
test("changes file actions open from the kebab and right-click", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
await page.getByTestId("diff-file-1-actions").click();
await expect(page.getByText("Copy path")).toBeVisible();
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
await page.getByTestId("diff-file-0-open-file").click();
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
});
test("changes diff switches between flat and tree file lists", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
@@ -250,6 +273,53 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await expectFlatFileList(page);
});
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
const changesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "changes-options-menu"),
readSvgRight(page, "diff-file-0-actions"),
]);
expectAligned(changesRightRail);
const [folderStat, fileStat] = await Promise.all([
page.getByTestId("diff-folder-src-stat").boundingBox(),
page.getByTestId("diff-file-0-stat").boundingBox(),
]);
expect(folderStat).not.toBeNull();
expect(fileStat).not.toBeNull();
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
const filesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "files-refresh"),
readSvgRight(page, "file-explorer-row-0-actions"),
]);
expectAligned(filesRightRail);
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
page.getByTestId("files-sort-label").boundingBox(),
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
page.getByTestId("file-explorer-row-0").boundingBox(),
]);
expect(sortLabel).not.toBeNull();
expect(firstRowIcon).not.toBeNull();
expect(treeBounds).not.toBeNull();
expect(rowBounds).not.toBeNull();
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
@@ -399,10 +469,14 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
});
}
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("diff-row-alignment-", {
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
});
async function createWorkspaceWithMountedTabDiff(
options: WorkspaceFixtureOptions = {},
): Promise<DirtyWorkspace> {
const files = [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }];
if (options.includeDeletedFile) {
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
}
const repo = await createTempGitRepo("diff-row-alignment-", { files });
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
@@ -412,6 +486,9 @@ async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
});
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
if (options.includeDeletedFile) {
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
}
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
@@ -436,6 +513,21 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
}
async function readSvgRight(page: Page, testID: string): Promise<number> {
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
if (!box) {
throw new Error(`Could not measure ${testID}`);
}
return box.x + box.width;
}
function expectAligned(values: number[]): void {
const [first, ...rest] = values;
for (const value of rest) {
expect(value).toBeCloseTo(first, 0);
}
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({

View File

@@ -92,9 +92,20 @@ export interface ChatHistoryContextAttachment {
export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
export type WorkspaceFileSelection =
| { kind: "whole_file" }
| { kind: "line_range"; startLine: number; endLine: number };
export interface WorkspaceFileComposerAttachment {
kind: "workspace_file";
path: string;
selection: WorkspaceFileSelection;
}
export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| WorkspaceFileComposerAttachment
| { kind: "forge_issue"; item: ForgeSearchItem }
| { kind: "forge_change_request"; item: ForgeSearchItem }
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106

View File

@@ -0,0 +1,9 @@
import type { RefCallback } from "react";
import type { View } from "react-native";
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
export function useWorkspaceFileDragSource(
_input: WorkspaceFileDragSourceInput,
): RefCallback<View> | undefined {
return undefined;
}

View File

@@ -0,0 +1,54 @@
import { useCallback, useEffect, useState, type RefCallback } from "react";
import type { View } from "react-native";
import { createWorkspaceFileAttachment } from "./workspace-file";
import { serializeWorkspaceFileDragPayload, WORKSPACE_FILE_DRAG_MIME } from "./workspace-file-drag";
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
export function useWorkspaceFileDragSource({
enabled,
disabled = false,
serverId,
workspaceId,
path,
selection,
}: WorkspaceFileDragSourceInput): RefCallback<View> {
const [element, setElement] = useState<HTMLElement | null>(null);
const dragSourceRef = useCallback((node: View | null) => {
setElement(node as unknown as HTMLElement | null);
}, []);
useEffect(() => {
if (!element || !enabled || disabled || !serverId || !workspaceId) {
if (element) {
element.draggable = false;
}
return;
}
const sourceServerId = serverId;
const sourceWorkspaceId = workspaceId;
element.draggable = true;
function handleDragStart(event: DragEvent) {
if (!event.dataTransfer) {
return;
}
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(
WORKSPACE_FILE_DRAG_MIME,
serializeWorkspaceFileDragPayload({
version: 1,
serverId: sourceServerId,
workspaceId: sourceWorkspaceId,
attachment: createWorkspaceFileAttachment({ path, selection }),
}),
);
}
element.addEventListener("dragstart", handleDragStart);
return () => {
element.draggable = false;
element.removeEventListener("dragstart", handleDragStart);
};
}, [disabled, element, enabled, path, selection, serverId, workspaceId]);
return dragSourceRef;
}

View File

@@ -0,0 +1,10 @@
import type { WorkspaceFileSelection } from "./types";
export interface WorkspaceFileDragSourceInput {
enabled: boolean;
disabled?: boolean;
serverId?: string;
workspaceId: string | null | undefined;
path: string;
selection?: WorkspaceFileSelection;
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { createWorkspaceFileAttachment } from "./workspace-file";
import {
parseWorkspaceFileDragPayload,
resolveWorkspaceFileDrop,
serializeWorkspaceFileDragPayload,
type WorkspaceFileDragPayload,
} from "./workspace-file-drag";
function payload(): WorkspaceFileDragPayload {
return {
version: 1,
serverId: "server-1",
workspaceId: "workspace-1",
attachment: createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
}),
};
}
describe("workspace file drag payload", () => {
it("round-trips workspace identity and future line selections", () => {
expect(parseWorkspaceFileDragPayload(serializeWorkspaceFileDragPayload(payload()))).toEqual(
payload(),
);
});
it("rejects malformed and invalid payloads", () => {
expect(parseWorkspaceFileDragPayload("not json")).toBeNull();
expect(
parseWorkspaceFileDragPayload(
JSON.stringify({
...payload(),
attachment: {
kind: "workspace_file",
path: "src/app.ts",
selection: { kind: "line_range", startLine: 24, endLine: 12 },
},
}),
),
).toBeNull();
});
it("accepts drops only within the originating server and workspace", () => {
const dragged = payload();
expect(
resolveWorkspaceFileDrop({
payload: dragged,
serverId: "server-1",
workspaceId: "workspace-1",
}),
).toEqual(dragged.attachment);
expect(
resolveWorkspaceFileDrop({
payload: dragged,
serverId: "server-1",
workspaceId: "workspace-2",
}),
).toBeNull();
});
});

View File

@@ -0,0 +1,55 @@
import type { WorkspaceFileComposerAttachment } from "./types";
import { isWorkspaceFileComposerAttachment } from "./workspace-file";
export const WORKSPACE_FILE_DRAG_MIME = "application/x-paseo-workspace-file+json";
export interface WorkspaceFileDragPayload {
version: 1;
serverId: string;
workspaceId: string;
attachment: WorkspaceFileComposerAttachment;
}
export function serializeWorkspaceFileDragPayload(payload: WorkspaceFileDragPayload): string {
return JSON.stringify(payload);
}
export function parseWorkspaceFileDragPayload(serialized: string): WorkspaceFileDragPayload | null {
let value: unknown;
try {
value = JSON.parse(serialized);
} catch {
return null;
}
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
if (
record.version !== 1 ||
typeof record.serverId !== "string" ||
record.serverId.length === 0 ||
typeof record.workspaceId !== "string" ||
record.workspaceId.length === 0 ||
!isWorkspaceFileComposerAttachment(record.attachment)
) {
return null;
}
return {
version: 1,
serverId: record.serverId,
workspaceId: record.workspaceId,
attachment: record.attachment,
};
}
export function resolveWorkspaceFileDrop(input: {
payload: WorkspaceFileDragPayload;
serverId: string;
workspaceId: string;
}): WorkspaceFileComposerAttachment | null {
return input.payload.serverId === input.serverId &&
input.payload.workspaceId === input.workspaceId
? input.payload.attachment
: null;
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import {
appendWorkspaceFileAttachment,
createWorkspaceFileAttachment,
getWorkspaceFileAttachmentKey,
getWorkspaceFileAttachmentSubtitle,
workspaceFileAttachmentToAgentAttachment,
} from "./workspace-file";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
describe("workspace file attachments", () => {
it("models whole files and line ranges as distinct selections", () => {
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
const lineRange = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
});
expect(wholeFile).toEqual({
kind: "workspace_file",
path: "src/app.ts",
selection: { kind: "whole_file" },
});
expect(getWorkspaceFileAttachmentKey(wholeFile)).not.toBe(
getWorkspaceFileAttachmentKey(lineRange),
);
expect(getWorkspaceFileAttachmentSubtitle(wholeFile)).toBe("src/app.ts");
expect(getWorkspaceFileAttachmentSubtitle(lineRange)).toBe("src/app.ts · 12-24");
});
it("deduplicates only identical paths and selections", () => {
const image = {
kind: "image" as const,
metadata: {
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb" as const,
storageKey: "image-1",
createdAt: 1,
},
};
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
const range = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 1, endLine: 5 },
});
const current: UserComposerAttachment[] = [image, wholeFile];
expect(appendWorkspaceFileAttachment(current, wholeFile)).toBe(current);
expect(appendWorkspaceFileAttachment(current, range)).toEqual([image, wholeFile, range]);
});
it("submits a path reference without uploading or inserting prompt text", () => {
const attachment = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
});
expect(workspaceFileAttachmentToAgentAttachment(attachment)).toEqual({
type: "text",
mimeType: "text/plain",
title: "app.ts",
text: "Workspace file: src/app.ts\nLines: 12-24",
});
expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({
images: [],
attachments: [workspaceFileAttachmentToAgentAttachment(attachment)],
});
});
});

View File

@@ -0,0 +1,106 @@
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type {
UserComposerAttachment,
WorkspaceFileComposerAttachment,
WorkspaceFileSelection,
} from "./types";
interface CreateWorkspaceFileAttachmentInput {
path: string;
selection?: WorkspaceFileSelection;
}
function normalizePath(path: string): string {
return path.trim().replace(/^\.\//, "");
}
export function isWorkspaceFileComposerAttachment(
value: unknown,
): value is WorkspaceFileComposerAttachment {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
if (
record.kind !== "workspace_file" ||
typeof record.path !== "string" ||
record.path.trim().length === 0
) {
return false;
}
const selection = record.selection;
if (!selection || typeof selection !== "object") {
return false;
}
const { kind, startLine, endLine } = selection as Record<string, unknown>;
if (kind === "whole_file") {
return true;
}
return (
kind === "line_range" &&
typeof startLine === "number" &&
Number.isInteger(startLine) &&
typeof endLine === "number" &&
Number.isInteger(endLine) &&
startLine > 0 &&
endLine >= startLine
);
}
export function createWorkspaceFileAttachment({
path,
selection = { kind: "whole_file" },
}: CreateWorkspaceFileAttachmentInput): WorkspaceFileComposerAttachment {
return {
kind: "workspace_file",
path: normalizePath(path),
selection,
};
}
export function getWorkspaceFileAttachmentKey(attachment: WorkspaceFileComposerAttachment): string {
const selection = attachment.selection;
const selectionKey =
selection.kind === "whole_file"
? selection.kind
: `${selection.kind}:${selection.startLine}-${selection.endLine}`;
return `${normalizePath(attachment.path)}:${selectionKey}`;
}
export function appendWorkspaceFileAttachment(
current: UserComposerAttachment[],
attachment: WorkspaceFileComposerAttachment,
): UserComposerAttachment[] {
const attachmentKey = getWorkspaceFileAttachmentKey(attachment);
const alreadyAttached = current.some(
(candidate) =>
candidate.kind === "workspace_file" &&
getWorkspaceFileAttachmentKey(candidate) === attachmentKey,
);
return alreadyAttached ? current : [...current, attachment];
}
export function workspaceFileAttachmentToAgentAttachment(
attachment: WorkspaceFileComposerAttachment,
): Extract<AgentAttachment, { type: "text" }> {
const fileName = attachment.path.split("/").pop() ?? attachment.path;
const lines =
attachment.selection.kind === "line_range"
? `\nLines: ${attachment.selection.startLine}-${attachment.selection.endLine}`
: "";
return {
type: "text",
mimeType: "text/plain",
title: fileName,
text: `Workspace file: ${attachment.path}${lines}`,
};
}
export function getWorkspaceFileAttachmentSubtitle(
attachment: WorkspaceFileComposerAttachment,
): string {
if (attachment.selection.kind === "whole_file") {
return attachment.path;
}
return `${attachment.path} · ${attachment.selection.startLine}-${attachment.selection.endLine}`;
}

View File

@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
interface DiffStatProps {
additions: number;
deletions: number;
testID?: string;
}
const compactFormatter = new Intl.NumberFormat("en-US", {
@@ -15,9 +16,9 @@ export function formatDiffCount(value: number): string {
return compactFormatter.format(value).toLowerCase();
}
export function DiffStat({ additions, deletions }: DiffStatProps) {
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
return (
<View style={styles.row}>
<View style={styles.row} testID={testID}>
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
</View>

View File

@@ -36,6 +36,13 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
import { useDraftStore } from "@/stores/draft-store";
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -386,15 +393,16 @@ function ExplorerSidebarContent({
{/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area">
{resolvedTab === "changes" && (
<GitDiffPane
<ChangedFilesPane
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
enabled={isOpen}
workspaceRoot={workspaceRoot}
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
)}
{resolvedTab === "files" && (
<FileExplorerPane
<FilesPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
@@ -415,6 +423,83 @@ function ExplorerSidebarContent({
);
}
/**
* Shared add-to-chat state for the changes/files panes: both expose an "add file
* to chat" action that attaches the file to the focused chat's composer.
* Available only when a workspace with a focused chat is available.
*/
function useAddFileToChat({
serverId,
workspaceId,
}: Pick<SidebarContentProps, "serverId" | "workspaceId">) {
const workspaceKey = workspaceId
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
: null;
const layout = useWorkspaceLayoutStore((state) =>
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
);
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
const focusedChat = useMemo(
() => resolveFocusedChatTarget({ serverId, layout }),
[serverId, layout],
);
const addFile = useCallback(
(filePath: string) => {
if (!focusedChat || !workspaceKey) {
return;
}
void useDraftStore.getState().attachWorkspaceFile({
draftKey: focusedChat.draftKey,
attachment: createWorkspaceFileAttachment({ path: filePath }),
});
focusTab(workspaceKey, focusedChat.tabId);
},
[focusTab, focusedChat, workspaceKey],
);
return { addFile, canAddToChat: focusedChat !== null };
}
function ChangedFilesPane({
serverId,
workspaceId,
workspaceRoot,
isOpen,
onOpenFile,
}: Pick<
SidebarContentProps,
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
>) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return (
<GitDiffPane
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
enabled={isOpen}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
function FilesPane({
serverId,
workspaceId,
workspaceRoot,
onOpenFile,
}: Pick<SidebarContentProps, "serverId" | "workspaceId" | "workspaceRoot" | "onOpenFile">) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return (
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
interface PrTabContentProps {
serverId: string;
cwd: string;

View File

@@ -0,0 +1,185 @@
import { useMemo, type ReactElement, type ReactNode } from "react";
import { type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
Copy,
Download,
FileText,
MessageSquarePlus,
MoreVertical,
type LucideIcon,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedMoreVertical = withUnistyles(MoreVertical);
/** Width occupied by a file action trigger, including its visual padding. */
export const FILE_ACTIONS_MENU_WIDTH = ICON_SIZE.sm + 2 * SPACING[1];
interface FileAction {
key: string;
label: string;
icon: LucideIcon;
onSelect: () => void;
testID?: string;
}
interface FileActionsMenuProps {
fileKind: "file" | "directory";
fileExists?: boolean;
onOpenFile?: () => void;
onCopyPath?: () => void;
onDownload?: () => void;
onAddToChat?: () => void;
/** Optional metadata block rendered above the actions (e.g. size/modified). */
header?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
hitSlop?: number;
accessibilityLabel: string;
testIDPrefix?: string;
}
// The menu lives inside pressable rows (diff header, explorer entry); stop the
// press so opening it doesn't also trigger the row.
function stopTriggerPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function triggerStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [styles.trigger, (Boolean(hovered) || pressed || Boolean(open)) && styles.triggerActive];
}
/**
* Shared kebab (⋮) menu for per-file actions. Used by the file explorer tree and
* git diff pane so both surfaces share action availability, ordering, and chrome.
*/
export function FileActionsMenu({
fileKind,
fileExists = true,
onOpenFile,
onCopyPath,
onDownload,
onAddToChat,
header,
open,
onOpenChange,
hitSlop = 12,
accessibilityLabel,
testIDPrefix,
}: FileActionsMenuProps): ReactElement | null {
const { t } = useTranslation();
const actions = useMemo<FileAction[]>(() => {
const availableFile = fileKind === "file" && fileExists;
const next: FileAction[] = [];
if (availableFile && onOpenFile) {
next.push({
key: "open-file",
label: t("workspace.fileActions.openFile"),
icon: FileText,
onSelect: onOpenFile,
testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined,
});
}
if (onCopyPath) {
next.push({
key: "copy-path",
label: t("workspace.fileActions.copyPath"),
icon: Copy,
onSelect: onCopyPath,
});
}
if (availableFile && onDownload) {
next.push({
key: "download",
label: t("workspace.fileActions.download"),
icon: Download,
onSelect: onDownload,
});
}
if (availableFile && onAddToChat) {
next.push({
key: "add-to-chat",
label: t("workspace.fileActions.addToChat"),
icon: MessageSquarePlus,
onSelect: onAddToChat,
testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined,
});
}
return next;
}, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]);
if (actions.length === 0) {
return null;
}
return (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger
hitSlop={hitSlop}
onPressIn={stopTriggerPropagation}
style={triggerStyle}
accessibilityLabel={accessibilityLabel}
testID={testIDPrefix ? `${testIDPrefix}-actions` : undefined}
>
<ThemedMoreVertical size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
{header ? (
<>
{header}
<DropdownMenuSeparator />
</>
) : null}
{actions.map((action) => (
<FileActionMenuItem key={action.key} action={action} />
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
const Icon = action.icon;
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
const leading = useMemo(
() => <ThemedIcon size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />,
[ThemedIcon],
);
return (
<DropdownMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
{action.label}
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
// The hover box comes from padding, but an equal negative vertical margin
// cancels its height contribution so the trigger overlaps the row's natural
// line height instead of growing it. The comfortable tap target is `hitSlop`,
// never padding.
padding: theme.spacing[1],
width: FILE_ACTIONS_MENU_WIDTH,
marginVertical: -theme.spacing[1],
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
triggerActive: {
backgroundColor: theme.colors.surface2,
},
}));

View File

@@ -1,4 +1,5 @@
import type { ImageAttachment } from "@/composer/types";
import type { WorkspaceFileDragPayload } from "@/attachments/workspace-file-drag";
export interface DroppedFileItem {
kind: "web-file";
@@ -18,4 +19,5 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
export interface FileDropSink {
onFiles: (images: ImageAttachment[]) => void;
onGenericFiles?: (items: DroppedItem[]) => void;
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
}

View File

@@ -11,6 +11,10 @@ import {
} from "@/attachments/file-types";
import { isWeb } from "@/constants/platform";
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
import {
parseWorkspaceFileDragPayload,
WORKSPACE_FILE_DRAG_MIME,
} from "@/attachments/workspace-file-drag";
type DesktopDragDropPayload =
| { type: "enter"; paths: string[] }
@@ -185,7 +189,11 @@ export function useDropListeners({
if (disabledRef.current) return;
dragCounter.current++;
if (e.dataTransfer?.types.includes("Files")) {
if (suppressed.value || !hasSink.value) return;
const types = new Set(e.dataTransfer?.types ?? []);
const acceptsWorkspaceFile =
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
if (types.has("Files") || acceptsWorkspaceFile) {
isDragging.value = true;
}
}
@@ -197,7 +205,11 @@ export function useDropListeners({
if (!e.dataTransfer) return;
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
const canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
const types = new Set(e.dataTransfer.types);
const acceptsWorkspaceFile =
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
const acceptsDrop = types.has("Files") || acceptsWorkspaceFile;
const canAccept = acceptsDrop && !disabledRef.current && !suppressed.value && hasSink.value;
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
}
@@ -225,6 +237,14 @@ export function useDropListeners({
const sink = getSink();
if (!sink) return;
const serializedWorkspaceFile = e.dataTransfer?.getData(WORKSPACE_FILE_DRAG_MIME);
if (serializedWorkspaceFile && sink.onWorkspaceFile) {
const payload = parseWorkspaceFileDragPayload(serializedWorkspaceFile);
if (payload) {
sink.onWorkspaceFile(payload);
}
}
const files = Array.from(e.dataTransfer?.files ?? []);
const genericItems: DroppedItem[] = files.map((file) => ({
kind: "web-file",

View File

@@ -15,35 +15,26 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import * as Clipboard from "expo-clipboard";
import {
ChevronDown,
Copy,
Download,
Eye,
EyeOff,
MoreVertical,
RotateCw,
} from "lucide-react-native";
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
import {
TreeChevron,
TreeIndentGuides,
treeRowPaddingLeft,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { FileActionsMenu } from "@/components/file-actions-menu";
import { useFileDownload } from "@/hooks/use-file-download";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "name" },
@@ -62,6 +53,8 @@ function formatFileSize({ size }: { size: number }): string {
}
interface TreeRowItemProps {
serverId: string;
workspaceId?: string | null;
entry: ExplorerEntry;
depth: number;
isExpanded: boolean;
@@ -70,21 +63,8 @@ interface TreeRowItemProps {
onEntryPress: (entry: ExplorerEntry) => void;
onCopyPath: (path: string) => void;
onDownloadEntry: (entry: ExplorerEntry) => void;
}
function stopPressInPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function menuButtonStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [
styles.menuButton,
(Boolean(hovered) || pressed || Boolean(open)) && styles.menuButtonActive,
];
onAddToChat?: (path: string) => void;
testID?: string;
}
function sortTriggerStyle({
@@ -103,6 +83,8 @@ function treeRowKeyExtractor(row: TreeRow) {
}
function TreeRowItem({
serverId,
workspaceId,
entry,
depth,
isExpanded,
@@ -111,10 +93,17 @@ function TreeRowItem({
onEntryPress,
onCopyPath,
onDownloadEntry,
onAddToChat,
testID,
}: TreeRowItemProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isDirectory = entry.kind === "directory";
const dragSourceRef = useWorkspaceFileDragSource({
enabled: !isDirectory,
serverId,
workspaceId,
path: entry.path,
});
const handlePress = useCallback(() => {
onEntryPress(entry);
@@ -123,10 +112,10 @@ function TreeRowItem({
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.entryRow,
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
{ paddingLeft: treeRowPaddingLeft(depth) },
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
],
[depth, isSelected, theme.spacing],
[depth, isSelected],
);
const handleCopy = useCallback(() => {
@@ -137,19 +126,38 @@ function TreeRowItem({
onDownloadEntry(entry);
}, [onDownloadEntry, entry]);
const copyLeading = useMemo(
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const downloadLeading = useMemo(
() => <Download size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
const handleAddToChat = useCallback(() => {
onAddToChat?.(entry.path);
}, [onAddToChat, entry.path]);
const metaHeader = useMemo(
() => (
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
),
[entry.modifiedAt, entry.size, t],
);
return (
<Pressable onPress={handlePress} style={pressableStyle}>
<Pressable onPress={handlePress} style={pressableStyle} testID={testID}>
<TreeIndentGuides depth={depth} />
<View style={styles.entryInfo}>
<View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryIcon}>
{(() => {
if (!isDirectory) {
@@ -163,40 +171,15 @@ function TreeRowItem({
{entry.name}
</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger hitSlop={8} onPressIn={stopPressInPropagation} style={menuButtonStyle}>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
<DropdownMenuSeparator />
<DropdownMenuItem leading={copyLeading} onSelect={handleCopy}>
{t("workspace.fileExplorer.context.copyPath")}
</DropdownMenuItem>
{entry.kind === "file" ? (
<DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}>
{t("workspace.fileExplorer.context.download")}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<FileActionsMenu
fileKind={entry.kind}
onCopyPath={handleCopy}
onDownload={handleDownload}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
header={metaHeader}
accessibilityLabel={t("workspace.fileActions.moreActions")}
testIDPrefix={testID}
/>
</Pressable>
);
}
@@ -206,6 +189,7 @@ interface FileExplorerPaneProps {
workspaceId?: string | null;
workspaceRoot: string;
onOpenFile?: (filePath: string) => void;
onAddToChat?: (path: string) => void;
}
interface TreeRow {
@@ -218,14 +202,10 @@ export function FileExplorerPane({
workspaceId,
workspaceRoot,
onOpenFile,
onAddToChat,
}: FileExplorerPaneProps) {
const { t } = useTranslation();
const daemons = useHosts();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId],
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const workspaceStateKey = useMemo(
() =>
@@ -235,10 +215,6 @@ export function FileExplorerPane({
}),
[normalizedWorkspaceRoot, workspaceId],
);
const workspaceScopeId = useMemo(
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId],
);
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
const explorerState = useSessionStore((state) =>
workspaceStateKey && state.sessions[serverId]
@@ -246,12 +222,16 @@ export function FileExplorerPane({
: undefined,
);
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const downloadFile = useFileDownload({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const sortOption = usePanelStore((state) => state.explorerSortOption);
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
@@ -346,18 +326,14 @@ export function FileExplorerPane({
[normalizedWorkspaceRoot],
);
const startDownload = useDownloadStore((state) => state.startDownload);
const handleDownloadEntry = useCallback(
(entry: ExplorerEntry) =>
downloadExplorerEntry({
entry,
workspaceScopeId,
serverId,
daemonProfile,
startDownload,
requestFileDownloadToken,
}),
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
(entry: ExplorerEntry) => {
if (entry.kind !== "file") {
return;
}
downloadFile({ fileName: entry.name, path: entry.path });
},
[downloadFile],
);
const handleSortCycle = useCallback(() => {
@@ -419,6 +395,8 @@ export function FileExplorerPane({
const renderTreeRow = useCallback(
(info: ListRenderItemInfo<TreeRow>) => (
<TreeRowDispatcher
serverId={serverId}
workspaceId={workspaceId}
info={info}
expandedPaths={expandedPaths}
selectedEntryPath={selectedEntryPath}
@@ -426,6 +404,7 @@ export function FileExplorerPane({
onEntryPress={handleEntryPress}
onCopyPath={handleCopyPath}
onDownloadEntry={handleDownloadEntry}
onAddToChat={onAddToChat}
/>
),
[
@@ -435,6 +414,9 @@ export function FileExplorerPane({
handleDownloadEntry,
isDirectoryLoading,
selectedEntryPath,
onAddToChat,
serverId,
workspaceId,
],
);
@@ -576,8 +558,14 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<Pressable onPress={handleSortCycle} style={sortTriggerStyleProp}>
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
<Pressable
onPress={handleSortCycle}
style={sortTriggerStyleProp}
testID="files-sort-trigger"
>
<Text style={styles.sortTriggerText} testID="files-sort-label">
{currentSortLabel}
</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.headerActions}>
@@ -588,6 +576,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
accessibilityRole="button"
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
accessibilityState={hiddenFilesToggleAccessibilityState}
testID="files-hidden-toggle"
>
{showHiddenFiles ? (
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
@@ -606,6 +595,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
testID="files-refresh"
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
@@ -778,39 +768,6 @@ function resolveTreeRows({
});
}
type StartDownloadFn = ReturnType<typeof useDownloadStore.getState>["startDownload"];
type StartDownloadParams = Parameters<StartDownloadFn>[0];
function downloadExplorerEntry({
entry,
workspaceScopeId,
serverId,
daemonProfile,
startDownload,
requestFileDownloadToken,
}: {
entry: ExplorerEntry;
workspaceScopeId: string | undefined;
serverId: string;
daemonProfile: StartDownloadParams["daemonProfile"];
startDownload: StartDownloadFn;
requestFileDownloadToken: (
targetPath: string,
) => ReturnType<StartDownloadParams["requestFileDownloadToken"]>;
}): void {
if (!workspaceScopeId || entry.kind !== "file") {
return;
}
startDownload({
serverId,
scopeId: workspaceScopeId,
fileName: entry.name,
path: entry.path,
daemonProfile,
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
});
}
function toggleDirectory({
entry,
workspaceStateKey,
@@ -850,6 +807,8 @@ function toggleDirectory({
}
function TreeRowDispatcher({
serverId,
workspaceId,
info,
expandedPaths,
selectedEntryPath,
@@ -857,7 +816,10 @@ function TreeRowDispatcher({
onEntryPress,
onCopyPath,
onDownloadEntry,
onAddToChat,
}: {
serverId: string;
workspaceId?: string | null;
info: ListRenderItemInfo<TreeRow>;
expandedPaths: Set<string>;
selectedEntryPath: string | null;
@@ -865,6 +827,7 @@ function TreeRowDispatcher({
onEntryPress: (entry: ExplorerEntry) => void;
onCopyPath: (path: string) => void | Promise<void>;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
}) {
const entry = info.item.entry;
const depth = info.item.depth;
@@ -875,6 +838,8 @@ function TreeRowDispatcher({
return (
<TreeRowItem
serverId={serverId}
workspaceId={workspaceId}
entry={entry}
depth={depth}
isExpanded={isExpanded}
@@ -883,6 +848,8 @@ function TreeRowDispatcher({
onEntryPress={onEntryPress}
onCopyPath={onCopyPath}
onDownloadEntry={onDownloadEntry}
onAddToChat={onAddToChat}
testID={`file-explorer-row-${info.index}`}
/>
);
}
@@ -1061,7 +1028,6 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
entriesContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
@@ -1111,9 +1077,8 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 2,
paddingRight: theme.spacing[2],
borderRadius: theme.borderRadius.md,
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
paddingRight: theme.spacing[3],
},
entryRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
@@ -1133,16 +1098,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
menuButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
menuButtonActive: {
backgroundColor: theme.colors.surface2,
},
contextMetaBlock: {
paddingVertical: theme.spacing[1],
},

View File

@@ -11,6 +11,7 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
// indentation, guide lines, and chevron. Keep those here so the two trees can't
// drift apart.
export const TREE_INDENT_PER_LEVEL = 16;
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
* in the Changes tree so their indentation can't drift apart. */

View File

@@ -338,7 +338,7 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
input.setLightboxMetadata(input.attachment.metadata);
return;
}
if (input.attachment.kind === "file") {
if (input.attachment.kind === "file" || input.attachment.kind === "workspace_file") {
return;
}
if (isWorkspaceAttachment(input.attachment)) {

View File

@@ -9,6 +9,7 @@ import {
buildForgeAttachmentFromSearchItem,
buildLegacyGitHubAttachmentFromSearchItem,
} from "@/utils/review-attachments";
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
@@ -49,6 +50,11 @@ export function splitComposerAttachmentsForSubmit(
continue;
}
if (attachment.kind === "workspace_file") {
agentAttachments.push(workspaceFileAttachmentToAgentAttachment(attachment));
continue;
}
if (isWorkspaceAttachment(attachment)) {
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
images.push(attachment.attachment.screenshot);

View File

@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { useDraftStore } from "@/stores/draft-store";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
const { asyncStorage } = vi.hoisted(() => ({
asyncStorage: new Map<string, string>(),
@@ -159,7 +160,11 @@ describe("useAgentInputDraft live contract", () => {
configurable: true,
});
useDraftStore.setState({ drafts: {}, createModalDraft: null });
useDraftStore.setState({
drafts: {},
createModalDraft: null,
attachmentFocusRequestByDraftKey: {},
});
});
it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => {
@@ -424,6 +429,39 @@ describe("useAgentInputDraft live contract", () => {
});
});
it("attaches to an unmounted legacy draft without losing its input", async () => {
const image: AttachmentMetadata = {
id: "legacy-image",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachments/legacy-image",
createdAt: 10,
};
useDraftStore.setState({
drafts: {
"draft:legacy-workspace-file": {
input: { text: "legacy text", images: [image] },
lifecycle: "active",
updatedAt: Date.now(),
version: 1,
} as unknown as DraftRecordForTest,
},
});
await useDraftStore.getState().attachWorkspaceFile({
draftKey: "draft:legacy-workspace-file",
attachment: createWorkspaceFileAttachment({ path: "src/app.ts" }),
});
expect(useDraftStore.getState().getDraftInput("draft:legacy-workspace-file")).toEqual({
text: "legacy text",
attachments: [
{ kind: "image", metadata: image },
createWorkspaceFileAttachment({ path: "src/app.ts" }),
],
});
});
it("clear resets text and attachments", async () => {
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
const image: AttachmentMetadata = {

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
@@ -9,7 +9,6 @@ import {
} from "@/hooks/use-agent-form-state";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import {
areAttachmentsEqual,
buildDraftAgentControls,
hasDraftContent,
resolveDraftKey,
@@ -22,6 +21,7 @@ import {
type ProviderSelectionState,
} from "@/provider-selection/provider-selection";
import { useDraftStore } from "@/stores/draft-store";
import { toDraftInputIfReady } from "@/stores/draft-store/state";
type AttachmentUpdater =
| UserComposerAttachment[]
@@ -57,6 +57,7 @@ export interface AgentInputDraft {
setAttachments: (updater: AttachmentUpdater) => void;
clear: (lifecycle: "sent" | "abandoned") => void;
isHydrated: boolean;
attachmentFocusRequestId: number;
composerState: DraftComposerState | null;
}
@@ -77,67 +78,66 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
}),
[formState.selectedServerId, input.draftKey],
);
const [text, setText] = useState("");
const [attachments, setAttachmentsState] = useState<UserComposerAttachment[]>([]);
const [isHydrated, setIsHydrated] = useState(false);
const draftGenerationRef = useRef(0);
const hydratedGenerationRef = useRef(0);
const draftRecord = useDraftStore((state) => state.drafts[draftKey]);
const draft = useMemo(() => toDraftInputIfReady(draftRecord), [draftRecord]);
const attachmentFocusRequestId = useDraftStore(
(state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0,
);
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
const text = draft?.text ?? "";
const attachments = draft?.attachments ?? [];
const isHydrated = hydratedDraftKey === draftKey;
const setAttachments = useCallback((updater: AttachmentUpdater) => {
setAttachmentsState((previousAttachments) => {
if (typeof updater === "function") {
return updater(previousAttachments);
const saveDraft = useCallback(
(
update: (draft: { text: string; attachments: UserComposerAttachment[] }) => {
text: string;
attachments: UserComposerAttachment[];
},
) => {
const store = useDraftStore.getState();
const current = store.getDraftInput(draftKey) ?? { text: "", attachments: [] };
const next = update(current);
if (!hasDraftContent(next)) {
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
return;
}
return updater;
});
}, []);
store.saveDraftInput({ draftKey, draft: next });
},
[draftKey],
);
const setText = useCallback(
(nextText: string) => {
saveDraft((current) => ({ ...current, text: nextText }));
},
[saveDraft],
);
const setAttachments = useCallback(
(updater: AttachmentUpdater) => {
saveDraft((current) => ({
...current,
attachments: typeof updater === "function" ? updater(current.attachments) : updater,
}));
},
[saveDraft],
);
const clear = useCallback(
(lifecycle: "sent" | "abandoned") => {
const store = useDraftStore.getState();
store.clearDraftInput({ draftKey, lifecycle });
const generation = store.beginDraftGeneration(draftKey);
draftGenerationRef.current = generation;
hydratedGenerationRef.current = generation;
setText("");
setAttachmentsState([]);
setIsHydrated(true);
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
},
[draftKey],
);
useEffect(() => {
const store = useDraftStore.getState();
const generation = store.beginDraftGeneration(draftKey);
draftGenerationRef.current = generation;
hydratedGenerationRef.current = 0;
setText("");
setAttachmentsState([]);
setIsHydrated(false);
let cancelled = false;
void (async () => {
const draft = await store.hydrateDraftInput({
draftKey,
});
if (cancelled) {
return;
await useDraftStore.getState().hydrateDraftInput({ draftKey });
if (!cancelled) {
setHydratedDraftKey(draftKey);
}
if (!useDraftStore.getState().isDraftGenerationCurrent({ draftKey, generation })) {
return;
}
if (draft) {
setText(draft.text);
setAttachmentsState(draft.attachments);
}
hydratedGenerationRef.current = generation;
setIsHydrated(true);
})();
return () => {
@@ -145,52 +145,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
};
}, [draftKey]);
useEffect(() => {
const currentGeneration = draftGenerationRef.current;
if (currentGeneration <= 0) {
return;
}
const store = useDraftStore.getState();
const isCurrentGeneration = store.isDraftGenerationCurrent({
draftKey,
generation: currentGeneration,
});
if (!isCurrentGeneration) {
return;
}
if (hydratedGenerationRef.current !== currentGeneration) {
return;
}
const existing = store.getDraftInput(draftKey);
const isSameDraft =
existing !== undefined &&
existing.text === text &&
areAttachmentsEqual({
left: existing.attachments,
right: attachments,
});
if (isSameDraft) {
return;
}
if (!hasDraftContent({ text, attachments })) {
if (existing) {
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
}
return;
}
store.saveDraftInput({
draftKey,
draft: {
text,
attachments,
},
});
}, [attachments, draftKey, text]);
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
useEffect(() => {
if (!composerOptions || !lockedWorkingDir) {
@@ -304,6 +258,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
setAttachments,
clear,
isHydrated,
attachmentFocusRequestId,
composerState,
};
}

View File

@@ -751,6 +751,7 @@ export function WorkspaceDraftAgentTab({
<Composer
agentId={tabId}
serverId={serverId}
workspaceId={workspaceId}
externalKeyboardShift
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
@@ -765,6 +766,7 @@ export function WorkspaceDraftAgentTab({
cwd={composerState.workingDir}
clearDraft={draftInput.clear}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
agentControls={composerAgentControls}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
import { resolveFocusedChatTarget } from "./focused-chat-target";
function layoutWithTarget(
target: import("@/stores/workspace-tabs-store").WorkspaceTab["target"],
): WorkspaceLayout {
return {
root: {
kind: "pane",
pane: {
id: "pane-1",
tabIds: ["focused-tab"],
focusedTabId: "focused-tab",
tabs: [{ tabId: "focused-tab", target, createdAt: 1 }],
},
},
focusedPaneId: "pane-1",
} as WorkspaceLayout;
}
describe("focused chat target", () => {
it("targets the focused agent draft", () => {
expect(
resolveFocusedChatTarget({
serverId: "server-1",
layout: layoutWithTarget({ kind: "agent", agentId: "agent-1" }),
}),
).toEqual({ tabId: "focused-tab", draftKey: "agent:server-1:agent-1" });
});
it("targets the focused unsent draft", () => {
expect(
resolveFocusedChatTarget({
serverId: "server-1",
layout: layoutWithTarget({ kind: "draft", draftId: "draft-1" }),
}),
).toEqual({ tabId: "focused-tab", draftKey: "draft:server-1:draft-1" });
});
it("does not guess when the focused tab is not a chat", () => {
expect(
resolveFocusedChatTarget({
serverId: "server-1",
layout: layoutWithTarget({ kind: "terminal", terminalId: "terminal-1" }),
}),
).toBeNull();
});
});

View File

@@ -0,0 +1,48 @@
import { buildDraftStoreKey } from "@/stores/draft-keys";
import {
collectAllTabs,
findPaneById,
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
export interface FocusedChatTarget {
tabId: string;
draftKey: string;
}
export function resolveFocusedChatTarget(input: {
serverId: string;
layout: WorkspaceLayout | undefined;
}): FocusedChatTarget | null {
if (!input.layout) {
return null;
}
const pane = findPaneById(input.layout.root, input.layout.focusedPaneId);
const focusedTabId = pane?.focusedTabId;
if (!focusedTabId) {
return null;
}
const tab = collectAllTabs(input.layout.root).find(
(candidate) => candidate.tabId === focusedTabId,
);
if (!tab) {
return null;
}
if (tab.target.kind === "agent") {
return {
tabId: tab.tabId,
draftKey: buildDraftStoreKey({ serverId: input.serverId, agentId: tab.target.agentId }),
};
}
if (tab.target.kind === "draft") {
return {
tabId: tab.tabId,
draftKey: buildDraftStoreKey({
serverId: input.serverId,
agentId: tab.tabId,
draftId: tab.target.draftId,
}),
};
}
return null;
}

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}

View File

@@ -8,7 +8,13 @@ import {
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DiffStat } from "@/components/diff-stat";
import { TreeChevron, TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
import { FILE_ACTIONS_MENU_WIDTH } from "@/components/file-actions-menu";
import {
TreeChevron,
TreeIndentGuides,
treeRowPaddingLeft,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives";
import { type Theme } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -80,7 +86,12 @@ export function DiffFolderRow({
</Text>
</View>
<View style={styles.right}>
<DiffStat additions={additions} deletions={deletions} />
<DiffStat
additions={additions}
deletions={deletions}
testID={testID ? `${testID}-stat` : undefined}
/>
<View style={styles.actionSlot} />
</View>
</Pressable>
</View>
@@ -94,8 +105,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
folderRow: {
flexDirection: "row",
alignItems: "center",
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
gap: theme.spacing[1],
minWidth: 0,
},
@@ -113,6 +124,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexDirection: "row",
alignItems: "center",
flexShrink: 0,
gap: theme.spacing[1],
},
actionSlot: {
width: FILE_ACTIONS_MENU_WIDTH,
},
folderName: {
fontSize: theme.fontSize.sm,

View File

@@ -57,7 +57,11 @@ import {
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items";
import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree";
import { DiffFolderRow } from "@/git/diff-folder-row";
import { TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
import {
TreeIndentGuides,
treeRowPaddingLeft,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives";
import { SvgXml } from "react-native-svg";
import { getFileIconSvg } from "@/components/material-file-icons";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
@@ -83,6 +87,10 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import * as Clipboard from "expo-clipboard";
import { FILE_ACTIONS_MENU_WIDTH, FileActionsMenu } from "@/components/file-actions-menu";
import { useFileDownload } from "@/hooks/use-file-download";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { GitActionsSplitButton } from "@/git/actions-split-button";
@@ -106,6 +114,7 @@ import {
hasVisibleDiffTokens,
} from "@/utils/diff-rendering";
import { isWeb, isNative } from "@/constants/platform";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
import {
buildWorkspaceAttachmentScopeKey,
useWorkspaceAttachmentsStore,
@@ -202,6 +211,7 @@ function HighlightedText({
interface DiffFileSectionProps {
file: ParsedDiffFile;
workspaceFileDragScope?: { serverId: string; workspaceId: string };
isExpanded: boolean;
/** Tree indentation level (0 on the flat/mobile path). */
depth?: number;
@@ -209,6 +219,10 @@ interface DiffFileSectionProps {
showDir?: boolean;
interactive?: boolean;
onToggle?: (path: string) => void;
onOpenFile?: (path: string) => void;
onAddToChat?: (path: string) => void;
onCopyPath?: (path: string) => void;
onDownload?: (path: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void;
testID?: string;
}
@@ -905,18 +919,31 @@ function SplitDiffColumn({
const DiffFileHeader = memo(function DiffFileHeader({
file,
workspaceFileDragScope,
isExpanded,
depth = 0,
showDir = true,
interactive = true,
onToggle,
onOpenFile,
onAddToChat,
onCopyPath,
onDownload,
onHeaderHeightChange,
testID,
}: DiffFileSectionProps) {
const { t } = useTranslation();
const dragSourceRef = useWorkspaceFileDragSource({
enabled: interactive,
disabled: file.isDeleted,
workspaceId: null,
path: file.path,
...workspaceFileDragScope,
});
const layoutYRef = useRef<number | null>(null);
const pressHandledRef = useRef(false);
const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null);
const [isActionsOpen, setIsActionsOpen] = useState(false);
const toggleExpanded = useCallback(() => {
if (!interactive) {
@@ -926,6 +953,31 @@ const DiffFileHeader = memo(function DiffFileHeader({
onToggle?.(file.path);
}, [file.path, interactive, onToggle]);
const handleOpenFile = useCallback(() => {
onOpenFile?.(file.path);
}, [file.path, onOpenFile]);
const handleAddToChat = useCallback(() => {
onAddToChat?.(file.path);
}, [file.path, onAddToChat]);
const handleCopyPath = useCallback(() => {
onCopyPath?.(file.path);
}, [file.path, onCopyPath]);
const handleDownload = useCallback(() => {
onDownload?.(file.path);
}, [file.path, onDownload]);
const handleContextMenu = useCallback(
(event: { preventDefault: () => void; stopPropagation: () => void }) => {
event.preventDefault();
event.stopPropagation();
setIsActionsOpen(true);
},
[],
);
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
layoutYRef.current = event.nativeEvent.layout.y;
@@ -983,7 +1035,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
const fileName = file.path.split("/").pop() ?? file.path;
const headerContent = (
<>
<View style={styles.fileHeaderLeft}>
<View ref={dragSourceRef} style={styles.fileHeaderLeft}>
{showDir ? null : (
<View style={styles.fileIcon}>
<SvgXml xml={getFileIconSvg(fileName)} width={16} height={16} />
@@ -1013,38 +1065,55 @@ const DiffFileHeader = memo(function DiffFileHeader({
)}
</View>
<View style={styles.fileHeaderRight}>
<DiffStat additions={file.additions} deletions={file.deletions} />
<DiffStat
additions={file.additions}
deletions={file.deletions}
testID={testID ? `${testID}-stat` : undefined}
/>
{interactive ? (
<FileActionsMenu
fileKind="file"
fileExists={!file.isDeleted}
onOpenFile={onOpenFile ? handleOpenFile : undefined}
onCopyPath={onCopyPath ? handleCopyPath : undefined}
onDownload={onDownload ? handleDownload : undefined}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
open={isActionsOpen}
onOpenChange={setIsActionsOpen}
accessibilityLabel={t("workspace.fileActions.moreActions")}
testIDPrefix={testID}
/>
) : null}
</View>
</>
);
let trigger: ReactElement;
if (!interactive) {
trigger = (
<View style={headerPressableStyle({ hovered: false, pressed: false })}>{headerContent}</View>
);
} else {
trigger = (
<Pressable
testID={testID ? `${testID}-toggle` : undefined}
style={headerPressableStyle}
// Android: prevent parent pan/scroll gestures from canceling the tap release.
cancelable={false}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={toggleExpanded}
// @ts-ignore - onContextMenu is web-only and not in RN types.
onContextMenu={handleContextMenu}
>
{headerContent}
</Pressable>
);
}
return (
<View style={containerStyle} onLayout={handleLayout} testID={testID}>
<TreeIndentGuides depth={depth} />
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
{interactive ? (
<Pressable
testID={testID ? `${testID}-toggle` : undefined}
style={headerPressableStyle}
// Android: prevent parent pan/scroll gestures from canceling the tap release.
cancelable={false}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={toggleExpanded}
>
{headerContent}
</Pressable>
) : (
<View style={headerPressableStyle({ hovered: false, pressed: false })}>
{headerContent}
</View>
)}
</TooltipTrigger>
<TooltipContent side="bottom" align="start" offset={6} maxWidth={520}>
<Text style={styles.tooltipText}>{file.path}</Text>
</TooltipContent>
</Tooltip>
{trigger}
</View>
);
});
@@ -1242,6 +1311,8 @@ interface GitDiffPaneProps {
workspaceId?: string | null;
cwd: string;
enabled?: boolean;
onOpenFile?: (path: string) => void;
onAddToChat?: (path: string) => void;
}
type PressableStyleFn = (
@@ -1639,6 +1710,11 @@ interface SharedDiffViewProps {
expandedPaths: string[];
collapsedFolders: string[];
reviewActions?: InlineReviewActions;
workspaceFileDragScope?: { serverId: string; workspaceId: string };
onOpenFile?: (path: string) => void;
onAddToChat?: (path: string) => void;
onCopyPath?: (path: string) => void;
onDownload?: (path: string) => void;
onExpandedPathsChange: (paths: string[]) => void;
onCollapsedFoldersChange: (paths: string[]) => void;
}
@@ -1671,6 +1747,12 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
const stickyHeaders = mode.kind === "working_tree";
const interactive = mode.kind === "working_tree";
const reviewActions = mode.kind === "working_tree" ? mode.reviewActions : undefined;
const onOpenFile = mode.kind === "working_tree" ? mode.onOpenFile : undefined;
const onAddToChat = mode.kind === "working_tree" ? mode.onAddToChat : undefined;
const workspaceFileDragScope =
mode.kind === "working_tree" ? mode.workspaceFileDragScope : undefined;
const onCopyPath = mode.kind === "working_tree" ? mode.onCopyPath : undefined;
const onDownload = mode.kind === "working_tree" ? mode.onDownload : undefined;
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]);
@@ -1917,11 +1999,16 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
return (
<DiffFileHeader
file={item.file}
workspaceFileDragScope={workspaceFileDragScope}
isExpanded={item.isExpanded}
depth={item.depth}
showDir={viewMode === "flat"}
interactive={interactive}
onToggle={interactive ? handleToggleExpanded : undefined}
onOpenFile={onOpenFile}
onAddToChat={onAddToChat}
onCopyPath={onCopyPath}
onDownload={onDownload}
onHeaderHeightChange={handleHeaderHeightChange}
testID={`diff-file-${item.fileIndex}`}
/>
@@ -1949,10 +2036,15 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
handleToggleFolder,
layout,
reviewActions,
workspaceFileDragScope,
textMetricsStyle,
viewMode,
wrapLines,
interactive,
onOpenFile,
onAddToChat,
onCopyPath,
onDownload,
],
);
@@ -1982,6 +2074,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
viewMode,
wrapLines,
reviewActions,
workspaceFileDragScope,
}),
[
expandedPathsArray,
@@ -1991,6 +2084,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
reviewActions,
typographyKey,
viewMode,
workspaceFileDragScope,
wrapLines,
],
);
@@ -2160,6 +2254,13 @@ function buildExpandAllButtonStyle(): PressableStyleFn {
];
}
function buildOverflowButtonStyle(): PressableStyleFn {
return ({ hovered, pressed }) => [
styles.overflowButton,
(Boolean(hovered) || pressed) && styles.toggleButtonSelected,
];
}
function buildToggleButtonStyle(
selected: boolean,
baseStyles: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
@@ -2174,7 +2275,14 @@ function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean
return input.paneEnabled && input.isGit;
}
export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPaneProps) {
export function GitDiffPane({
serverId,
workspaceId,
cwd,
enabled,
onOpenFile,
onAddToChat,
}: GitDiffPaneProps) {
const { settings: appSettings } = useAppSettings();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
@@ -2214,7 +2322,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const overflowToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []);
const toast = useToast();
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
@@ -2461,6 +2569,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
},
[setDiffCollapsedFoldersForWorkspace, workspaceStateKey],
);
const downloadFile = useFileDownload({ serverId, workspaceId, workspaceRoot: cwd });
const handleCopyPath = useCallback(
(path: string) => {
void Clipboard.setStringAsync(
buildAbsoluteExplorerPath({ workspaceRoot: cwd, entryPath: path }),
);
},
[cwd],
);
const handleDownloadPath = useCallback(
(path: string) => {
downloadFile({ fileName: path.split("/").pop() ?? path, path });
},
[downloadFile],
);
const workingTreeMode = useMemo(
() => ({
kind: "working_tree" as const,
@@ -2468,6 +2591,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
expandedPaths: stableExpandedPathsArray,
collapsedFolders: stableCollapsedFoldersArray,
reviewActions,
workspaceFileDragScope: workspaceId ? { serverId, workspaceId } : undefined,
onOpenFile,
onAddToChat,
onCopyPath: handleCopyPath,
onDownload: handleDownloadPath,
onExpandedPathsChange: handleExpandedPathsChange,
onCollapsedFoldersChange: handleCollapsedFoldersChange,
}),
@@ -2476,6 +2604,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
stableExpandedPathsArray,
stableCollapsedFoldersArray,
reviewActions,
serverId,
workspaceId,
onOpenFile,
onAddToChat,
handleCopyPath,
handleDownloadPath,
handleExpandedPathsChange,
handleCollapsedFoldersChange,
],
@@ -2742,6 +2876,18 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.base,
flexShrink: 0,
},
overflowButton: {
width: FILE_ACTIONS_MENU_WIDTH,
height: {
xs: 32,
sm: 32,
md: 24,
},
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.base,
flexShrink: 0,
},
actionErrorText: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[1],
@@ -2830,8 +2976,8 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
paddingLeft: theme.spacing[3],
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
gap: theme.spacing[1],
minWidth: 0,
zIndex: 2,

View File

@@ -0,0 +1,56 @@
import { useCallback, useMemo } from "react";
import { useHosts } from "@/runtime/host-runtime";
import { useDownloadStore } from "@/stores/download-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
interface UseFileDownloadParams {
serverId: string;
workspaceId?: string | null;
workspaceRoot: string;
}
/**
* Returns a stable callback that downloads a single workspace file by its
* workspace-relative path. Shared by the file explorer tree and the git diff
* pane so both surfaces download through the same host token + download-store
* pipeline instead of duplicating the plumbing.
*/
export function useFileDownload({
serverId,
workspaceId,
workspaceRoot,
}: UseFileDownloadParams): (input: { fileName: string; path: string }) => void {
const daemons = useHosts();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId],
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const workspaceScopeId = useMemo(
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId],
);
const { requestFileDownloadToken } = useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const startDownload = useDownloadStore((state) => state.startDownload);
return useCallback(
({ fileName, path }) => {
if (!workspaceScopeId) {
return;
}
void startDownload({
serverId,
scopeId: workspaceScopeId,
fileName,
path,
daemonProfile,
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
});
},
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
);
}

View File

@@ -366,6 +366,13 @@ export const ar: TranslationResources = {
copyBranchName: "نسخ اسم الفرع",
copied: "تم النسخ",
},
fileActions: {
openFile: "افتح الملف",
copyPath: "نسخ المسار",
download: "تحميل",
addToChat: "إضافة إلى الدردشة…",
moreActions: "المزيد من الإجراءات",
},
fileExplorer: {
sort: {
name: "اسم",
@@ -375,8 +382,6 @@ export const ar: TranslationResources = {
context: {
size: "مقاس",
modified: "معدل",
copyPath: "نسخ المسار",
download: "تحميل",
},
actions: {
back: "خلف",

View File

@@ -365,6 +365,13 @@ export const en = {
copyBranchName: "Copy branch name",
copied: "Copied",
},
fileActions: {
openFile: "Open file",
copyPath: "Copy path",
download: "Download",
addToChat: "Add to chat…",
moreActions: "More actions",
},
fileExplorer: {
sort: {
name: "Name",
@@ -374,8 +381,6 @@ export const en = {
context: {
size: "Size",
modified: "Modified",
copyPath: "Copy path",
download: "Download",
},
actions: {
back: "Back",

View File

@@ -370,6 +370,13 @@ export const es: TranslationResources = {
copyBranchName: "Copiar nombre de rama",
copied: "Copiado",
},
fileActions: {
openFile: "Abrir archivo",
copyPath: "Copiar ruta",
download: "Descargar",
addToChat: "Añadir al chat…",
moreActions: "Más acciones",
},
fileExplorer: {
sort: {
name: "Nombre",
@@ -379,8 +386,6 @@ export const es: TranslationResources = {
context: {
size: "Tamaño",
modified: "Modificado",
copyPath: "Copiar ruta",
download: "Descargar",
},
actions: {
back: "Atrás",

View File

@@ -370,6 +370,13 @@ export const fr: TranslationResources = {
copyBranchName: "Copier le nom de la branche",
copied: "Copié",
},
fileActions: {
openFile: "Ouvrir le fichier",
copyPath: "Copier le chemin",
download: "Télécharger",
addToChat: "Ajouter au chat…",
moreActions: "Plus de propositions",
},
fileExplorer: {
sort: {
name: "Nom",
@@ -379,8 +386,6 @@ export const fr: TranslationResources = {
context: {
size: "Taille",
modified: "Modifié",
copyPath: "Copier le chemin",
download: "Télécharger",
},
actions: {
back: "Dos",

View File

@@ -370,6 +370,13 @@ export const ja: TranslationResources = {
copyBranchName: "ブランチ名をコピー",
copied: "コピーしました",
},
fileActions: {
openFile: "ファイルを開く",
copyPath: "パスをコピー",
download: "ダウンロード",
addToChat: "チャットに追加…",
moreActions: "その他のアクション",
},
fileExplorer: {
sort: {
name: "名前",
@@ -379,8 +386,6 @@ export const ja: TranslationResources = {
context: {
size: "サイズ",
modified: "更新日時",
copyPath: "パスをコピー",
download: "ダウンロード",
},
actions: {
back: "戻る",

View File

@@ -370,6 +370,13 @@ export const ptBR: TranslationResources = {
copyBranchName: "Copiar nome da branch",
copied: "Copiado",
},
fileActions: {
openFile: "Abrir arquivo",
copyPath: "Copiar caminho",
download: "Baixar",
addToChat: "Adicionar ao chat…",
moreActions: "Mais ações",
},
fileExplorer: {
sort: {
name: "Nome",
@@ -379,8 +386,6 @@ export const ptBR: TranslationResources = {
context: {
size: "Tamanho",
modified: "Modificado",
copyPath: "Copiar caminho",
download: "Baixar",
},
actions: {
back: "Voltar",

View File

@@ -369,6 +369,13 @@ export const ru: TranslationResources = {
copyBranchName: "Копировать имя ветки",
copied: "Скопировано",
},
fileActions: {
openFile: "Открыть файл",
copyPath: "Копировать путь",
download: "Скачать",
addToChat: "Добавить в чат…",
moreActions: "Дополнительные действия",
},
fileExplorer: {
sort: {
name: "Имя",
@@ -378,8 +385,6 @@ export const ru: TranslationResources = {
context: {
size: "Размер",
modified: "Модифицированный",
copyPath: "Копировать путь",
download: "Скачать",
},
actions: {
back: "Назад",

View File

@@ -366,6 +366,13 @@ export const zhCN: TranslationResources = {
copyBranchName: "复制分支名称",
copied: "已复制",
},
fileActions: {
openFile: "打开文件",
copyPath: "复制路径",
download: "下载",
addToChat: "添加到聊天…",
moreActions: "更多操作",
},
fileExplorer: {
sort: {
name: "名称",
@@ -375,8 +382,6 @@ export const zhCN: TranslationResources = {
context: {
size: "大小",
modified: "修改时间",
copyPath: "复制路径",
download: "下载",
},
actions: {
back: "返回",

View File

@@ -1160,8 +1160,16 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
});
// Stabilize the agentInputDraft object identity so that memo(AgentComposerSection) can bail out
// when only toast state changes (which does not affect any draft field).
const { text, setText, attachments, setAttachments, clear, isHydrated, composerState } =
rawAgentInputDraft;
const {
text,
setText,
attachments,
setAttachments,
clear,
isHydrated,
attachmentFocusRequestId,
composerState,
} = rawAgentInputDraft;
const agentInputDraft = useMemo(
(): AgentInputDraft => ({
text,
@@ -1170,9 +1178,19 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
setAttachments,
clear,
isHydrated,
attachmentFocusRequestId,
composerState,
}),
[text, setText, attachments, setAttachments, clear, isHydrated, composerState],
[
text,
setText,
attachments,
setAttachments,
clear,
isHydrated,
attachmentFocusRequestId,
composerState,
],
);
const streamSection = (
<RenderProfile id={`AgentStreamSection:${agentId}`}>
@@ -1537,6 +1555,7 @@ function ActiveAgentComposer({
<Composer
agentId={agentId}
serverId={serverId}
workspaceId={workspaceId}
externalKeyboardShift
isPaneFocused={isPaneFocused}
value={agentInputDraft.text}
@@ -1548,6 +1567,7 @@ function ActiveAgentComposer({
cwd={cwd}
clearDraft={agentInputDraft.clear}
autoFocus={isPaneFocused}
autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)}
isSubmitLoading={isSubmitLoading}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}

View File

@@ -1,7 +1,8 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { AttachmentMetadata } from "@/attachments/types";
import type { AttachmentMetadata, WorkspaceFileComposerAttachment } from "@/attachments/types";
import { appendWorkspaceFileAttachment } from "@/attachments/workspace-file";
import {
garbageCollectAttachments,
persistAttachmentFromDataUrl,
@@ -39,16 +40,21 @@ interface DraftStoreActions {
draftKey: string;
lifecycle?: Exclude<DraftLifecycleState, "active">;
}) => void;
attachWorkspaceFile: (input: {
draftKey: string;
attachment: WorkspaceFileComposerAttachment;
}) => Promise<void>;
getCreateModalDraft: () => DraftInput | null;
saveCreateModalDraft: (draft: DraftInput | null) => void;
beginDraftGeneration: (draftKey: string) => number;
isDraftGenerationCurrent: (input: { draftKey: string; generation: number }) => boolean;
collectActiveAttachmentIds: () => string[];
}
type DraftStore = DraftStoreState & DraftStoreActions;
interface DraftStoreRuntimeState {
attachmentFocusRequestByDraftKey: Record<string, number>;
}
type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions;
const draftGenerations = new Map<string, number>();
let gcScheduled = false;
const draftPersistStorage = createDraftPersistStorage(
createJSONStorage<DraftStoreState>(() => AsyncStorage),
@@ -237,6 +243,7 @@ export const useDraftStore = create<DraftStore>()(
(set, get) => ({
drafts: {},
createModalDraft: null,
attachmentFocusRequestByDraftKey: {},
getDraftInput: (draftKey) => {
const record = get().drafts[draftKey];
@@ -344,7 +351,32 @@ export const useDraftStore = create<DraftStore>()(
return { drafts: nextDrafts };
});
draftGenerations.delete(draftKey);
scheduleAttachmentGc();
},
attachWorkspaceFile: async ({ draftKey, attachment }) => {
await get().hydrateDraftInput({ draftKey });
set((state) => {
const existing = state.drafts[draftKey];
const draft = toDraftInputIfReady(existing) ?? { text: "", attachments: [] };
return {
drafts: {
...state.drafts,
[draftKey]: createDraftRecord({
draft: {
...draft,
attachments: appendWorkspaceFileAttachment(draft.attachments, attachment),
},
lifecycle: "active",
previousVersion: existing?.version,
}),
},
attachmentFocusRequestByDraftKey: {
...state.attachmentFocusRequestByDraftKey,
[draftKey]: (state.attachmentFocusRequestByDraftKey[draftKey] ?? 0) + 1,
},
};
});
scheduleAttachmentGc();
},
@@ -369,16 +401,6 @@ export const useDraftStore = create<DraftStore>()(
scheduleAttachmentGc();
},
beginDraftGeneration: (draftKey) => {
const next = (draftGenerations.get(draftKey) ?? 0) + 1;
draftGenerations.set(draftKey, next);
return next;
},
isDraftGenerationCurrent: ({ draftKey, generation }) => {
return (draftGenerations.get(draftKey) ?? 0) === generation;
},
collectActiveAttachmentIds: () => {
return Array.from(collectReferencedAttachmentIdsFromState(get()).values());
},
@@ -387,6 +409,7 @@ export const useDraftStore = create<DraftStore>()(
name: "paseo-drafts",
version: DRAFT_STORE_VERSION,
storage: draftPersistStorage,
partialize: ({ drafts, createModalDraft }) => ({ drafts, createModalDraft }),
migrate: (persistedState) => {
return migratePersistedState(persistedState, {
migrateLegacyImages,

View File

@@ -71,6 +71,46 @@ describe("draft-store lifecycle", () => {
});
describe("draft-store normalization", () => {
it("preserves uploaded file attachments when hydrating a draft", () => {
const attachment = {
kind: "file" as const,
attachment: {
type: "uploaded_file" as const,
id: "file-1",
fileName: "context.json",
mimeType: "application/json",
size: 42,
path: "/tmp/context.json",
},
};
expect(
toDraftInputIfReady({
input: { text: "Review this", attachments: [attachment] },
lifecycle: "active",
updatedAt: 1,
version: 1,
}),
).toEqual({ text: "Review this", attachments: [attachment] });
});
it("preserves workspace file selections when hydrating a draft", () => {
const attachment = {
kind: "workspace_file" as const,
path: "src/app.ts",
selection: { kind: "line_range" as const, startLine: 12, endLine: 24 },
};
expect(
toDraftInputIfReady({
input: { text: "Review this", attachments: [attachment] },
lifecycle: "active",
updatedAt: 1,
version: 1,
}),
).toEqual({ text: "Review this", attachments: [attachment] });
});
it("preserves New Workspace picker ownership when hydrating a draft", () => {
const pickerAttachment = {
kind: "github_pr" as const,

View File

@@ -3,7 +3,12 @@ import {
type AttachmentMetadata,
type UserComposerAttachment,
} from "@/attachments/types";
import { ForgeSearchItemSchema, GitHubSearchItemSchema } from "@getpaseo/protocol/messages";
import { isWorkspaceFileComposerAttachment } from "@/attachments/workspace-file";
import {
ForgeSearchItemSchema,
GitHubSearchItemSchema,
UploadedFileAttachmentSchema,
} from "@getpaseo/protocol/messages";
export const DRAFT_STORE_VERSION = 5;
export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
@@ -83,6 +88,12 @@ export function isUserComposerAttachment(value: unknown): value is UserComposerA
const metadata = record.metadata;
return isAttachmentMetadata(metadata);
}
if (record.kind === "workspace_file") {
return isWorkspaceFileComposerAttachment(value);
}
if (record.kind === "file") {
return UploadedFileAttachmentSchema.safeParse(record.attachment).success;
}
if (
record.kind !== "forge_issue" &&
record.kind !== "forge_change_request" &&
@@ -113,6 +124,13 @@ export function normalizeComposerAttachment(
metadata: normalizeAttachmentMetadata(attachment.metadata),
};
}
if (attachment.kind === "workspace_file") {
return {
kind: "workspace_file",
path: attachment.path.trim().replace(/^\.\//, ""),
selection: attachment.selection,
};
}
if (attachment.kind === "github_pr") {
const item =
(attachment.item as { kind: string }).kind === "pr"

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { applyFileMentionReplacement, findActiveFileMention } from "./file-mention-autocomplete";
import {
applyFileMentionReplacement,
findActiveFileMention,
formatQuotedFileMentionPath,
} from "./file-mention-autocomplete";
describe("findActiveFileMention", () => {
it("detects mentions at the start of input", () => {
@@ -46,6 +50,14 @@ describe("findActiveFileMention", () => {
});
});
describe("formatQuotedFileMentionPath", () => {
it("quotes workspace-relative paths using file mention escaping", () => {
expect(formatQuotedFileMentionPath('src/changed "file".ts')).toBe(
'"src/changed \\"file\\".ts"',
);
});
});
describe("applyFileMentionReplacement", () => {
it("replaces only the active @query segment with a quoted relative path", () => {
const text = "open @src/com next";

View File

@@ -40,9 +40,13 @@ export function findActiveFileMention(input: FindActiveFileMentionInput): FileMe
return null;
}
export function formatQuotedFileMentionPath(relativePath: string): string {
const safePath = relativePath.replace(/"/g, '\\"');
return `"${safePath}"`;
}
export function applyFileMentionReplacement(input: ApplyFileMentionReplacementInput): string {
const safePath = input.relativePath.replace(/"/g, '\\"');
const before = input.text.slice(0, input.mention.start);
const after = input.text.slice(input.mention.end);
return `${before}"${safePath}"${after}`;
return `${before}${formatQuotedFileMentionPath(input.relativePath)}${after}`;
}