From bd937850b3fd2db5c698e74ebc55ce14a90c1b4b Mon Sep 17 00:00:00 2001 From: nikuscs Date: Wed, 22 Jul 2026 14:58:24 +0100 Subject: [PATCH] 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 --- .../app/e2e/add-changed-file-to-chat.spec.ts | 49 ++++ packages/app/e2e/diff-row-alignment.spec.ts | 102 ++++++- packages/app/src/attachments/types.ts | 11 + .../use-workspace-file-drag-source.ts | 9 + .../use-workspace-file-drag-source.web.ts | 54 ++++ .../workspace-file-drag-source.types.ts | 10 + .../attachments/workspace-file-drag.test.ts | 62 ++++ .../src/attachments/workspace-file-drag.ts | 55 ++++ .../src/attachments/workspace-file.test.ts | 71 +++++ .../app/src/attachments/workspace-file.ts | 106 +++++++ packages/app/src/components/diff-stat.tsx | 5 +- .../app/src/components/explorer-sidebar.tsx | 93 +++++- .../app/src/components/file-actions-menu.tsx | 185 ++++++++++++ .../app/src/components/file-drop/types.ts | 2 + .../file-drop/use-drop-listeners.ts | 24 +- .../app/src/components/file-explorer-pane.tsx | 265 ++++++++---------- .../app/src/components/tree-primitives.tsx | 1 + packages/app/src/composer/actions.ts | 2 +- .../app/src/composer/attachments/submit.ts | 6 + .../composer/draft/input-draft.live.test.tsx | 40 ++- .../app/src/composer/draft/input-draft.ts | 147 ++++------ .../app/src/composer/draft/workspace-tab.tsx | 2 + .../src/composer/focused-chat-target.test.ts | 49 ++++ .../app/src/composer/focused-chat-target.ts | 48 ++++ packages/app/src/composer/index.tsx | 87 +++++- packages/app/src/git/diff-folder-row.tsx | 23 +- packages/app/src/git/diff-pane.tsx | 208 ++++++++++++-- packages/app/src/hooks/use-file-download.ts | 56 ++++ packages/app/src/i18n/resources/ar.ts | 9 +- packages/app/src/i18n/resources/en.ts | 9 +- packages/app/src/i18n/resources/es.ts | 9 +- packages/app/src/i18n/resources/fr.ts | 9 +- packages/app/src/i18n/resources/ja.ts | 9 +- packages/app/src/i18n/resources/pt-BR.ts | 9 +- packages/app/src/i18n/resources/ru.ts | 9 +- packages/app/src/i18n/resources/zh-CN.ts | 9 +- packages/app/src/panels/agent-panel.tsx | 26 +- packages/app/src/stores/draft-store/index.ts | 55 ++-- .../app/src/stores/draft-store/state.test.ts | 40 +++ packages/app/src/stores/draft-store/state.ts | 20 +- .../utils/file-mention-autocomplete.test.ts | 14 +- .../src/utils/file-mention-autocomplete.ts | 8 +- 42 files changed, 1665 insertions(+), 342 deletions(-) create mode 100644 packages/app/e2e/add-changed-file-to-chat.spec.ts create mode 100644 packages/app/src/attachments/use-workspace-file-drag-source.ts create mode 100644 packages/app/src/attachments/use-workspace-file-drag-source.web.ts create mode 100644 packages/app/src/attachments/workspace-file-drag-source.types.ts create mode 100644 packages/app/src/attachments/workspace-file-drag.test.ts create mode 100644 packages/app/src/attachments/workspace-file-drag.ts create mode 100644 packages/app/src/attachments/workspace-file.test.ts create mode 100644 packages/app/src/attachments/workspace-file.ts create mode 100644 packages/app/src/components/file-actions-menu.tsx create mode 100644 packages/app/src/composer/focused-chat-target.test.ts create mode 100644 packages/app/src/composer/focused-chat-target.ts create mode 100644 packages/app/src/hooks/use-file-download.ts diff --git a/packages/app/e2e/add-changed-file-to-chat.spec.ts b/packages/app/e2e/add-changed-file-to-chat.spec.ts new file mode 100644 index 000000000..6b5f55001 --- /dev/null +++ b/packages/app/e2e/add-changed-file-to-chat.spec.ts @@ -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(); + } +}); diff --git a/packages/app/e2e/diff-row-alignment.spec.ts b/packages/app/e2e/diff-row-alignment.spec.ts index 60e1e91b4..fecb6529f 100644 --- a/packages/app/e2e/diff-row-alignment.spec.ts +++ b/packages/app/e2e/diff-row-alignment.spec.ts @@ -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; } @@ -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 { - const repo = await createTempGitRepo("diff-row-alignment-", { - files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }], - }); +async function createWorkspaceWithMountedTabDiff( + options: WorkspaceFixtureOptions = {}, +): Promise { + 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 { }); 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 { await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 }); } +async function readSvgRight(page: Page, testID: string): Promise { + 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 { await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 }); await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({ diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index b862a543a..7e53567b2 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -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 diff --git a/packages/app/src/attachments/use-workspace-file-drag-source.ts b/packages/app/src/attachments/use-workspace-file-drag-source.ts new file mode 100644 index 000000000..1f8615d6c --- /dev/null +++ b/packages/app/src/attachments/use-workspace-file-drag-source.ts @@ -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 | undefined { + return undefined; +} diff --git a/packages/app/src/attachments/use-workspace-file-drag-source.web.ts b/packages/app/src/attachments/use-workspace-file-drag-source.web.ts new file mode 100644 index 000000000..e8de9ec24 --- /dev/null +++ b/packages/app/src/attachments/use-workspace-file-drag-source.web.ts @@ -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 { + const [element, setElement] = useState(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; +} diff --git a/packages/app/src/attachments/workspace-file-drag-source.types.ts b/packages/app/src/attachments/workspace-file-drag-source.types.ts new file mode 100644 index 000000000..04b84b7b8 --- /dev/null +++ b/packages/app/src/attachments/workspace-file-drag-source.types.ts @@ -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; +} diff --git a/packages/app/src/attachments/workspace-file-drag.test.ts b/packages/app/src/attachments/workspace-file-drag.test.ts new file mode 100644 index 000000000..9c4597f0d --- /dev/null +++ b/packages/app/src/attachments/workspace-file-drag.test.ts @@ -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(); + }); +}); diff --git a/packages/app/src/attachments/workspace-file-drag.ts b/packages/app/src/attachments/workspace-file-drag.ts new file mode 100644 index 000000000..50904f98f --- /dev/null +++ b/packages/app/src/attachments/workspace-file-drag.ts @@ -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; + 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; +} diff --git a/packages/app/src/attachments/workspace-file.test.ts b/packages/app/src/attachments/workspace-file.test.ts new file mode 100644 index 000000000..44cc1e7f8 --- /dev/null +++ b/packages/app/src/attachments/workspace-file.test.ts @@ -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)], + }); + }); +}); diff --git a/packages/app/src/attachments/workspace-file.ts b/packages/app/src/attachments/workspace-file.ts new file mode 100644 index 000000000..f1aa24ea9 --- /dev/null +++ b/packages/app/src/attachments/workspace-file.ts @@ -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; + 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; + 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 { + 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}`; +} diff --git a/packages/app/src/components/diff-stat.tsx b/packages/app/src/components/diff-stat.tsx index 154a2162a..d22879322 100644 --- a/packages/app/src/components/diff-stat.tsx +++ b/packages/app/src/components/diff-stat.tsx @@ -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 ( - + +{formatDiffCount(additions)} -{formatDiffCount(deletions)} diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 2d7209ae9..c233f3582 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -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): void {} @@ -386,15 +393,16 @@ function ExplorerSidebarContent({ {/* Content based on active tab */} {resolvedTab === "changes" && ( - )} {resolvedTab === "files" && ( - ) { + 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 ( + + ); +} + +function FilesPane({ + serverId, + workspaceId, + workspaceRoot, + onOpenFile, +}: Pick) { + const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId }); + return ( + + ); +} + interface PrTabContentProps { serverId: string; cwd: string; diff --git a/packages/app/src/components/file-actions-menu.tsx b/packages/app/src/components/file-actions-menu.tsx new file mode 100644 index 000000000..061a47b2e --- /dev/null +++ b/packages/app/src/components/file-actions-menu.tsx @@ -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(() => { + 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 ( + + + + + + {header ? ( + <> + {header} + + + ) : null} + {actions.map((action) => ( + + ))} + + + ); +} + +function FileActionMenuItem({ action }: { action: FileAction }): ReactElement { + const Icon = action.icon; + const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]); + const leading = useMemo( + () => , + [ThemedIcon], + ); + return ( + + {action.label} + + ); +} + +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, + }, +})); diff --git a/packages/app/src/components/file-drop/types.ts b/packages/app/src/components/file-drop/types.ts index 11b44773d..89bbbca7c 100644 --- a/packages/app/src/components/file-drop/types.ts +++ b/packages/app/src/components/file-drop/types.ts @@ -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; } diff --git a/packages/app/src/components/file-drop/use-drop-listeners.ts b/packages/app/src/components/file-drop/use-drop-listeners.ts index b8411b50c..6262109e7 100644 --- a/packages/app/src/components/file-drop/use-drop-listeners.ts +++ b/packages/app/src/components/file-drop/use-drop-listeners.ts @@ -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", diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 206474503..d0f1c227e 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -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( - () => , - [theme.colors.foregroundMuted], - ); - const downloadLeading = useMemo( - () => , - [theme.colors.foregroundMuted], + const handleAddToChat = useCallback(() => { + onAddToChat?.(entry.path); + }, [onAddToChat, entry.path]); + + const metaHeader = useMemo( + () => ( + + + + {t("workspace.fileExplorer.context.size")} + + + {formatFileSize({ size: entry.size })} + + + + + {t("workspace.fileExplorer.context.modified")} + + + {formatTimeAgo(new Date(entry.modifiedAt))} + + + + ), + [entry.modifiedAt, entry.size, t], ); return ( - + - + {(() => { if (!isDirectory) { @@ -163,40 +171,15 @@ function TreeRowItem({ {entry.name} - - - - - - - - - {t("workspace.fileExplorer.context.size")} - - - {formatFileSize({ size: entry.size })} - - - - - {t("workspace.fileExplorer.context.modified")} - - - {formatTimeAgo(new Date(entry.modifiedAt))} - - - - - - {t("workspace.fileExplorer.context.copyPath")} - - {entry.kind === "file" ? ( - - {t("workspace.fileExplorer.context.download")} - - ) : null} - - + ); } @@ -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) => ( ), [ @@ -435,6 +414,9 @@ export function FileExplorerPane({ handleDownloadEntry, isDirectoryLoading, selectedEntryPath, + onAddToChat, + serverId, + workspaceId, ], ); @@ -576,8 +558,14 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { return ( - - {currentSortLabel} + + + {currentSortLabel} + @@ -588,6 +576,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { accessibilityRole="button" accessibilityLabel={hiddenFilesToggleAccessibilityLabel} accessibilityState={hiddenFilesToggleAccessibilityState} + testID="files-hidden-toggle" > {showHiddenFiles ? ( @@ -606,6 +595,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { ? t("workspace.fileExplorer.actions.refreshing") : t("workspace.fileExplorer.actions.refresh") } + testID="files-refresh" > {isRefreshFetching ? ( @@ -778,39 +768,6 @@ function resolveTreeRows({ }); } -type StartDownloadFn = ReturnType["startDownload"]; -type StartDownloadParams = Parameters[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; -}): 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; expandedPaths: Set; selectedEntryPath: string | null; @@ -865,6 +827,7 @@ function TreeRowDispatcher({ onEntryPress: (entry: ExplorerEntry) => void; onCopyPath: (path: string) => void | Promise; 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 ( ); } @@ -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], }, diff --git a/packages/app/src/components/tree-primitives.tsx b/packages/app/src/components/tree-primitives.tsx index 11949c140..920243425 100644 --- a/packages/app/src/components/tree-primitives.tsx +++ b/packages/app/src/components/tree-primitives.tsx @@ -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. */ diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 8fd16f37c..382d9f775 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -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)) { diff --git a/packages/app/src/composer/attachments/submit.ts b/packages/app/src/composer/attachments/submit.ts index f814d06c0..c1c15bb80 100644 --- a/packages/app/src/composer/attachments/submit.ts +++ b/packages/app/src/composer/attachments/submit.ts @@ -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); diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 64b684ea0..ff3797466 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -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(), @@ -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 | null = null; const image: AttachmentMetadata = { diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 2309ad445..995a5382f 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -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([]); - 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(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, }; } diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 7ac294915..d75e9e940 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -751,6 +751,7 @@ export function WorkspaceDraftAgentTab({ { + 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(); + }); +}); diff --git a/packages/app/src/composer/focused-chat-target.ts b/packages/app/src/composer/focused-chat-target.ts new file mode 100644 index 000000000..479fcf088 --- /dev/null +++ b/packages/app/src/composer/focused-chat-target.ts @@ -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; +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index bd7787760..a920cbc99 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -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 ( + + ); + } 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 ( + + + + ); +} + 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; onClientSlashCommand?: (command: ClientSlashCommand) => Promise; @@ -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} diff --git a/packages/app/src/git/diff-folder-row.tsx b/packages/app/src/git/diff-folder-row.tsx index cd0cbe5f5..101d34e2a 100644 --- a/packages/app/src/git/diff-folder-row.tsx +++ b/packages/app/src/git/diff-folder-row.tsx @@ -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({ - + + @@ -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, diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 5a1d2e007..2048e7742 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -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(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 = ( <> - + {showDir ? null : ( @@ -1013,38 +1065,55 @@ const DiffFileHeader = memo(function DiffFileHeader({ )} - + + {interactive ? ( + + ) : null} ); + let trigger: ReactElement; + if (!interactive) { + trigger = ( + {headerContent} + ); + } else { + trigger = ( + + {headerContent} + + ); + } return ( - - - {interactive ? ( - - {headerContent} - - ) : ( - - {headerContent} - - )} - - - {file.path} - - + {trigger} ); }); @@ -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 ( @@ -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 | StyleProp[], @@ -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, diff --git a/packages/app/src/hooks/use-file-download.ts b/packages/app/src/hooks/use-file-download.ts new file mode 100644 index 000000000..91a88170b --- /dev/null +++ b/packages/app/src/hooks/use-file-download.ts @@ -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], + ); +} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b82eef681..87dd6f583 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -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: "خلف", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index cb3e413d7..db5921e04 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 2a2187ed9..4edc7bce9 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 81d372679..2c7e67bd0 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 6be03847c..2029d9da0 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -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: "戻る", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index d83390d32..61030df17 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 07453f949..179ef7025 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -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: "Назад", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index e845d819f..fbb97630f 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -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: "返回", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index ed3a1689a..e34c6aa4f 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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 = ( @@ -1537,6 +1555,7 @@ function ActiveAgentComposer({ ; }) => void; + attachWorkspaceFile: (input: { + draftKey: string; + attachment: WorkspaceFileComposerAttachment; + }) => Promise; 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; +} + +type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions; -const draftGenerations = new Map(); let gcScheduled = false; const draftPersistStorage = createDraftPersistStorage( createJSONStorage(() => AsyncStorage), @@ -237,6 +243,7 @@ export const useDraftStore = create()( (set, get) => ({ drafts: {}, createModalDraft: null, + attachmentFocusRequestByDraftKey: {}, getDraftInput: (draftKey) => { const record = get().drafts[draftKey]; @@ -344,7 +351,32 @@ export const useDraftStore = create()( 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()( 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()( name: "paseo-drafts", version: DRAFT_STORE_VERSION, storage: draftPersistStorage, + partialize: ({ drafts, createModalDraft }) => ({ drafts, createModalDraft }), migrate: (persistedState) => { return migratePersistedState(persistedState, { migrateLegacyImages, diff --git a/packages/app/src/stores/draft-store/state.test.ts b/packages/app/src/stores/draft-store/state.test.ts index c4ff8f664..56807d085 100644 --- a/packages/app/src/stores/draft-store/state.test.ts +++ b/packages/app/src/stores/draft-store/state.test.ts @@ -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, diff --git a/packages/app/src/stores/draft-store/state.ts b/packages/app/src/stores/draft-store/state.ts index 1515a537f..a2314ec3a 100644 --- a/packages/app/src/stores/draft-store/state.ts +++ b/packages/app/src/stores/draft-store/state.ts @@ -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" diff --git a/packages/app/src/utils/file-mention-autocomplete.test.ts b/packages/app/src/utils/file-mention-autocomplete.test.ts index e5e2ec506..4f818e25e 100644 --- a/packages/app/src/utils/file-mention-autocomplete.test.ts +++ b/packages/app/src/utils/file-mention-autocomplete.test.ts @@ -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"; diff --git a/packages/app/src/utils/file-mention-autocomplete.ts b/packages/app/src/utils/file-mention-autocomplete.ts index 748a13458..d19424cfe 100644 --- a/packages/app/src/utils/file-mention-autocomplete.ts +++ b/packages/app/src/utils/file-mention-autocomplete.ts @@ -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}`; }