newpr 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +11 -1
  2. package/src/analyzer/pipeline.ts +37 -15
  3. package/src/analyzer/progress.ts +2 -0
  4. package/src/cli/index.ts +7 -2
  5. package/src/cli/preflight.ts +126 -0
  6. package/src/github/fetch-pr.ts +53 -1
  7. package/src/history/store.ts +107 -1
  8. package/src/history/types.ts +1 -0
  9. package/src/llm/client.ts +197 -0
  10. package/src/llm/prompts.ts +80 -19
  11. package/src/llm/response-parser.ts +13 -1
  12. package/src/tui/Shell.tsx +7 -2
  13. package/src/types/github.ts +14 -0
  14. package/src/types/output.ts +50 -0
  15. package/src/web/client/App.tsx +33 -5
  16. package/src/web/client/components/AppShell.tsx +107 -47
  17. package/src/web/client/components/ChatSection.tsx +427 -0
  18. package/src/web/client/components/DetailPane.tsx +217 -77
  19. package/src/web/client/components/DiffViewer.tsx +713 -0
  20. package/src/web/client/components/InputScreen.tsx +178 -27
  21. package/src/web/client/components/LoadingTimeline.tsx +19 -6
  22. package/src/web/client/components/Markdown.tsx +220 -41
  23. package/src/web/client/components/ResultsScreen.tsx +109 -73
  24. package/src/web/client/components/ReviewModal.tsx +187 -0
  25. package/src/web/client/components/SettingsPanel.tsx +62 -86
  26. package/src/web/client/components/TipTapEditor.tsx +405 -0
  27. package/src/web/client/hooks/useAnalysis.ts +8 -1
  28. package/src/web/client/lib/shiki.ts +63 -0
  29. package/src/web/client/panels/CartoonPanel.tsx +94 -37
  30. package/src/web/client/panels/DiscussionPanel.tsx +158 -0
  31. package/src/web/client/panels/FilesPanel.tsx +435 -54
  32. package/src/web/client/panels/GroupsPanel.tsx +62 -40
  33. package/src/web/client/panels/StoryPanel.tsx +43 -23
  34. package/src/web/components/ui/tabs.tsx +3 -3
  35. package/src/web/server/routes.ts +856 -14
  36. package/src/web/server/session-manager.ts +11 -2
  37. package/src/web/server.ts +66 -4
  38. package/src/web/styles/built.css +1 -1
  39. package/src/web/styles/globals.css +117 -1
  40. package/src/workspace/agent.ts +22 -6
  41. package/src/workspace/explore.ts +41 -16
  42. package/src/web/client/panels/NarrativePanel.tsx +0 -9
  43. package/src/web/client/panels/SummaryPanel.tsx +0 -20
@@ -1,6 +1,5 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
- import { X, Check, Loader2, Key, Bot, Globe, Settings2 } from "lucide-react";
3
- import { Button } from "../../components/ui/button.tsx";
2
+ import { X, Check, Loader2 } from "lucide-react";
4
3
 
5
4
  interface ConfigData {
6
5
  model: string;
@@ -30,7 +29,7 @@ const MODELS = [
30
29
 
31
30
  const AGENTS = [
32
31
  { value: "", label: "Auto" },
33
- { value: "claude", label: "Claude Code" },
32
+ { value: "claude", label: "Claude" },
34
33
  { value: "opencode", label: "OpenCode" },
35
34
  { value: "codex", label: "Codex" },
36
35
  ];
@@ -76,109 +75,114 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
76
75
  if (!config) {
77
76
  return (
78
77
  <div className="flex items-center justify-center py-20">
79
- <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
78
+ <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/40" />
80
79
  </div>
81
80
  );
82
81
  }
83
82
 
84
83
  return (
85
- <div className="flex flex-col gap-0">
86
- <div className="flex items-center justify-between pb-6">
87
- <h2 className="text-lg font-semibold tracking-tight">Settings</h2>
84
+ <div>
85
+ <div className="flex items-center justify-between mb-6">
86
+ <h2 className="text-xs font-semibold">Settings</h2>
88
87
  <div className="flex items-center gap-2">
89
- {saving && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
90
- {saved && <Check className="h-3.5 w-3.5 text-green-500" />}
88
+ {saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground/40" />}
89
+ {saved && <Check className="h-3 w-3 text-green-500" />}
91
90
  <button
92
91
  type="button"
93
92
  onClick={onClose}
94
- className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
93
+ className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-accent/40 transition-colors"
95
94
  >
96
- <X className="h-4 w-4" />
95
+ <X className="h-3.5 w-3.5" />
97
96
  </button>
98
97
  </div>
99
98
  </div>
100
99
 
101
- <div className="space-y-8">
102
- <Section icon={Key} title="Authentication">
100
+ <div className="space-y-6">
101
+ <Section title="Authentication">
103
102
  <Row label="OpenRouter API Key">
104
103
  {showApiKeyField ? (
105
- <div className="flex gap-2">
104
+ <div className="flex gap-1.5">
106
105
  <input
107
106
  type="password"
108
107
  value={apiKeyInput}
109
108
  onChange={(e) => setApiKeyInput(e.target.value)}
110
109
  placeholder="sk-or-..."
111
- className="flex-1 h-8 rounded-md border bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
110
+ className="flex-1 h-7 rounded-md border bg-background px-2.5 text-[11px] font-mono placeholder:text-muted-foreground/40 focus:outline-none focus:border-foreground/20"
112
111
  autoFocus
112
+ onKeyDown={(e) => {
113
+ if (e.key === "Enter" && apiKeyInput.trim()) {
114
+ save({ openrouter_api_key: apiKeyInput.trim() });
115
+ setApiKeyInput("");
116
+ setShowApiKeyField(false);
117
+ }
118
+ if (e.key === "Escape") {
119
+ setShowApiKeyField(false);
120
+ setApiKeyInput("");
121
+ }
122
+ }}
113
123
  />
114
- <Button
115
- size="sm"
124
+ <button
125
+ type="button"
116
126
  disabled={!apiKeyInput.trim()}
117
127
  onClick={() => {
118
128
  save({ openrouter_api_key: apiKeyInput.trim() });
119
129
  setApiKeyInput("");
120
130
  setShowApiKeyField(false);
121
131
  }}
132
+ className="h-7 px-2.5 rounded-md bg-foreground text-background text-[11px] font-medium disabled:opacity-20 hover:opacity-80 transition-opacity"
122
133
  >
123
134
  Save
124
- </Button>
125
- <Button
126
- variant="ghost"
127
- size="sm"
128
- onClick={() => { setShowApiKeyField(false); setApiKeyInput(""); }}
129
- >
130
- Cancel
131
- </Button>
135
+ </button>
132
136
  </div>
133
137
  ) : (
134
- <div className="flex items-center gap-3">
135
- <StatusDot ok={config.has_api_key} />
136
- <span className="text-sm text-muted-foreground">
138
+ <div className="flex items-center gap-2">
139
+ <span className={`h-1.5 w-1.5 rounded-full ${config.has_api_key ? "bg-green-500" : "bg-red-500"}`} />
140
+ <span className="text-[11px] text-muted-foreground/50">
137
141
  {config.has_api_key ? "Configured" : "Not set"}
138
142
  </span>
139
143
  <button
140
144
  type="button"
141
145
  onClick={() => setShowApiKeyField(true)}
142
- className="text-xs text-muted-foreground hover:text-foreground underline transition-colors"
146
+ className="text-[11px] text-muted-foreground/40 hover:text-foreground transition-colors"
143
147
  >
144
- {config.has_api_key ? "Change" : "Set key"}
148
+ {config.has_api_key ? "Change" : "Set"}
145
149
  </button>
146
150
  </div>
147
151
  )}
148
152
  </Row>
149
153
  <Row label="GitHub Token">
150
- <div className="flex items-center gap-3">
151
- <StatusDot ok={config.has_github_token} />
152
- <span className="text-sm text-muted-foreground">
153
- {config.has_github_token ? "Detected from gh CLI" : "Not detected"}
154
+ <div className="flex items-center gap-2">
155
+ <span className={`h-1.5 w-1.5 rounded-full ${config.has_github_token ? "bg-green-500" : "bg-red-500"}`} />
156
+ <span className="text-[11px] text-muted-foreground/50">
157
+ {config.has_github_token ? "gh CLI" : "Not detected"}
154
158
  </span>
155
159
  </div>
156
160
  </Row>
157
161
  </Section>
158
162
 
159
- <Section icon={Bot} title="Model & Agent">
160
- <Row label="Model">
163
+ <Section title="Model">
164
+ <Row label="LLM">
161
165
  <select
162
166
  value={config.model}
163
167
  onChange={(e) => save({ model: e.target.value })}
164
- className="h-8 rounded-md border bg-background px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring cursor-pointer"
168
+ className="h-7 rounded-md border bg-background px-2 text-[11px] font-mono focus:outline-none focus:border-foreground/20 cursor-pointer"
165
169
  >
166
170
  {MODELS.map((m) => (
167
171
  <option key={m} value={m}>{m.split("/").pop()}</option>
168
172
  ))}
169
173
  </select>
170
174
  </Row>
171
- <Row label="Exploration Agent">
172
- <div className="flex gap-1.5">
175
+ <Row label="Agent">
176
+ <div className="flex gap-px rounded-md border p-0.5">
173
177
  {AGENTS.map((a) => (
174
178
  <button
175
179
  key={a.value}
176
180
  type="button"
177
181
  onClick={() => save({ agent: a.value })}
178
- className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
182
+ className={`px-2.5 py-1 rounded text-[11px] transition-colors ${
179
183
  (config.agent ?? "") === a.value
180
- ? "bg-primary text-primary-foreground"
181
- : "bg-muted text-muted-foreground hover:text-foreground"
184
+ ? "bg-accent text-foreground font-medium"
185
+ : "text-muted-foreground/50 hover:text-foreground"
182
186
  }`}
183
187
  >
184
188
  {a.label}
@@ -186,14 +190,11 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
186
190
  ))}
187
191
  </div>
188
192
  </Row>
189
- </Section>
190
-
191
- <Section icon={Globe} title="Language">
192
- <Row label="Output Language">
193
+ <Row label="Language">
193
194
  <select
194
195
  value={config.language}
195
196
  onChange={(e) => save({ language: e.target.value })}
196
- className="h-8 rounded-md border bg-background px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring cursor-pointer"
197
+ className="h-7 rounded-md border bg-background px-2 text-[11px] focus:outline-none focus:border-foreground/20 cursor-pointer"
197
198
  >
198
199
  {LANGUAGES.map((l) => (
199
200
  <option key={l} value={l}>{l === "auto" ? "Auto-detect" : l}</option>
@@ -202,27 +203,18 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
202
203
  </Row>
203
204
  </Section>
204
205
 
205
- <Section icon={Settings2} title="Advanced">
206
+ <Section title="Limits">
206
207
  <Row label="Max files">
207
- <NumberInput
208
- value={config.max_files}
209
- defaultValue={config.defaults.max_files}
210
- onChange={(v) => save({ max_files: v })}
211
- />
208
+ <NumberInput value={config.max_files} onChange={(v) => save({ max_files: v })} />
212
209
  </Row>
213
- <Row label="Timeout (sec)">
214
- <NumberInput
215
- value={config.timeout}
216
- defaultValue={config.defaults.timeout}
217
- onChange={(v) => save({ timeout: v })}
218
- />
210
+ <Row label="Timeout">
211
+ <div className="flex items-center gap-1.5">
212
+ <NumberInput value={config.timeout} onChange={(v) => save({ timeout: v })} />
213
+ <span className="text-[10px] text-muted-foreground/30">sec</span>
214
+ </div>
219
215
  </Row>
220
216
  <Row label="Concurrency">
221
- <NumberInput
222
- value={config.concurrency}
223
- defaultValue={config.defaults.concurrency}
224
- onChange={(v) => save({ concurrency: v })}
225
- />
217
+ <NumberInput value={config.concurrency} onChange={(v) => save({ concurrency: v })} />
226
218
  </Row>
227
219
  </Section>
228
220
  </div>
@@ -230,22 +222,13 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
230
222
  );
231
223
  }
232
224
 
233
- function Section({
234
- icon: Icon,
235
- title,
236
- children,
237
- }: {
238
- icon: typeof Key;
239
- title: string;
240
- children: React.ReactNode;
241
- }) {
225
+ function Section({ title, children }: { title: string; children: React.ReactNode }) {
242
226
  return (
243
227
  <div>
244
- <div className="flex items-center gap-2 mb-4">
245
- <Icon className="h-4 w-4 text-muted-foreground" />
246
- <h3 className="text-sm font-medium">{title}</h3>
228
+ <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-3">
229
+ {title}
247
230
  </div>
248
- <div className="space-y-4 pl-6">{children}</div>
231
+ <div className="space-y-3">{children}</div>
249
232
  </div>
250
233
  );
251
234
  }
@@ -253,24 +236,17 @@ function Section({
253
236
  function Row({ label, children }: { label: string; children: React.ReactNode }) {
254
237
  return (
255
238
  <div className="flex items-center justify-between gap-4">
256
- <label className="text-sm text-muted-foreground shrink-0">{label}</label>
239
+ <label className="text-[11px] text-muted-foreground/60 shrink-0">{label}</label>
257
240
  <div className="flex-1 flex justify-end">{children}</div>
258
241
  </div>
259
242
  );
260
243
  }
261
244
 
262
- function StatusDot({ ok }: { ok: boolean }) {
263
- return (
264
- <span className={`h-2 w-2 rounded-full ${ok ? "bg-green-500" : "bg-red-500"}`} />
265
- );
266
- }
267
-
268
245
  function NumberInput({
269
246
  value,
270
247
  onChange,
271
248
  }: {
272
249
  value: number;
273
- defaultValue?: number;
274
250
  onChange: (v: number) => void;
275
251
  }) {
276
252
  const [local, setLocal] = useState(String(value));
@@ -293,7 +269,7 @@ function NumberInput({
293
269
  onChange={(e) => setLocal(e.target.value)}
294
270
  onBlur={handleBlur}
295
271
  onKeyDown={(e) => { if (e.key === "Enter") handleBlur(); }}
296
- className="w-20 h-8 rounded-md border bg-background px-3 text-sm text-right font-mono focus:outline-none focus:ring-1 focus:ring-ring"
272
+ className="w-16 h-7 rounded-md border bg-background px-2 text-[11px] text-right font-mono tabular-nums focus:outline-none focus:border-foreground/20"
297
273
  />
298
274
  );
299
275
  }
@@ -0,0 +1,405 @@
1
+ import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react";
2
+ import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react";
3
+ import StarterKit from "@tiptap/starter-kit";
4
+ import Placeholder from "@tiptap/extension-placeholder";
5
+ import Mention from "@tiptap/extension-mention";
6
+ import { PluginKey } from "@tiptap/pm/state";
7
+ import type { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
8
+
9
+ export interface AnchorItem {
10
+ kind: "group" | "file";
11
+ id: string;
12
+ label: string;
13
+ }
14
+
15
+ export interface CommandItem {
16
+ id: string;
17
+ label: string;
18
+ description: string;
19
+ }
20
+
21
+ interface SuggestionEntry {
22
+ id: string;
23
+ label: string;
24
+ badge?: string;
25
+ badgeClass?: string;
26
+ description?: string;
27
+ mono?: boolean;
28
+ }
29
+
30
+ interface TipTapEditorProps {
31
+ content?: string;
32
+ placeholder?: string;
33
+ disabled?: boolean;
34
+ autoFocus?: boolean;
35
+ className?: string;
36
+ onSubmit?: () => void;
37
+ onChange?: (text: string) => void;
38
+ submitOnEnter?: boolean;
39
+ submitOnModEnter?: boolean;
40
+ onEscape?: () => void;
41
+ editorRef?: React.MutableRefObject<ReturnType<typeof useEditor> | null>;
42
+ anchorItems?: AnchorItem[];
43
+ commands?: CommandItem[];
44
+ }
45
+
46
+ function SuggestionList({
47
+ items,
48
+ command,
49
+ selectedIndex,
50
+ setSelectedIndex,
51
+ }: {
52
+ items: SuggestionEntry[];
53
+ command: (item: SuggestionEntry) => void;
54
+ selectedIndex: number;
55
+ setSelectedIndex: (i: number) => void;
56
+ }) {
57
+ const listRef = useRef<HTMLDivElement>(null);
58
+
59
+ useEffect(() => {
60
+ const el = listRef.current?.children[selectedIndex] as HTMLElement | undefined;
61
+ el?.scrollIntoView({ block: "nearest" });
62
+ }, [selectedIndex]);
63
+
64
+ if (items.length === 0) return null;
65
+
66
+ return (
67
+ <div
68
+ ref={listRef}
69
+ className="z-50 min-w-[200px] max-w-[340px] max-h-[200px] overflow-y-auto rounded-lg border bg-background shadow-lg py-1"
70
+ >
71
+ {items.map((item, i) => (
72
+ <button
73
+ key={item.id}
74
+ type="button"
75
+ onClick={() => command(item)}
76
+ onMouseEnter={() => setSelectedIndex(i)}
77
+ className={`w-full flex items-center gap-2 px-3 py-1.5 text-left transition-colors ${
78
+ i === selectedIndex ? "bg-accent" : "hover:bg-accent/50"
79
+ }`}
80
+ >
81
+ {item.badge && (
82
+ <span className={`shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded ${item.badgeClass ?? "bg-muted text-muted-foreground"}`}>
83
+ {item.badge}
84
+ </span>
85
+ )}
86
+ <span className={`truncate text-xs ${item.mono ? "font-mono" : ""}`}>
87
+ {item.label}
88
+ </span>
89
+ {item.description && (
90
+ <span className="ml-auto shrink-0 text-[10px] text-muted-foreground/50">
91
+ {item.description}
92
+ </span>
93
+ )}
94
+ </button>
95
+ ))}
96
+ </div>
97
+ );
98
+ }
99
+
100
+ interface SuggestionListRef {
101
+ onKeyDown: (props: SuggestionKeyDownProps) => boolean;
102
+ }
103
+
104
+ const SuggestionListWrapper = forwardRef<SuggestionListRef, SuggestionProps<SuggestionEntry>>(
105
+ (props, ref) => {
106
+ const [selectedIndex, setSelectedIndex] = useState(0);
107
+
108
+ useEffect(() => {
109
+ setSelectedIndex(0);
110
+ }, [props.items]);
111
+
112
+ useImperativeHandle(ref, () => ({
113
+ onKeyDown: ({ event }: SuggestionKeyDownProps) => {
114
+ if (event.key === "ArrowUp") {
115
+ setSelectedIndex((i) => (i + props.items.length - 1) % props.items.length);
116
+ return true;
117
+ }
118
+ if (event.key === "ArrowDown") {
119
+ setSelectedIndex((i) => (i + 1) % props.items.length);
120
+ return true;
121
+ }
122
+ if (event.key === "Enter") {
123
+ const item = props.items[selectedIndex];
124
+ if (item) props.command(item);
125
+ return true;
126
+ }
127
+ if (event.key === "Escape") {
128
+ return true;
129
+ }
130
+ return false;
131
+ },
132
+ }));
133
+
134
+ return (
135
+ <SuggestionList
136
+ items={props.items}
137
+ command={props.command}
138
+ selectedIndex={selectedIndex}
139
+ setSelectedIndex={setSelectedIndex}
140
+ />
141
+ );
142
+ },
143
+ );
144
+
145
+ function createSuggestionRender(suggestionOpenRef: React.MutableRefObject<boolean>) {
146
+ return () => {
147
+ let renderer: ReactRenderer<SuggestionListRef> | null = null;
148
+ let popup: HTMLDivElement | null = null;
149
+
150
+ const positionPopup = (rect: DOMRect | null) => {
151
+ if (!rect || !popup) return;
152
+ const menuHeight = popup.offsetHeight || 200;
153
+ const spaceBelow = window.innerHeight - rect.bottom;
154
+ const fitsBelow = spaceBelow > menuHeight + 8;
155
+ popup.style.left = `${rect.left}px`;
156
+ if (fitsBelow) {
157
+ popup.style.top = `${rect.bottom + 4}px`;
158
+ popup.style.bottom = "";
159
+ } else {
160
+ popup.style.top = "";
161
+ popup.style.bottom = `${window.innerHeight - rect.top + 4}px`;
162
+ }
163
+ };
164
+
165
+ return {
166
+ onStart: (props: SuggestionProps<SuggestionEntry>) => {
167
+ suggestionOpenRef.current = true;
168
+ popup = document.createElement("div");
169
+ popup.style.position = "fixed";
170
+ popup.style.zIndex = "50";
171
+ document.body.appendChild(popup);
172
+
173
+ renderer = new ReactRenderer(SuggestionListWrapper, {
174
+ props,
175
+ editor: props.editor,
176
+ });
177
+ popup.appendChild(renderer.element);
178
+ positionPopup(props.clientRect?.() ?? null);
179
+ },
180
+ onUpdate: (props: SuggestionProps<SuggestionEntry>) => {
181
+ renderer?.updateProps(props);
182
+ positionPopup(props.clientRect?.() ?? null);
183
+ },
184
+ onKeyDown: (props: SuggestionKeyDownProps) => {
185
+ if (props.event.key === "Escape") {
186
+ popup?.remove();
187
+ renderer?.destroy();
188
+ popup = null;
189
+ renderer = null;
190
+ suggestionOpenRef.current = false;
191
+ return true;
192
+ }
193
+ return renderer?.ref?.onKeyDown(props) ?? false;
194
+ },
195
+ onExit: () => {
196
+ popup?.remove();
197
+ renderer?.destroy();
198
+ popup = null;
199
+ renderer = null;
200
+ suggestionOpenRef.current = false;
201
+ },
202
+ };
203
+ };
204
+ }
205
+
206
+ function getTextWithAnchors(editor: ReturnType<typeof useEditor>): string {
207
+ if (!editor) return "";
208
+ const doc = editor.state.doc;
209
+ const parts: string[] = [];
210
+
211
+ doc.descendants((node) => {
212
+ if (node.type.name === "anchorMention") {
213
+ const kind = node.attrs.kind ?? "file";
214
+ const id = node.attrs.id ?? node.attrs.label ?? "";
215
+ parts.push(`[[${kind}:${id}]]`);
216
+ return false;
217
+ }
218
+ if (node.type.name === "slashCommand") {
219
+ parts.push(`/${node.attrs.id ?? node.attrs.label ?? ""}`);
220
+ return false;
221
+ }
222
+ if (node.isText) {
223
+ parts.push(node.text ?? "");
224
+ }
225
+ if (node.type.name === "paragraph" && parts.length > 0) {
226
+ const last = parts[parts.length - 1];
227
+ if (last !== "\n") parts.push("\n");
228
+ }
229
+ return true;
230
+ });
231
+
232
+ return parts.join("").trim();
233
+ }
234
+
235
+ const AnchorMention = Mention.extend({ name: "anchorMention" });
236
+ const SlashCommand = Mention.extend({ name: "slashCommand" });
237
+
238
+ export function TipTapEditor({
239
+ content,
240
+ placeholder: placeholderText = "",
241
+ disabled = false,
242
+ autoFocus = false,
243
+ className = "",
244
+ onSubmit,
245
+ onChange,
246
+ submitOnEnter = false,
247
+ submitOnModEnter = false,
248
+ onEscape,
249
+ editorRef,
250
+ anchorItems,
251
+ commands,
252
+ }: TipTapEditorProps) {
253
+ const callbacksRef = useRef({ onSubmit, onChange, onEscape });
254
+ callbacksRef.current = { onSubmit, onChange, onEscape };
255
+ const anchorItemsRef = useRef(anchorItems);
256
+ anchorItemsRef.current = anchorItems;
257
+ const commandsRef = useRef(commands);
258
+ commandsRef.current = commands;
259
+ const suggestionOpenRef = useRef(false);
260
+
261
+ const editor = useEditor({
262
+ extensions: [
263
+ StarterKit.configure({
264
+ heading: false,
265
+ horizontalRule: false,
266
+ blockquote: false,
267
+ }),
268
+ Placeholder.configure({ placeholder: placeholderText }),
269
+ ...(anchorItems
270
+ ? [
271
+ AnchorMention.configure({
272
+ HTMLAttributes: { class: "mention-anchor" },
273
+ suggestion: {
274
+ char: "@",
275
+ pluginKey: new PluginKey("anchorMention"),
276
+ allowSpaces: true,
277
+ items: ({ query }: { query: string }) => {
278
+ const all = (anchorItemsRef.current ?? []).map((a) => ({
279
+ id: a.id,
280
+ label: a.label,
281
+ badge: a.kind === "group" ? "group" : "file",
282
+ badgeClass: a.kind === "group"
283
+ ? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
284
+ : "bg-muted text-muted-foreground",
285
+ mono: a.kind === "file",
286
+ kind: a.kind,
287
+ }));
288
+ if (!query) return all.slice(0, 12);
289
+ const q = query.toLowerCase();
290
+ return all.filter((item) => item.label.toLowerCase().includes(q)).slice(0, 12);
291
+ },
292
+ render: createSuggestionRender(suggestionOpenRef),
293
+ command: ({ editor: ed, range, props: attrs }) => {
294
+ const item = attrs as unknown as AnchorItem & SuggestionEntry;
295
+ ed.chain()
296
+ .focus()
297
+ .insertContentAt(range, [
298
+ {
299
+ type: "anchorMention",
300
+ attrs: { id: item.id, label: item.label, kind: item.kind ?? "file" },
301
+ },
302
+ { type: "text", text: " " },
303
+ ])
304
+ .run();
305
+ },
306
+ },
307
+ }).extend({
308
+ addAttributes() {
309
+ return {
310
+ ...this.parent?.(),
311
+ kind: { default: "file" },
312
+ };
313
+ },
314
+ }),
315
+ ]
316
+ : []),
317
+ ...(commands && commands.length > 0
318
+ ? [
319
+ SlashCommand.configure({
320
+ HTMLAttributes: { class: "mention-command" },
321
+ suggestion: {
322
+ char: "/",
323
+ pluginKey: new PluginKey("slashCommand"),
324
+ startOfLine: true,
325
+ items: ({ query }: { query: string }) => {
326
+ const all = (commandsRef.current ?? []).map((c) => ({
327
+ id: c.id,
328
+ label: `/${c.id}`,
329
+ description: c.description,
330
+ }));
331
+ if (!query) return all;
332
+ const q = query.toLowerCase();
333
+ return all.filter((item) => item.id.toLowerCase().includes(q));
334
+ },
335
+ render: createSuggestionRender(suggestionOpenRef),
336
+ command: ({ editor: ed, range, props: attrs }) => {
337
+ ed.chain()
338
+ .focus()
339
+ .deleteRange(range)
340
+ .insertContent(`/${attrs.id} `)
341
+ .run();
342
+ },
343
+ },
344
+ }),
345
+ ]
346
+ : []),
347
+ ],
348
+ editorProps: {
349
+ attributes: {
350
+ class: `outline-none min-h-[20px] ${className}`,
351
+ },
352
+ handleKeyDown: (_view, event) => {
353
+ if (suggestionOpenRef.current) return false;
354
+ if (event.key === "Escape" && callbacksRef.current.onEscape) {
355
+ event.preventDefault();
356
+ callbacksRef.current.onEscape();
357
+ return true;
358
+ }
359
+ if (submitOnEnter && event.key === "Enter" && !event.shiftKey && !event.metaKey && !event.ctrlKey) {
360
+ event.preventDefault();
361
+ callbacksRef.current.onSubmit?.();
362
+ return true;
363
+ }
364
+ if (submitOnModEnter && event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
365
+ event.preventDefault();
366
+ callbacksRef.current.onSubmit?.();
367
+ return true;
368
+ }
369
+ return false;
370
+ },
371
+ },
372
+ content: content ?? "",
373
+ editable: !disabled,
374
+ onUpdate: ({ editor: ed }) => {
375
+ callbacksRef.current.onChange?.(ed.getText());
376
+ },
377
+ });
378
+
379
+ useEffect(() => {
380
+ if (editorRef) editorRef.current = editor;
381
+ }, [editor, editorRef]);
382
+
383
+ useEffect(() => {
384
+ if (!editor) return;
385
+ editor.setEditable(!disabled);
386
+ }, [editor, disabled]);
387
+
388
+ useEffect(() => {
389
+ if (!editor || content === undefined) return;
390
+ const current = editor.getText();
391
+ if (content !== current) {
392
+ editor.commands.setContent(content ? `<p>${content.replace(/\n/g, "<br>")}</p>` : "");
393
+ }
394
+ }, [editor, content]);
395
+
396
+ useEffect(() => {
397
+ if (autoFocus && editor) {
398
+ setTimeout(() => editor.commands.focus("end"), 0);
399
+ }
400
+ }, [autoFocus, editor]);
401
+
402
+ return <EditorContent editor={editor} />;
403
+ }
404
+
405
+ export { getTextWithAnchors };