Skip to content

Commit 30df0dc

Browse files
committed
split files, add small readme
1 parent b3589ee commit 30df0dc

File tree

11 files changed

+284
-257
lines changed

11 files changed

+284
-257
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Message Editor
2+
3+
Tiptap-based editor with mention support for files (`@`) and commands (`/`).
4+
5+
## Structure
6+
7+
```
8+
message-editor/
9+
├── components/
10+
│ ├── MessageEditor.tsx # Main editor component
11+
│ └── EditorToolbar.tsx # Attachment buttons
12+
├── tiptap/
13+
│ ├── useTiptapEditor.ts # Hook that creates the editor
14+
│ ├── useDraftSync.ts # Persists drafts to store
15+
│ ├── extensions.ts # Configures Tiptap extensions
16+
│ ├── CommandMention.ts # / command suggestions
17+
│ ├── FileMention.ts # @ file suggestions
18+
│ ├── MentionChipNode.ts # Renders chips in editor
19+
│ └── SuggestionList.tsx # Dropdown UI for suggestions
20+
├── suggestions/
21+
│ └── getSuggestions.ts # Fetches suggestions via tRPC
22+
├── stores/
23+
│ └── draftStore.ts # Zustand store for drafts
24+
├── utils/
25+
│ └── content.ts # EditorContent type + serialization
26+
└── types.ts # Suggestion item types
27+
```
28+
29+
## How it works
30+
31+
1. `MessageEditor` calls `useTiptapEditor` with session config
32+
2. `useTiptapEditor` creates a Tiptap editor with extensions from `extensions.ts`
33+
3. Extensions include `CommandMention` and `FileMention` which show suggestions on `/` and `@`
34+
4. Suggestions are fetched via `getSuggestions.ts` (commands from session store, files via tRPC)
35+
5. Selected suggestions become `MentionChipNode` elements in the editor
36+
6. `useDraftSync` saves editor content to `draftStore` on every change
37+
7. On submit, content is serialized to XML via `contentToXml()` and sent to the session

apps/array/src/renderer/features/message-editor/components/EditorToolbar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ModelSelector } from "@features/sessions/components/ModelSelector";
22
import { Paperclip } from "@phosphor-icons/react";
33
import { Flex, IconButton, Tooltip } from "@radix-ui/themes";
44
import { useRef } from "react";
5-
import type { MentionChip } from "../core/content";
5+
import type { MentionChip } from "../utils/content";
66

77
interface EditorToolbarProps {
88
disabled?: boolean;

apps/array/src/renderer/features/message-editor/components/MessageEditor.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { ArrowUp, Stop } from "@phosphor-icons/react";
33
import { Flex, IconButton, Text, Tooltip } from "@radix-ui/themes";
44
import { EditorContent } from "@tiptap/react";
55
import { forwardRef, useImperativeHandle } from "react";
6-
import type { EditorContent as EditorContentType } from "../core/content";
76
import { useDraftStore } from "../stores/draftStore";
87
import { useTiptapEditor } from "../tiptap/useTiptapEditor";
98
import type { EditorHandle } from "../types";
9+
import type { EditorContent as EditorContentType } from "../utils/content";
1010
import { EditorToolbar } from "./EditorToolbar";
1111

1212
export type { EditorHandle as MessageEditorHandle };
@@ -58,15 +58,14 @@ export const MessageEditor = forwardRef<EditorHandle, MessageEditorProps>(
5858
insertChip,
5959
} = useTiptapEditor({
6060
sessionId,
61-
taskId,
6261
placeholder,
63-
repoPath,
6462
disabled,
6563
isCloud,
64+
autoFocus,
65+
context: { taskId, repoPath },
6666
onSubmit,
6767
onBashCommand,
6868
onBashModeChange,
69-
autoFocus,
7069
});
7170

7271
useImperativeHandle(

apps/array/src/renderer/features/message-editor/stores/draftStore.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { AvailableCommand } from "@agentclientprotocol/sdk";
22
import { create } from "zustand";
33
import { persist } from "zustand/middleware";
44
import { immer } from "zustand/middleware/immer";
5-
import type { EditorContent } from "../core/content";
5+
import type { EditorContent } from "../utils/content";
66

77
type SessionId = string;
88

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import Placeholder from "@tiptap/extension-placeholder";
2+
import StarterKit from "@tiptap/starter-kit";
3+
import { createCommandMention } from "./CommandMention";
4+
import { createFileMention } from "./FileMention";
5+
import { MentionChipNode } from "./MentionChipNode";
6+
7+
export interface EditorExtensionsOptions {
8+
sessionId: string;
9+
placeholder?: string;
10+
fileMentions?: boolean;
11+
commands?: boolean;
12+
onCommandSubmit?: (text: string) => void;
13+
onClearDraft?: () => void;
14+
}
15+
16+
export function getEditorExtensions(options: EditorExtensionsOptions) {
17+
const {
18+
sessionId,
19+
placeholder = "",
20+
fileMentions = true,
21+
commands = true,
22+
onCommandSubmit,
23+
onClearDraft,
24+
} = options;
25+
26+
const extensions = [
27+
StarterKit.configure({
28+
heading: false,
29+
blockquote: false,
30+
codeBlock: false,
31+
bulletList: false,
32+
orderedList: false,
33+
listItem: false,
34+
horizontalRule: false,
35+
bold: false,
36+
italic: false,
37+
strike: false,
38+
code: false,
39+
}),
40+
Placeholder.configure({ placeholder }),
41+
MentionChipNode,
42+
];
43+
44+
if (fileMentions) {
45+
extensions.push(createFileMention(sessionId));
46+
}
47+
48+
if (commands) {
49+
extensions.push(
50+
createCommandMention({
51+
sessionId,
52+
onSubmit: onCommandSubmit,
53+
onClearDraft,
54+
}),
55+
);
56+
}
57+
58+
return extensions;
59+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type { Editor, JSONContent } from "@tiptap/core";
2+
import { useCallback, useLayoutEffect, useRef } from "react";
3+
import { useDraftStore } from "../stores/draftStore";
4+
import { type EditorContent, isContentEmpty } from "../utils/content";
5+
6+
function tiptapJsonToEditorContent(json: JSONContent): EditorContent {
7+
const segments: EditorContent["segments"] = [];
8+
9+
const traverse = (node: JSONContent) => {
10+
if (node.type === "text" && node.text) {
11+
segments.push({ type: "text", text: node.text });
12+
} else if (node.type === "mentionChip" && node.attrs) {
13+
segments.push({
14+
type: "chip",
15+
chip: {
16+
type: node.attrs.type,
17+
id: node.attrs.id,
18+
label: node.attrs.label,
19+
},
20+
});
21+
} else if (node.content) {
22+
for (const child of node.content) {
23+
traverse(child);
24+
}
25+
}
26+
};
27+
28+
traverse(json);
29+
return { segments };
30+
}
31+
32+
function editorContentToTiptapJson(content: EditorContent): JSONContent {
33+
const paragraphContent: JSONContent[] = [];
34+
35+
for (const seg of content.segments) {
36+
if (seg.type === "text") {
37+
paragraphContent.push({ type: "text", text: seg.text });
38+
} else {
39+
paragraphContent.push({
40+
type: "mentionChip",
41+
attrs: {
42+
type: seg.chip.type,
43+
id: seg.chip.id,
44+
label: seg.chip.label,
45+
},
46+
});
47+
}
48+
}
49+
50+
return {
51+
type: "doc",
52+
content: [{ type: "paragraph", content: paragraphContent }],
53+
};
54+
}
55+
56+
export interface DraftContext {
57+
taskId?: string;
58+
repoPath?: string | null;
59+
}
60+
61+
export function useDraftSync(
62+
editor: Editor | null,
63+
sessionId: string,
64+
context?: DraftContext,
65+
) {
66+
const hasRestoredRef = useRef(false);
67+
const editorRef = useRef(editor);
68+
editorRef.current = editor;
69+
70+
const draftActions = useDraftStore((s) => s.actions);
71+
const draft = useDraftStore((s) => s.drafts[sessionId] ?? null);
72+
const hasHydrated = useDraftStore((s) => s._hasHydrated);
73+
74+
// Set context for this session
75+
useLayoutEffect(() => {
76+
draftActions.setContext(sessionId, {
77+
taskId: context?.taskId,
78+
repoPath: context?.repoPath,
79+
});
80+
return () => {
81+
draftActions.removeContext(sessionId);
82+
};
83+
}, [sessionId, context?.taskId, context?.repoPath, draftActions]);
84+
85+
// Restore draft on mount
86+
useLayoutEffect(() => {
87+
if (!hasHydrated || !editor || hasRestoredRef.current) return;
88+
if (!draft || isContentEmpty(draft)) return;
89+
90+
hasRestoredRef.current = true;
91+
92+
if (typeof draft === "string") {
93+
editor.commands.setContent(draft);
94+
} else {
95+
editor.commands.setContent(editorContentToTiptapJson(draft));
96+
}
97+
}, [hasHydrated, draft, editor]);
98+
99+
const saveDraft = useCallback(
100+
(e: Editor) => {
101+
const json = e.getJSON();
102+
const content = tiptapJsonToEditorContent(json);
103+
draftActions.setDraft(
104+
sessionId,
105+
isContentEmpty(content) ? null : content,
106+
);
107+
},
108+
[sessionId, draftActions],
109+
);
110+
111+
const clearDraft = useCallback(() => {
112+
draftActions.setDraft(sessionId, null);
113+
}, [sessionId, draftActions]);
114+
115+
const getContent = useCallback((): EditorContent => {
116+
if (!editorRef.current) return { segments: [] };
117+
return tiptapJsonToEditorContent(editorRef.current.getJSON());
118+
}, []);
119+
120+
return { saveDraft, clearDraft, getContent };
121+
}

0 commit comments

Comments
 (0)