feat: add workspace files to chat from Files and Changes

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

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

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

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

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

* feat(app): drag workspace files into chat

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

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

* test(app): cover direct file attachment flow

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

* fix(app): align workspace file pane rows

* refactor(app): simplify workspace file attachments

* fix(app): preserve uploaded file draft attachments

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
nikuscs
2026-07-22 15:58:24 +02:00
committed by GitHub
co-authored by Mohamed Boudra
parent 42ee5a5949
commit bd937850b3
42 changed files with 1665 additions and 342 deletions
+39 -16
View File
@@ -1,7 +1,8 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { AttachmentMetadata } from "@/attachments/types";
import type { AttachmentMetadata, WorkspaceFileComposerAttachment } from "@/attachments/types";
import { appendWorkspaceFileAttachment } from "@/attachments/workspace-file";
import {
garbageCollectAttachments,
persistAttachmentFromDataUrl,
@@ -39,16 +40,21 @@ interface DraftStoreActions {
draftKey: string;
lifecycle?: Exclude<DraftLifecycleState, "active">;
}) => void;
attachWorkspaceFile: (input: {
draftKey: string;
attachment: WorkspaceFileComposerAttachment;
}) => Promise<void>;
getCreateModalDraft: () => DraftInput | null;
saveCreateModalDraft: (draft: DraftInput | null) => void;
beginDraftGeneration: (draftKey: string) => number;
isDraftGenerationCurrent: (input: { draftKey: string; generation: number }) => boolean;
collectActiveAttachmentIds: () => string[];
}
type DraftStore = DraftStoreState & DraftStoreActions;
interface DraftStoreRuntimeState {
attachmentFocusRequestByDraftKey: Record<string, number>;
}
type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions;
const draftGenerations = new Map<string, number>();
let gcScheduled = false;
const draftPersistStorage = createDraftPersistStorage(
createJSONStorage<DraftStoreState>(() => AsyncStorage),
@@ -237,6 +243,7 @@ export const useDraftStore = create<DraftStore>()(
(set, get) => ({
drafts: {},
createModalDraft: null,
attachmentFocusRequestByDraftKey: {},
getDraftInput: (draftKey) => {
const record = get().drafts[draftKey];
@@ -344,7 +351,32 @@ export const useDraftStore = create<DraftStore>()(
return { drafts: nextDrafts };
});
draftGenerations.delete(draftKey);
scheduleAttachmentGc();
},
attachWorkspaceFile: async ({ draftKey, attachment }) => {
await get().hydrateDraftInput({ draftKey });
set((state) => {
const existing = state.drafts[draftKey];
const draft = toDraftInputIfReady(existing) ?? { text: "", attachments: [] };
return {
drafts: {
...state.drafts,
[draftKey]: createDraftRecord({
draft: {
...draft,
attachments: appendWorkspaceFileAttachment(draft.attachments, attachment),
},
lifecycle: "active",
previousVersion: existing?.version,
}),
},
attachmentFocusRequestByDraftKey: {
...state.attachmentFocusRequestByDraftKey,
[draftKey]: (state.attachmentFocusRequestByDraftKey[draftKey] ?? 0) + 1,
},
};
});
scheduleAttachmentGc();
},
@@ -369,16 +401,6 @@ export const useDraftStore = create<DraftStore>()(
scheduleAttachmentGc();
},
beginDraftGeneration: (draftKey) => {
const next = (draftGenerations.get(draftKey) ?? 0) + 1;
draftGenerations.set(draftKey, next);
return next;
},
isDraftGenerationCurrent: ({ draftKey, generation }) => {
return (draftGenerations.get(draftKey) ?? 0) === generation;
},
collectActiveAttachmentIds: () => {
return Array.from(collectReferencedAttachmentIdsFromState(get()).values());
},
@@ -387,6 +409,7 @@ export const useDraftStore = create<DraftStore>()(
name: "paseo-drafts",
version: DRAFT_STORE_VERSION,
storage: draftPersistStorage,
partialize: ({ drafts, createModalDraft }) => ({ drafts, createModalDraft }),
migrate: (persistedState) => {
return migratePersistedState(persistedState, {
migrateLegacyImages,
@@ -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,
+19 -1
View File
@@ -3,7 +3,12 @@ import {
type AttachmentMetadata,
type UserComposerAttachment,
} from "@/attachments/types";
import { ForgeSearchItemSchema, GitHubSearchItemSchema } from "@getpaseo/protocol/messages";
import { isWorkspaceFileComposerAttachment } from "@/attachments/workspace-file";
import {
ForgeSearchItemSchema,
GitHubSearchItemSchema,
UploadedFileAttachmentSchema,
} from "@getpaseo/protocol/messages";
export const DRAFT_STORE_VERSION = 5;
export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
@@ -83,6 +88,12 @@ export function isUserComposerAttachment(value: unknown): value is UserComposerA
const metadata = record.metadata;
return isAttachmentMetadata(metadata);
}
if (record.kind === "workspace_file") {
return isWorkspaceFileComposerAttachment(value);
}
if (record.kind === "file") {
return UploadedFileAttachmentSchema.safeParse(record.attachment).success;
}
if (
record.kind !== "forge_issue" &&
record.kind !== "forge_change_request" &&
@@ -113,6 +124,13 @@ export function normalizeComposerAttachment(
metadata: normalizeAttachmentMetadata(attachment.metadata),
};
}
if (attachment.kind === "workspace_file") {
return {
kind: "workspace_file",
path: attachment.path.trim().replace(/^\.\//, ""),
selection: attachment.selection,
};
}
if (attachment.kind === "github_pr") {
const item =
(attachment.item as { kind: string }).kind === "pr"