feat: add workspace files to chat from Files and Changes
* feat(app): open changed files from context menu * fix(app): preserve changed-file row behavior * feat(app): add changed files to open chats * feat(app): share file actions across explorer and changes * feat(app): attach workspace files to focused chat * feat(app): drag workspace files into chat * refactor(app): use file actions menu for changed files * fix(app): open changed-file actions on right click * test(app): cover direct file attachment flow * fix(app): enable add to chat on native * fix(app): align workspace file pane rows * refactor(app): simplify workspace file attachments * fix(app): preserve uploaded file draft attachments --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
42ee5a5949
commit
bd937850b3
@@ -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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { RefCallback } from "react";
|
||||
import type { View } from "react-native";
|
||||
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
|
||||
|
||||
export function useWorkspaceFileDragSource(
|
||||
_input: WorkspaceFileDragSourceInput,
|
||||
): RefCallback<View> | undefined {
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect, useState, type RefCallback } from "react";
|
||||
import type { View } from "react-native";
|
||||
import { createWorkspaceFileAttachment } from "./workspace-file";
|
||||
import { serializeWorkspaceFileDragPayload, WORKSPACE_FILE_DRAG_MIME } from "./workspace-file-drag";
|
||||
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
|
||||
|
||||
export function useWorkspaceFileDragSource({
|
||||
enabled,
|
||||
disabled = false,
|
||||
serverId,
|
||||
workspaceId,
|
||||
path,
|
||||
selection,
|
||||
}: WorkspaceFileDragSourceInput): RefCallback<View> {
|
||||
const [element, setElement] = useState<HTMLElement | null>(null);
|
||||
const dragSourceRef = useCallback((node: View | null) => {
|
||||
setElement(node as unknown as HTMLElement | null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!element || !enabled || disabled || !serverId || !workspaceId) {
|
||||
if (element) {
|
||||
element.draggable = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceServerId = serverId;
|
||||
const sourceWorkspaceId = workspaceId;
|
||||
element.draggable = true;
|
||||
function handleDragStart(event: DragEvent) {
|
||||
if (!event.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
event.dataTransfer.setData(
|
||||
WORKSPACE_FILE_DRAG_MIME,
|
||||
serializeWorkspaceFileDragPayload({
|
||||
version: 1,
|
||||
serverId: sourceServerId,
|
||||
workspaceId: sourceWorkspaceId,
|
||||
attachment: createWorkspaceFileAttachment({ path, selection }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
element.addEventListener("dragstart", handleDragStart);
|
||||
return () => {
|
||||
element.draggable = false;
|
||||
element.removeEventListener("dragstart", handleDragStart);
|
||||
};
|
||||
}, [disabled, element, enabled, path, selection, serverId, workspaceId]);
|
||||
|
||||
return dragSourceRef;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { WorkspaceFileComposerAttachment } from "./types";
|
||||
import { isWorkspaceFileComposerAttachment } from "./workspace-file";
|
||||
|
||||
export const WORKSPACE_FILE_DRAG_MIME = "application/x-paseo-workspace-file+json";
|
||||
|
||||
export interface WorkspaceFileDragPayload {
|
||||
version: 1;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
attachment: WorkspaceFileComposerAttachment;
|
||||
}
|
||||
|
||||
export function serializeWorkspaceFileDragPayload(payload: WorkspaceFileDragPayload): string {
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
export function parseWorkspaceFileDragPayload(serialized: string): WorkspaceFileDragPayload | null {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(serialized);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
record.version !== 1 ||
|
||||
typeof record.serverId !== "string" ||
|
||||
record.serverId.length === 0 ||
|
||||
typeof record.workspaceId !== "string" ||
|
||||
record.workspaceId.length === 0 ||
|
||||
!isWorkspaceFileComposerAttachment(record.attachment)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
serverId: record.serverId,
|
||||
workspaceId: record.workspaceId,
|
||||
attachment: record.attachment,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWorkspaceFileDrop(input: {
|
||||
payload: WorkspaceFileDragPayload;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): WorkspaceFileComposerAttachment | null {
|
||||
return input.payload.serverId === input.serverId &&
|
||||
input.payload.workspaceId === input.workspaceId
|
||||
? input.payload.attachment
|
||||
: null;
|
||||
}
|
||||
@@ -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)],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
UserComposerAttachment,
|
||||
WorkspaceFileComposerAttachment,
|
||||
WorkspaceFileSelection,
|
||||
} from "./types";
|
||||
|
||||
interface CreateWorkspaceFileAttachmentInput {
|
||||
path: string;
|
||||
selection?: WorkspaceFileSelection;
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.trim().replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
export function isWorkspaceFileComposerAttachment(
|
||||
value: unknown,
|
||||
): value is WorkspaceFileComposerAttachment {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
record.kind !== "workspace_file" ||
|
||||
typeof record.path !== "string" ||
|
||||
record.path.trim().length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const selection = record.selection;
|
||||
if (!selection || typeof selection !== "object") {
|
||||
return false;
|
||||
}
|
||||
const { kind, startLine, endLine } = selection as Record<string, unknown>;
|
||||
if (kind === "whole_file") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
kind === "line_range" &&
|
||||
typeof startLine === "number" &&
|
||||
Number.isInteger(startLine) &&
|
||||
typeof endLine === "number" &&
|
||||
Number.isInteger(endLine) &&
|
||||
startLine > 0 &&
|
||||
endLine >= startLine
|
||||
);
|
||||
}
|
||||
|
||||
export function createWorkspaceFileAttachment({
|
||||
path,
|
||||
selection = { kind: "whole_file" },
|
||||
}: CreateWorkspaceFileAttachmentInput): WorkspaceFileComposerAttachment {
|
||||
return {
|
||||
kind: "workspace_file",
|
||||
path: normalizePath(path),
|
||||
selection,
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkspaceFileAttachmentKey(attachment: WorkspaceFileComposerAttachment): string {
|
||||
const selection = attachment.selection;
|
||||
const selectionKey =
|
||||
selection.kind === "whole_file"
|
||||
? selection.kind
|
||||
: `${selection.kind}:${selection.startLine}-${selection.endLine}`;
|
||||
return `${normalizePath(attachment.path)}:${selectionKey}`;
|
||||
}
|
||||
|
||||
export function appendWorkspaceFileAttachment(
|
||||
current: UserComposerAttachment[],
|
||||
attachment: WorkspaceFileComposerAttachment,
|
||||
): UserComposerAttachment[] {
|
||||
const attachmentKey = getWorkspaceFileAttachmentKey(attachment);
|
||||
const alreadyAttached = current.some(
|
||||
(candidate) =>
|
||||
candidate.kind === "workspace_file" &&
|
||||
getWorkspaceFileAttachmentKey(candidate) === attachmentKey,
|
||||
);
|
||||
return alreadyAttached ? current : [...current, attachment];
|
||||
}
|
||||
|
||||
export function workspaceFileAttachmentToAgentAttachment(
|
||||
attachment: WorkspaceFileComposerAttachment,
|
||||
): Extract<AgentAttachment, { type: "text" }> {
|
||||
const fileName = attachment.path.split("/").pop() ?? attachment.path;
|
||||
const lines =
|
||||
attachment.selection.kind === "line_range"
|
||||
? `\nLines: ${attachment.selection.startLine}-${attachment.selection.endLine}`
|
||||
: "";
|
||||
return {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: fileName,
|
||||
text: `Workspace file: ${attachment.path}${lines}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkspaceFileAttachmentSubtitle(
|
||||
attachment: WorkspaceFileComposerAttachment,
|
||||
): string {
|
||||
if (attachment.selection.kind === "whole_file") {
|
||||
return attachment.path;
|
||||
}
|
||||
return `${attachment.path} · ${attachment.selection.startLine}-${attachment.selection.endLine}`;
|
||||
}
|
||||
Reference in New Issue
Block a user