Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
DesktopPreviewTabState,
} from "@t3tools/contracts";
import { exposeClerkBridge } from "@clerk/electron/preload";
import { contextBridge, ipcRenderer } from "electron";
import { contextBridge, ipcRenderer, webUtils } from "electron";

import * as IpcChannels from "./ipc/channels.ts";

Expand Down Expand Up @@ -97,6 +97,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro),
setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled),
pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options),
getPathForFile: (file: File) => webUtils.getPathForFile(file),
pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined),
confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message),
setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
resolvePromptInjectedEffort,
} from "@t3tools/shared/model";
import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList";
import { replaceComposerFileLinksWithBasenames } from "@t3tools/shared/composerInlineTokens";
import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
import { truncate } from "@t3tools/shared/String";
import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
Expand Down Expand Up @@ -240,6 +241,7 @@ import {
} from "../state/entities";
import { environmentShell } from "../state/shell";
import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
import { canResolveComposerHostFilePaths } from "./chat/composerFileDrop";
import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
Expand Down Expand Up @@ -1225,6 +1227,12 @@ function ChatViewContent(props: ChatViewProps) {
() => new Map(environments.map((environment) => [environment.environmentId, environment])),
[environments],
);
const composerHostEnvironment = environmentById.get(environmentId);
const canResolveComposerHostPaths = canResolveComposerHostFilePaths(
composerHostEnvironment?.entry.target._tag ?? null,
);
const composerEnvironmentPlatformOs =
composerHostEnvironment?.serverConfig?.environment.platform.os ?? null;
const composerDraftTarget: ScopedThreadRef | DraftId =
routeKind === "server" ? routeThreadRef : props.draftId;
const draftThread = useComposerDraftStore((store) =>
Expand Down Expand Up @@ -5068,7 +5076,9 @@ function ChatViewContent(props: ChatViewProps) {
firstComposerImageName = firstComposerImage.name;
}
}
let titleSeed = trimmed;
// Mention markup would read as raw "[name](path)" in the thread list;
// seed the title with basenames the way the composer renders them.
let titleSeed = replaceComposerFileLinksWithBasenames(trimmed).trim();
if (!titleSeed) {
if (firstComposerImageName) {
titleSeed = `Image: ${firstComposerImageName}`;
Expand Down Expand Up @@ -6239,6 +6249,8 @@ function ChatViewContent(props: ChatViewProps) {
keybindings={keybindings}
terminalOpen={Boolean(terminalUiState.terminalOpen)}
gitCwd={gitCwd}
canResolveHostFilePaths={canResolveComposerHostPaths}
environmentPlatformOs={composerEnvironmentPlatformOs}
promptRef={promptRef}
composerImagesRef={composerImagesRef}
composerTerminalContextsRef={composerTerminalContextsRef}
Expand Down
123 changes: 121 additions & 2 deletions apps/web/src/components/ComposerPromptEditor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste";
class TestClipboardEvent extends Event {
readonly clipboardData: DataTransfer;

constructor(text: string) {
constructor(text: string, files: readonly File[] = []) {
super("paste", { cancelable: true });
this.clipboardData = {
files: [],
files,
getData: (type: string) => (type === "text/plain" ? text : ""),
} as unknown as DataTransfer;
}
Expand Down Expand Up @@ -71,6 +71,125 @@ describe("registerComposerInlineTokenPaste", () => {
);
});

it("inserts pasted non-image files as mentions and leaves images to the attachment handler", () => {
vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
const editor = createEditor();
const plainTextFallback = vi.fn(() => true);
const resolvePastedFilePath = vi.fn((file: File) =>
file.name === "app.ts" ? "src/app.ts" : null,
);
const image = { name: "shot.png", type: "image/png" } as File;
const source = { name: "app.ts", type: "text/plain" } as File;

editor.update(
() => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode("Ask"));
$getRoot().append(paragraph);
paragraph.selectEnd();
},
{ discrete: true },
);
registerComposerInlineTokenPaste(editor, {
createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
getExpandedAbsoluteOffsetForPoint: () => 3,
resolvePastedFilePath,
});
editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR);

const event = new TestClipboardEvent("", [image, source]);
let handled = false;
editor.update(
() => {
handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
},
{ discrete: true },
);

expect(handled).toBe(true);
expect(resolvePastedFilePath).toHaveBeenCalledOnce();
expect(resolvePastedFilePath).toHaveBeenCalledWith(source);
expect(plainTextFallback).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
"Ask <mention:src/app.ts> ",
);
});

it("appends pasted file mentions when the selection is not a range", () => {
vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
const editor = createEditor();
const source = { name: "app.ts", type: "text/plain" } as File;

// No selectEnd(): the paste lands with no range selection, as it does
// when a chip is node-selected.
editor.update(
() => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode("Ask"));
$getRoot().append(paragraph);
},
{ discrete: true },
);
registerComposerInlineTokenPaste(editor, {
createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
getExpandedAbsoluteOffsetForPoint: () => 3,
resolvePastedFilePath: () => "src/app.ts",
});

const event = new TestClipboardEvent("", [source]);
let handled = false;
editor.update(
() => {
handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
},
{ discrete: true },
);

expect(handled).toBe(true);
expect(event.defaultPrevented).toBe(true);
expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
"Ask <mention:src/app.ts> ",
);
});

it("leaves an unavailable pasted file for the parent error handler", () => {
vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
const editor = createEditor();
const plainTextFallback = vi.fn(() => false);
const source = { name: "app.ts", type: "text/plain" } as File;

editor.update(
() => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode("Ask"));
$getRoot().append(paragraph);
paragraph.selectEnd();
},
{ discrete: true },
);
registerComposerInlineTokenPaste(editor, {
createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
getExpandedAbsoluteOffsetForPoint: () => 3,
resolvePastedFilePath: () => null,
});
editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR);

const event = new TestClipboardEvent("", [source]);
let handled = true;
editor.update(
() => {
handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
},
{ discrete: true },
);

expect(handled).toBe(false);
expect(plainTextFallback).toHaveBeenCalledOnce();
expect(event.defaultPrevented).toBe(false);
expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe("Ask");
});

it.each([
"yarn expo install @expo/ui",
"npm install @jane/foo.js",
Expand Down
16 changes: 13 additions & 3 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,7 @@ interface ComposerPromptEditorProps {
event: KeyboardEvent,
) => boolean;
onPaste: React.ClipboardEventHandler<HTMLElement>;
resolvePastedFilePath?: (file: File) => string | null;
editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
}

Expand Down Expand Up @@ -1244,16 +1245,20 @@ function ComposerChipSelectionPlugin() {
return null;
}

function ComposerInlineTokenPastePlugin() {
function ComposerInlineTokenPastePlugin(props: {
resolvePastedFilePath?: (file: File) => string | null;
}) {
const [editor] = useLexicalComposerContext();
const { resolvePastedFilePath } = props;

useEffect(
() =>
registerComposerInlineTokenPaste(editor, {
createMentionNode: $createComposerMentionNode,
getExpandedAbsoluteOffsetForPoint,
...(resolvePastedFilePath ? { resolvePastedFilePath } : {}),
}),
[editor],
[editor, resolvePastedFilePath],
);

return null;
Expand Down Expand Up @@ -1537,6 +1542,7 @@ function ComposerPromptEditorInner({
onChange,
onCommandKeyDown,
onPaste,
resolvePastedFilePath,
editorRef,
}: ComposerPromptEditorProps) {
const [editor] = useLexicalComposerContext();
Expand Down Expand Up @@ -1779,7 +1785,9 @@ function ComposerPromptEditorInner({
<ComposerInlineTokenArrowPlugin />
<ComposerInlineTokenSelectionNormalizePlugin />
<ComposerInlineTokenBackspacePlugin />
<ComposerInlineTokenPastePlugin />
<ComposerInlineTokenPastePlugin
{...(resolvePastedFilePath ? { resolvePastedFilePath } : {})}
/>
<ComposerChipSelectionPlugin />
<HistoryPlugin />
</div>
Expand All @@ -1799,6 +1807,7 @@ export function ComposerPromptEditor({
onChange,
onCommandKeyDown,
onPaste,
resolvePastedFilePath,
editorRef,
}: ComposerPromptEditorProps) {
const initialValueRef = useRef(value);
Expand Down Expand Up @@ -1838,6 +1847,7 @@ export function ComposerPromptEditor({
editorRef={editorRef}
{...(onCommandKeyDown ? { onCommandKeyDown } : {})}
{...(className ? { className } : {})}
{...(resolvePastedFilePath ? { resolvePastedFilePath } : {})}
/>
</LexicalComposer>
);
Expand Down
Loading
Loading