Skip to content
Open
Show file tree
Hide file tree
Changes from all 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),
confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message),
setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
showContextMenu: (items, position) =>
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 @@ -39,6 +39,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 @@ -232,6 +233,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 @@ -1211,6 +1213,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 @@ -4953,7 +4961,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 @@ -6145,6 +6155,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