Skip to content

Commit df59bd8

Browse files
committed
Implement feature to auto convert long text to attachments on paste
1 parent 06e2cea commit df59bd8

7 files changed

Lines changed: 164 additions & 22 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { create } from "zustand";
2+
3+
interface TextAttachmentStore {
4+
attachments: Map<string, string>;
5+
addAttachment: (id: string, content: string) => void;
6+
getAttachment: (id: string) => string | undefined;
7+
removeAttachment: (id: string) => void;
8+
clearAttachments: () => void;
9+
}
10+
11+
export const useTextAttachmentStore = create<TextAttachmentStore>(
12+
(set, get) => ({
13+
attachments: new Map(),
14+
addAttachment: (id, content) =>
15+
set((state) => {
16+
const newAttachments = new Map(state.attachments);
17+
newAttachments.set(id, content);
18+
return { attachments: newAttachments };
19+
}),
20+
getAttachment: (id) => get().attachments.get(id),
21+
removeAttachment: (id) =>
22+
set((state) => {
23+
const newAttachments = new Map(state.attachments);
24+
newAttachments.delete(id);
25+
return { attachments: newAttachments };
26+
}),
27+
clearAttachments: () => set({ attachments: new Map() }),
28+
}),
29+
);
30+
31+
export const TEXT_ATTACHMENT_THRESHOLD = 5000;

apps/array/src/renderer/features/message-editor/tiptap/MentionChipNode.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ export type ChipType =
66
| "error"
77
| "experiment"
88
| "insight"
9-
| "feature_flag";
9+
| "feature_flag"
10+
| "text_attachment";
1011

1112
export interface MentionChipAttrs {
1213
type: ChipType;
@@ -44,7 +45,8 @@ export const MentionChipNode = Node.create({
4445
renderHTML({ node, HTMLAttributes }) {
4546
const { type, label } = node.attrs as MentionChipAttrs;
4647
const isCommand = type === "command";
47-
const prefix = isCommand ? "/" : "@";
48+
const isTextAttachment = type === "text_attachment";
49+
const prefix = isCommand ? "/" : isTextAttachment ? "📄 " : "@";
4850

4951
return [
5052
"span",

apps/array/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import { useSettingsStore } from "@features/settings/stores/settingsStore";
12
import { toast } from "@renderer/utils/toast";
23
import { useEditor } from "@tiptap/react";
34
import { useCallback, useRef, useState } from "react";
45
import { usePromptHistoryStore } from "../stores/promptHistoryStore";
6+
import {
7+
TEXT_ATTACHMENT_THRESHOLD,
8+
useTextAttachmentStore,
9+
} from "../stores/textAttachmentStore";
510
import type { MentionChip } from "../utils/content";
611
import { contentToXml } from "../utils/content";
712
import { getEditorExtensions } from "./extensions";
@@ -194,6 +199,50 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
194199
return true;
195200
}
196201

202+
return false;
203+
},
204+
handlePaste: (view, event) => {
205+
const autoConvertLongText =
206+
useSettingsStore.getState().autoConvertLongText;
207+
const clipboardText = event.clipboardData?.getData("text/plain");
208+
209+
if (
210+
autoConvertLongText &&
211+
clipboardText &&
212+
clipboardText.length > TEXT_ATTACHMENT_THRESHOLD
213+
) {
214+
event.preventDefault();
215+
216+
// Generate unique ID for attachment
217+
const attachmentId = `text-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
218+
219+
// Format size for label
220+
const sizeKB = (clipboardText.length / 1024).toFixed(1);
221+
const label = `Pasted text (${sizeKB} KB)`;
222+
223+
// Store the text content
224+
useTextAttachmentStore
225+
.getState()
226+
.addAttachment(attachmentId, clipboardText);
227+
228+
// Insert chip at current position
229+
const chipNode = view.state.schema.nodes.mentionChip?.create({
230+
type: "text_attachment",
231+
id: attachmentId,
232+
label,
233+
});
234+
235+
if (chipNode) {
236+
const { tr } = view.state;
237+
const pos = view.state.selection.from;
238+
tr.insert(pos, chipNode);
239+
tr.insertText(" ", pos + chipNode.nodeSize);
240+
view.dispatch(tr);
241+
}
242+
243+
return true;
244+
}
245+
197246
return false;
198247
},
199248
},
@@ -247,12 +296,14 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
247296
if (command) callbackRefs.current.onBashCommand?.(command);
248297
} else {
249298
const content = draft.getContent();
250-
callbackRefs.current.onSubmit?.(contentToXml(content));
299+
const textAttachments = useTextAttachmentStore.getState().attachments;
300+
callbackRefs.current.onSubmit?.(contentToXml(content, textAttachments));
251301
}
252302

253303
editor.commands.clearContent();
254304
prevBashModeRef.current = false;
255305
draft.clearDraft();
306+
useTextAttachmentStore.getState().clearAttachments();
256307
}, [editor, disabled, isLoading, isCloud, draft]);
257308

258309
submitRef.current = submit;

apps/array/src/renderer/features/message-editor/utils/content.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ export interface MentionChip {
55
| "error"
66
| "experiment"
77
| "insight"
8-
| "feature_flag";
8+
| "feature_flag"
9+
| "text_attachment";
910
id: string;
1011
label: string;
1112
}
@@ -28,7 +29,10 @@ export function contentToPlainText(content: EditorContent): string {
2829
.join("");
2930
}
3031

31-
export function contentToXml(content: EditorContent): string {
32+
export function contentToXml(
33+
content: EditorContent,
34+
textAttachments?: Map<string, string>,
35+
): string {
3236
return content.segments
3337
.map((seg) => {
3438
if (seg.type === "text") return seg.text;
@@ -46,6 +50,10 @@ export function contentToXml(content: EditorContent): string {
4650
return `<insight id="${chip.id}" />`;
4751
case "feature_flag":
4852
return `<feature_flag id="${chip.id}" />`;
53+
case "text_attachment": {
54+
const attachmentContent = textAttachments?.get(chip.id) ?? "";
55+
return `<text-attachment name="${chip.label}">\n${attachmentContent}\n</text-attachment>`;
56+
}
4957
default:
5058
return `@${chip.label}`;
5159
}

apps/array/src/renderer/features/settings/components/SettingsView.tsx

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,12 @@ export function SettingsView() {
5656
createPR,
5757
cursorGlow,
5858
desktopNotifications,
59+
autoConvertLongText,
5960
setAutoRunTasks,
6061
setCreatePR,
6162
setCursorGlow,
6263
setDesktopNotifications,
64+
setAutoConvertLongText,
6365
} = useSettingsStore();
6466
const terminalLayoutMode = useTerminalLayoutStore(
6567
(state) => state.terminalLayoutMode,
@@ -160,6 +162,18 @@ export function SettingsView() {
160162
[terminalLayoutMode, setTerminalLayout],
161163
);
162164

165+
const handleAutoConvertLongTextChange = useCallback(
166+
(checked: boolean) => {
167+
track(ANALYTICS_EVENTS.SETTING_CHANGED, {
168+
setting_name: "auto_convert_long_text",
169+
new_value: checked,
170+
old_value: autoConvertLongText,
171+
});
172+
setAutoConvertLongText(checked);
173+
},
174+
[autoConvertLongText, setAutoConvertLongText],
175+
);
176+
163177
const handleWorktreeLocationChange = async (newLocation: string) => {
164178
setLocalWorktreeLocation(newLocation);
165179
try {
@@ -341,20 +355,40 @@ export function SettingsView() {
341355
<Flex direction="column" gap="3">
342356
<Heading size="3">Chat</Heading>
343357
<Card>
344-
<Flex align="center" justify="between">
345-
<Flex direction="column" gap="1">
346-
<Text size="1" weight="medium">
347-
Desktop notifications
348-
</Text>
349-
<Text size="1" color="gray">
350-
Show notifications when the agent finishes working on a task
351-
</Text>
358+
<Flex direction="column" gap="4">
359+
<Flex align="center" justify="between">
360+
<Flex direction="column" gap="1">
361+
<Text size="1" weight="medium">
362+
Desktop notifications
363+
</Text>
364+
<Text size="1" color="gray">
365+
Show notifications when the agent finishes working on a
366+
task
367+
</Text>
368+
</Flex>
369+
<Switch
370+
checked={desktopNotifications}
371+
onCheckedChange={setDesktopNotifications}
372+
size="1"
373+
/>
374+
</Flex>
375+
376+
<Flex align="center" justify="between">
377+
<Flex direction="column" gap="1">
378+
<Text size="1" weight="medium">
379+
Auto-convert long text
380+
</Text>
381+
<Text size="1" color="gray">
382+
Convert pasted text over 5000 characters into text
383+
attachments
384+
</Text>
385+
</Flex>
386+
<Switch
387+
checked={autoConvertLongText}
388+
onCheckedChange={handleAutoConvertLongTextChange}
389+
size="1"
390+
/>
352391
</Flex>
353-
<Switch
354-
checked={desktopNotifications}
355-
onCheckedChange={setDesktopNotifications}
356-
size="1"
357-
/>
358392
</Flex>
359393
</Card>
360394
</Flex>

apps/array/src/renderer/features/settings/stores/settingsStore.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ interface SettingsStore {
1616
defaultModel: string;
1717
desktopNotifications: boolean;
1818
cursorGlow: boolean;
19+
autoConvertLongText: boolean;
1920

2021
setAutoRunTasks: (autoRun: boolean) => void;
2122
setDefaultRunMode: (mode: DefaultRunMode) => void;
@@ -26,6 +27,7 @@ interface SettingsStore {
2627
setDefaultModel: (model: string) => void;
2728
setDesktopNotifications: (enabled: boolean) => void;
2829
setCursorGlow: (enabled: boolean) => void;
30+
setAutoConvertLongText: (enabled: boolean) => void;
2931
}
3032

3133
export const useSettingsStore = create<SettingsStore>()(
@@ -40,6 +42,7 @@ export const useSettingsStore = create<SettingsStore>()(
4042
defaultModel: DEFAULT_MODEL,
4143
desktopNotifications: true,
4244
cursorGlow: false,
45+
autoConvertLongText: true,
4346

4447
setAutoRunTasks: (autoRun) => set({ autoRunTasks: autoRun }),
4548
setDefaultRunMode: (mode) => set({ defaultRunMode: mode }),
@@ -52,6 +55,8 @@ export const useSettingsStore = create<SettingsStore>()(
5255
setDesktopNotifications: (enabled) =>
5356
set({ desktopNotifications: enabled }),
5457
setCursorGlow: (enabled) => set({ cursorGlow: enabled }),
58+
setAutoConvertLongText: (enabled) =>
59+
set({ autoConvertLongText: enabled }),
5560
}),
5661
{
5762
name: "settings-storage",

apps/array/src/renderer/features/task-detail/hooks/useTaskCreation.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useAuthStore } from "@features/auth/stores/authStore";
22
import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor";
3+
import { useTextAttachmentStore } from "@features/message-editor/stores/textAttachmentStore";
34
import type { EditorContent } from "@features/message-editor/utils/content";
45
import { useSettingsStore } from "@features/settings/stores/settingsStore";
56
import { useCreateTask } from "@features/tasks/hooks/useTasks";
@@ -35,7 +36,10 @@ interface UseTaskCreationReturn {
3536
handleSubmit: () => void;
3637
}
3738

38-
function contentToXml(content: EditorContent): string {
39+
function contentToXml(
40+
content: EditorContent,
41+
textAttachments?: Map<string, string>,
42+
): string {
3943
return content.segments
4044
.map((seg) => {
4145
if (seg.type === "text") return seg.text;
@@ -53,6 +57,10 @@ function contentToXml(content: EditorContent): string {
5357
return `<insight id="${chip.id}" />`;
5458
case "feature_flag":
5559
return `<feature_flag id="${chip.id}" />`;
60+
case "text_attachment": {
61+
const attachmentContent = textAttachments?.get(chip.id) ?? "";
62+
return `<text-attachment name="${chip.label}">\n${attachmentContent}\n</text-attachment>`;
63+
}
5664
default:
5765
return `@${chip.label}`;
5866
}
@@ -74,6 +82,7 @@ function extractFileMentionsFromContent(content: EditorContent): string[] {
7482

7583
function prepareTaskInput(
7684
content: EditorContent,
85+
textAttachments: Map<string, string>,
7786
options: {
7887
selectedDirectory: string;
7988
selectedRepository?: string | null;
@@ -85,7 +94,7 @@ function prepareTaskInput(
8594
},
8695
): TaskCreationInput {
8796
return {
88-
content: contentToXml(content).trim(),
97+
content: contentToXml(content, textAttachments).trim(),
8998
filePaths: extractFileMentionsFromContent(content),
9099
repoPath: options.selectedDirectory,
91100
repository: options.selectedRepository,
@@ -143,7 +152,8 @@ export function useTaskCreation({
143152

144153
try {
145154
const content = editor.getContent();
146-
const input = prepareTaskInput(content, {
155+
const textAttachments = useTextAttachmentStore.getState().attachments;
156+
const input = prepareTaskInput(content, textAttachments, {
147157
selectedDirectory,
148158
selectedRepository,
149159
githubIntegrationId,
@@ -165,8 +175,9 @@ export function useTaskCreation({
165175
// Navigate to the new task
166176
navigateToTask(task);
167177

168-
// Clear editor
178+
// Clear editor and text attachments
169179
editor.clear();
180+
useTextAttachmentStore.getState().clearAttachments();
170181

171182
log.info("Task created successfully", { taskId: task.id });
172183
} else {

0 commit comments

Comments
 (0)