Skip to content

Commit 408e2e5

Browse files
committed
Implement feature to auto convert long text to attachments on paste
1 parent 5afbdfd commit 408e2e5

7 files changed

Lines changed: 148 additions & 9 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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,13 @@ export function SettingsView() {
5959
defaultModel,
6060
defaultThinkingEnabled,
6161
desktopNotifications,
62+
autoConvertLongText,
6263
setAutoRunTasks,
6364
setCreatePR,
6465
setCursorGlow,
6566
setDefaultThinkingEnabled,
6667
setDesktopNotifications,
68+
setAutoConvertLongText,
6769
} = useSettingsStore();
6870
const terminalLayoutMode = useTerminalLayoutStore(
6971
(state) => state.terminalLayoutMode,
@@ -176,6 +178,18 @@ export function SettingsView() {
176178
[defaultThinkingEnabled, setDefaultThinkingEnabled],
177179
);
178180

181+
const handleAutoConvertLongTextChange = useCallback(
182+
(checked: boolean) => {
183+
track(ANALYTICS_EVENTS.SETTING_CHANGED, {
184+
setting_name: "auto_convert_long_text",
185+
new_value: checked,
186+
old_value: autoConvertLongText,
187+
});
188+
setAutoConvertLongText(checked);
189+
},
190+
[autoConvertLongText, setAutoConvertLongText],
191+
);
192+
179193
const handleWorktreeLocationChange = async (newLocation: string) => {
180194
setLocalWorktreeLocation(newLocation);
181195
try {
@@ -395,6 +409,23 @@ export function SettingsView() {
395409
size="1"
396410
/>
397411
</Flex>
412+
413+
<Flex align="center" justify="between">
414+
<Flex direction="column" gap="1">
415+
<Text size="1" weight="medium">
416+
Auto-convert long text
417+
</Text>
418+
<Text size="1" color="gray">
419+
Convert pasted text over 5000 characters into text
420+
attachments
421+
</Text>
422+
</Flex>
423+
<Switch
424+
checked={autoConvertLongText}
425+
onCheckedChange={handleAutoConvertLongTextChange}
426+
size="1"
427+
/>
428+
</Flex>
398429
</Flex>
399430
</Card>
400431
</Flex>

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface SettingsStore {
1717
defaultThinkingEnabled: boolean;
1818
desktopNotifications: boolean;
1919
cursorGlow: boolean;
20+
autoConvertLongText: boolean;
2021

2122
setAutoRunTasks: (autoRun: boolean) => void;
2223
setDefaultRunMode: (mode: DefaultRunMode) => void;
@@ -28,6 +29,7 @@ interface SettingsStore {
2829
setDefaultThinkingEnabled: (enabled: boolean) => void;
2930
setDesktopNotifications: (enabled: boolean) => void;
3031
setCursorGlow: (enabled: boolean) => void;
32+
setAutoConvertLongText: (enabled: boolean) => void;
3133
}
3234

3335
export const useSettingsStore = create<SettingsStore>()(
@@ -43,6 +45,7 @@ export const useSettingsStore = create<SettingsStore>()(
4345
defaultThinkingEnabled: false,
4446
desktopNotifications: true,
4547
cursorGlow: false,
48+
autoConvertLongText: true,
4649

4750
setAutoRunTasks: (autoRun) => set({ autoRunTasks: autoRun }),
4851
setDefaultRunMode: (mode) => set({ defaultRunMode: mode }),
@@ -57,6 +60,8 @@ export const useSettingsStore = create<SettingsStore>()(
5760
setDesktopNotifications: (enabled) =>
5861
set({ desktopNotifications: enabled }),
5962
setCursorGlow: (enabled) => set({ cursorGlow: enabled }),
63+
setAutoConvertLongText: (enabled) =>
64+
set({ autoConvertLongText: enabled }),
6065
}),
6166
{
6267
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)