@cryptiklemur/lattice 1.3.0 → 1.5.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 (109) hide show
  1. package/bun.lock +776 -2
  2. package/client/index.html +1 -13
  3. package/client/package.json +7 -1
  4. package/client/src/App.tsx +2 -0
  5. package/client/src/commands.ts +36 -0
  6. package/client/src/components/analytics/AnalyticsView.tsx +61 -0
  7. package/client/src/components/analytics/ChartCard.tsx +22 -0
  8. package/client/src/components/analytics/PeriodSelector.tsx +42 -0
  9. package/client/src/components/analytics/QuickStats.tsx +99 -0
  10. package/client/src/components/analytics/charts/CostAreaChart.tsx +83 -0
  11. package/client/src/components/analytics/charts/CostDistributionChart.tsx +62 -0
  12. package/client/src/components/analytics/charts/CostDonutChart.tsx +93 -0
  13. package/client/src/components/analytics/charts/CumulativeCostChart.tsx +62 -0
  14. package/client/src/components/analytics/charts/SessionBubbleChart.tsx +122 -0
  15. package/client/src/components/chat/AttachmentChips.tsx +116 -0
  16. package/client/src/components/chat/ChatInput.tsx +250 -73
  17. package/client/src/components/chat/ChatView.tsx +242 -10
  18. package/client/src/components/chat/CommandPalette.tsx +162 -0
  19. package/client/src/components/chat/Message.tsx +23 -2
  20. package/client/src/components/chat/PromptQuestion.tsx +271 -0
  21. package/client/src/components/chat/TodoCard.tsx +57 -0
  22. package/client/src/components/chat/ToolResultRenderer.tsx +2 -1
  23. package/client/src/components/chat/VoiceRecorder.tsx +85 -0
  24. package/client/src/components/dashboard/DashboardView.tsx +5 -0
  25. package/client/src/components/project-settings/ProjectMemory.tsx +12 -2
  26. package/client/src/components/project-settings/ProjectNotifications.tsx +48 -0
  27. package/client/src/components/project-settings/ProjectRules.tsx +10 -1
  28. package/client/src/components/project-settings/ProjectSettingsView.tsx +6 -0
  29. package/client/src/components/settings/Appearance.tsx +1 -0
  30. package/client/src/components/settings/ClaudeSettings.tsx +10 -0
  31. package/client/src/components/settings/Editor.tsx +123 -0
  32. package/client/src/components/settings/GlobalMcp.tsx +10 -1
  33. package/client/src/components/settings/GlobalMemory.tsx +19 -0
  34. package/client/src/components/settings/GlobalRules.tsx +149 -0
  35. package/client/src/components/settings/GlobalSkills.tsx +10 -0
  36. package/client/src/components/settings/Notifications.tsx +88 -0
  37. package/client/src/components/settings/SettingsView.tsx +12 -0
  38. package/client/src/components/settings/skill-shared.tsx +2 -1
  39. package/client/src/components/setup/SetupWizard.tsx +1 -1
  40. package/client/src/components/sidebar/NodeSettingsModal.tsx +23 -1
  41. package/client/src/components/sidebar/ProjectDropdown.tsx +176 -27
  42. package/client/src/components/sidebar/SettingsSidebar.tsx +11 -1
  43. package/client/src/components/sidebar/Sidebar.tsx +43 -2
  44. package/client/src/components/sidebar/UserIsland.tsx +18 -7
  45. package/client/src/components/ui/UpdatePrompt.tsx +47 -0
  46. package/client/src/components/workspace/FileBrowser.tsx +174 -0
  47. package/client/src/components/workspace/FileTree.tsx +129 -0
  48. package/client/src/components/workspace/FileViewer.tsx +211 -0
  49. package/client/src/components/workspace/NoteCard.tsx +119 -0
  50. package/client/src/components/workspace/NotesView.tsx +102 -0
  51. package/client/src/components/workspace/ScheduledTasksView.tsx +117 -0
  52. package/client/src/components/workspace/SplitPane.tsx +81 -0
  53. package/client/src/components/workspace/TabBar.tsx +185 -0
  54. package/client/src/components/workspace/TaskCard.tsx +158 -0
  55. package/client/src/components/workspace/TaskEditModal.tsx +114 -0
  56. package/client/src/components/{panels/Terminal.tsx → workspace/TerminalInstance.tsx} +50 -7
  57. package/client/src/components/workspace/TerminalView.tsx +110 -0
  58. package/client/src/components/workspace/WorkspaceView.tsx +116 -0
  59. package/client/src/hooks/useAnalytics.ts +75 -0
  60. package/client/src/hooks/useAttachments.ts +280 -0
  61. package/client/src/hooks/useEditorConfig.ts +28 -0
  62. package/client/src/hooks/useIdleDetection.ts +44 -0
  63. package/client/src/hooks/useInstallPrompt.ts +53 -0
  64. package/client/src/hooks/useNotifications.ts +54 -0
  65. package/client/src/hooks/useOnline.ts +6 -0
  66. package/client/src/hooks/useSession.ts +110 -4
  67. package/client/src/hooks/useSpinnerVerb.ts +36 -0
  68. package/client/src/hooks/useSwipeDrawer.ts +275 -0
  69. package/client/src/hooks/useVoiceRecorder.ts +123 -0
  70. package/client/src/hooks/useWorkspace.ts +48 -0
  71. package/client/src/providers/WebSocketProvider.tsx +18 -0
  72. package/client/src/router.tsx +52 -20
  73. package/client/src/stores/analytics.ts +54 -0
  74. package/client/src/stores/session.ts +136 -0
  75. package/client/src/stores/sidebar.ts +11 -2
  76. package/client/src/stores/workspace.ts +254 -0
  77. package/client/src/styles/global.css +123 -0
  78. package/client/src/utils/editorUrl.ts +62 -0
  79. package/client/vite.config.ts +54 -1
  80. package/package.json +1 -1
  81. package/server/src/analytics/engine.ts +491 -0
  82. package/server/src/daemon.ts +12 -1
  83. package/server/src/features/scheduler.ts +23 -0
  84. package/server/src/features/sticky-notes.ts +5 -3
  85. package/server/src/handlers/analytics.ts +34 -0
  86. package/server/src/handlers/attachment.ts +172 -0
  87. package/server/src/handlers/chat.ts +43 -2
  88. package/server/src/handlers/editor.ts +40 -0
  89. package/server/src/handlers/fs.ts +10 -2
  90. package/server/src/handlers/memory.ts +3 -0
  91. package/server/src/handlers/notes.ts +4 -2
  92. package/server/src/handlers/scheduler.ts +18 -1
  93. package/server/src/handlers/session.ts +14 -8
  94. package/server/src/handlers/settings.ts +37 -2
  95. package/server/src/handlers/terminal.ts +13 -6
  96. package/server/src/project/pty-worker.cjs +83 -0
  97. package/server/src/project/sdk-bridge.ts +266 -11
  98. package/server/src/project/session.ts +4 -4
  99. package/server/src/project/terminal.ts +78 -34
  100. package/shared/src/analytics.ts +24 -0
  101. package/shared/src/index.ts +1 -0
  102. package/shared/src/messages.ts +173 -4
  103. package/shared/src/models.ts +27 -1
  104. package/shared/src/project-settings.ts +1 -1
  105. package/tp.js +19 -0
  106. package/client/public/manifest.json +0 -24
  107. package/client/public/sw.js +0 -61
  108. package/client/src/components/panels/FileBrowser.tsx +0 -241
  109. package/client/src/components/panels/StickyNotes.tsx +0 -187
@@ -0,0 +1,122 @@
1
+ import {
2
+ ScatterChart,
3
+ Scatter,
4
+ XAxis,
5
+ YAxis,
6
+ CartesianGrid,
7
+ Tooltip,
8
+ ResponsiveContainer,
9
+ ZAxis,
10
+ } from "recharts";
11
+
12
+ var TICK_STYLE = {
13
+ fontSize: 10,
14
+ fontFamily: "var(--font-mono)",
15
+ fill: "oklch(0.9 0.02 280 / 0.3)",
16
+ };
17
+
18
+ var GRID_COLOR = "oklch(0.9 0.02 280 / 0.06)";
19
+
20
+ var PROJECT_PALETTE = [
21
+ "oklch(55% 0.25 280)",
22
+ "#a855f7",
23
+ "#22c55e",
24
+ "#f59e0b",
25
+ "oklch(65% 0.2 240)",
26
+ "oklch(65% 0.25 25)",
27
+ "oklch(65% 0.25 150)",
28
+ "oklch(70% 0.2 60)",
29
+ ];
30
+
31
+ interface SessionBubbleDatum {
32
+ id: string;
33
+ title: string;
34
+ cost: number;
35
+ tokens: number;
36
+ timestamp: number;
37
+ project: string;
38
+ }
39
+
40
+ interface SessionBubbleChartProps {
41
+ data: SessionBubbleDatum[];
42
+ }
43
+
44
+ function formatDate(ts: number): string {
45
+ var d = new Date(ts);
46
+ return (d.getMonth() + 1) + "/" + d.getDate();
47
+ }
48
+
49
+ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: SessionBubbleDatum }> }) {
50
+ if (!active || !payload || payload.length === 0) return null;
51
+ var d = payload[0].payload;
52
+ return (
53
+ <div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg max-w-[180px]">
54
+ <p className="text-[10px] font-mono text-base-content/50 mb-1 truncate">{d.title || d.id}</p>
55
+ <div className="text-[11px] font-mono text-base-content/70 space-y-0.5">
56
+ <p><span className="text-base-content/40">cost </span>${d.cost.toFixed(4)}</p>
57
+ <p><span className="text-base-content/40">tokens </span>{d.tokens.toLocaleString()}</p>
58
+ <p><span className="text-base-content/40">project </span>{d.project}</p>
59
+ </div>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ export function SessionBubbleChart({ data }: SessionBubbleChartProps) {
65
+ var projects = Array.from(new Set(data.map(function (d) { return d.project; })));
66
+
67
+ function getColor(project: string): string {
68
+ var idx = projects.indexOf(project);
69
+ return PROJECT_PALETTE[idx % PROJECT_PALETTE.length];
70
+ }
71
+
72
+ var byProject = projects.map(function (project) {
73
+ return {
74
+ project,
75
+ color: getColor(project),
76
+ points: data
77
+ .filter(function (d) { return d.project === project; })
78
+ .map(function (d) { return { ...d, x: d.timestamp, y: d.tokens, z: Math.max(d.cost * 1000, 20) }; }),
79
+ };
80
+ });
81
+
82
+ var minTs = Math.min(...data.map(function (d) { return d.timestamp; }));
83
+ var maxTs = Math.max(...data.map(function (d) { return d.timestamp; }));
84
+
85
+ return (
86
+ <ResponsiveContainer width="100%" height={200}>
87
+ <ScatterChart margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
88
+ <CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} />
89
+ <XAxis
90
+ dataKey="x"
91
+ type="number"
92
+ domain={[minTs, maxTs]}
93
+ tick={TICK_STYLE}
94
+ axisLine={false}
95
+ tickLine={false}
96
+ tickFormatter={function (v) { return formatDate(v); }}
97
+ />
98
+ <YAxis
99
+ dataKey="y"
100
+ type="number"
101
+ tick={TICK_STYLE}
102
+ axisLine={false}
103
+ tickLine={false}
104
+ tickFormatter={function (v) { return v >= 1000 ? (v / 1000).toFixed(0) + "k" : String(v); }}
105
+ />
106
+ <ZAxis dataKey="z" range={[20, 300]} />
107
+ <Tooltip content={<CustomTooltip />} cursor={{ strokeDasharray: "3 3", stroke: GRID_COLOR }} />
108
+ {byProject.map(function (group) {
109
+ return (
110
+ <Scatter
111
+ key={group.project}
112
+ name={group.project}
113
+ data={group.points}
114
+ fill={group.color}
115
+ fillOpacity={0.7}
116
+ />
117
+ );
118
+ })}
119
+ </ScatterChart>
120
+ </ResponsiveContainer>
121
+ );
122
+ }
@@ -0,0 +1,116 @@
1
+ import { useState } from "react";
2
+ import { X, RotateCcw, Eye, EyeOff } from "lucide-react";
3
+ import type { ClientAttachment } from "../../hooks/useAttachments";
4
+
5
+ interface AttachmentChipsProps {
6
+ attachments: ClientAttachment[];
7
+ onRemove: (id: string) => void;
8
+ onRetry: (id: string) => void;
9
+ }
10
+
11
+ function formatSize(bytes: number): string {
12
+ if (bytes < 1024) return bytes + "B";
13
+ if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + "KB";
14
+ return (bytes / (1024 * 1024)).toFixed(1) + "MB";
15
+ }
16
+
17
+ function getExtBadge(name: string, type: string): string {
18
+ if (type === "paste") return "TXT";
19
+ var ext = name.split(".").pop()?.toUpperCase() || "";
20
+ return ext.slice(0, 4) || "FILE";
21
+ }
22
+
23
+ export function AttachmentChips(props: AttachmentChipsProps) {
24
+ var [expandedPaste, setExpandedPaste] = useState<string | null>(null);
25
+
26
+ if (props.attachments.length === 0) return null;
27
+
28
+ return (
29
+ <div className="flex flex-wrap gap-1.5 px-3 py-2 border-b border-base-content/8" role="list" aria-label="Attachments">
30
+ {props.attachments.map(function (att) {
31
+ var isFailed = att.status === "failed";
32
+ var isUploading = att.status === "uploading";
33
+
34
+ return (
35
+ <div key={att.id} role="listitem" className="relative">
36
+ <div
37
+ className={
38
+ "relative flex items-center gap-1.5 rounded-lg px-2 py-1 text-[12px] overflow-hidden transition-colors " +
39
+ (isFailed
40
+ ? "bg-error/10 border border-error/30 text-error/80"
41
+ : "bg-base-content/5 border border-base-content/10 text-base-content/70")
42
+ }
43
+ >
44
+ {isUploading && (
45
+ <div
46
+ className="absolute left-0 top-0 bottom-0 bg-primary/10 transition-all duration-300"
47
+ style={{ width: att.progress + "%" }}
48
+ />
49
+ )}
50
+
51
+ {att.type === "image" && att.previewUrl ? (
52
+ <img
53
+ src={att.previewUrl}
54
+ alt=""
55
+ className="relative w-7 h-7 rounded object-cover flex-shrink-0"
56
+ />
57
+ ) : (
58
+ <span className="relative font-mono text-[10px] text-primary/60 flex-shrink-0">
59
+ {getExtBadge(att.name, att.type)}
60
+ </span>
61
+ )}
62
+
63
+ <span className="relative truncate max-w-[120px]">{att.name}</span>
64
+
65
+ {att.type === "paste" && att.lineCount ? (
66
+ <span className="relative text-[10px] text-base-content/30">{att.lineCount} lines</span>
67
+ ) : isUploading ? (
68
+ <span className="relative text-[10px] text-primary/50">{att.progress}%</span>
69
+ ) : (
70
+ <span className="relative text-[10px] text-base-content/30">{formatSize(att.size)}</span>
71
+ )}
72
+
73
+ {att.type === "paste" && att.content && (
74
+ <button
75
+ onClick={function () {
76
+ setExpandedPaste(expandedPaste === att.id ? null : att.id);
77
+ }}
78
+ className="relative text-[10px] text-primary/50 hover:text-primary/80 underline"
79
+ aria-label={expandedPaste === att.id ? "Hide preview" : "Show preview"}
80
+ >
81
+ {expandedPaste === att.id ? <EyeOff size={10} /> : <Eye size={10} />}
82
+ </button>
83
+ )}
84
+
85
+ {isFailed && (
86
+ <button
87
+ onClick={function () { props.onRetry(att.id); }}
88
+ className="relative text-error/60 hover:text-error/90"
89
+ aria-label={"Retry " + att.name}
90
+ title={att.error}
91
+ >
92
+ <RotateCcw size={10} />
93
+ </button>
94
+ )}
95
+
96
+ <button
97
+ onClick={function () { props.onRemove(att.id); }}
98
+ className="relative text-base-content/30 hover:text-base-content/60"
99
+ aria-label={"Remove " + att.name}
100
+ >
101
+ <X size={12} />
102
+ </button>
103
+ </div>
104
+
105
+ {expandedPaste === att.id && att.content && (
106
+ <div className="absolute left-0 bottom-full mb-1 w-[400px] max-h-[200px] overflow-auto rounded-lg border border-base-content/10 bg-base-300 shadow-lg z-50 p-2 font-mono text-[11px] text-base-content/50 whitespace-pre">
107
+ {att.content.slice(0, 2000)}
108
+ {att.content.length > 2000 && "\n... (" + (att.content.length - 2000) + " more characters)"}
109
+ </div>
110
+ )}
111
+ </div>
112
+ );
113
+ })}
114
+ </div>
115
+ );
116
+ }
@@ -1,11 +1,21 @@
1
1
  import { useRef, useState, useEffect, useMemo } from "react";
2
- import { SendHorizontal, Settings } from "lucide-react";
2
+ import { SendHorizontal, Settings, Paperclip } from "lucide-react";
3
3
  import { useSkills } from "../../hooks/useSkills";
4
+ import { CommandPalette, getFilteredItems } from "./CommandPalette";
5
+ import { useAttachments } from "../../hooks/useAttachments";
6
+ import { useVoiceRecorder } from "../../hooks/useVoiceRecorder";
7
+ import { AttachmentChips } from "./AttachmentChips";
8
+ import { VoiceRecorder } from "./VoiceRecorder";
4
9
 
5
10
  interface ChatInputProps {
6
- onSend: (text: string) => void;
11
+ onSend: (text: string, attachmentIds: string[]) => void;
7
12
  disabled: boolean;
13
+ disabledPlaceholder?: string;
8
14
  toolbarContent?: React.ReactNode;
15
+ failedInput?: string | null;
16
+ onFailedInputConsumed?: () => void;
17
+ prefillText?: string | null;
18
+ onPrefillConsumed?: () => void;
9
19
  }
10
20
 
11
21
  function getModKey(): string {
@@ -26,15 +36,19 @@ export function ChatInput(props: ChatInputProps) {
26
36
  var [showMobileSettings, setShowMobileSettings] = useState(false);
27
37
  var modKey = useMemo(getModKey, []);
28
38
 
29
- var filtered = useMemo(function () {
30
- if (slashQuery === null) return [];
31
- var q = slashQuery.toLowerCase();
32
- return skills.filter(function (s) {
33
- return s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q);
34
- });
39
+ var attachmentsHook = useAttachments();
40
+ var voice = useVoiceRecorder();
41
+ var [isDragging, setIsDragging] = useState(false);
42
+ var dragCounter = useRef(0);
43
+ var fileInputRef = useRef<HTMLInputElement>(null);
44
+ var savedTextRef = useRef("");
45
+
46
+ var itemCount = useMemo(function () {
47
+ if (slashQuery === null) return 0;
48
+ return getFilteredItems(slashQuery, skills).length;
35
49
  }, [slashQuery, skills]);
36
50
 
37
- var isOpen = slashQuery !== null && filtered.length > 0;
51
+ var isOpen = slashQuery !== null && itemCount > 0;
38
52
 
39
53
  useEffect(function () {
40
54
  setSelectedIndex(0);
@@ -60,6 +74,34 @@ export function ChatInput(props: ChatInputProps) {
60
74
  return function () { document.removeEventListener("mousedown", handleClick); };
61
75
  }, [showMobileSettings]);
62
76
 
77
+ useEffect(function () {
78
+ if (props.failedInput && textareaRef.current) {
79
+ var el = textareaRef.current;
80
+ el.value = props.failedInput;
81
+ el.style.height = "auto";
82
+ el.style.height = Math.min(el.scrollHeight, 160) + "px";
83
+ el.focus();
84
+ checkSlash();
85
+ if (props.onFailedInputConsumed) {
86
+ props.onFailedInputConsumed();
87
+ }
88
+ }
89
+ }, [props.failedInput]);
90
+
91
+ useEffect(function () {
92
+ if (props.prefillText && textareaRef.current) {
93
+ var el = textareaRef.current;
94
+ el.value = props.prefillText;
95
+ el.style.height = "auto";
96
+ el.style.height = Math.min(el.scrollHeight, 160) + "px";
97
+ el.focus();
98
+ checkSlash();
99
+ if (props.onPrefillConsumed) {
100
+ props.onPrefillConsumed();
101
+ }
102
+ }
103
+ }, [props.prefillText]);
104
+
63
105
  function checkSlash() {
64
106
  var el = textareaRef.current;
65
107
  if (!el) return;
@@ -71,30 +113,42 @@ export function ChatInput(props: ChatInputProps) {
71
113
  }
72
114
  }
73
115
 
74
- function selectSkill(name: string) {
116
+ function selectItem(item: { name: string; args?: string; category: string; handler: string }) {
75
117
  var el = textareaRef.current;
76
118
  if (!el) return;
77
- el.value = "/" + name + " ";
78
- el.focus();
79
- setSlashQuery(null);
119
+
120
+ var hasArgs = !!item.args;
121
+ var isSkill = item.category === "skill";
122
+
123
+ if (hasArgs || isSkill) {
124
+ el.value = "/" + item.name + " ";
125
+ el.focus();
126
+ setSlashQuery(null);
127
+ } else {
128
+ props.onSend("/" + item.name, []);
129
+ el.value = "";
130
+ el.style.height = "auto";
131
+ setSlashQuery(null);
132
+ }
80
133
  }
81
134
 
82
135
  function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
83
136
  if (isOpen) {
84
137
  if (e.key === "ArrowUp") {
85
138
  e.preventDefault();
86
- setSelectedIndex(function (i) { return i > 0 ? i - 1 : filtered.length - 1; });
139
+ setSelectedIndex(function (i) { return i > 0 ? i - 1 : itemCount - 1; });
87
140
  return;
88
141
  }
89
142
  if (e.key === "ArrowDown") {
90
143
  e.preventDefault();
91
- setSelectedIndex(function (i) { return i < filtered.length - 1 ? i + 1 : 0; });
144
+ setSelectedIndex(function (i) { return i < itemCount - 1 ? i + 1 : 0; });
92
145
  return;
93
146
  }
94
147
  if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
95
148
  e.preventDefault();
96
- if (filtered[selectedIndex]) {
97
- selectSkill(filtered[selectedIndex].name);
149
+ var items = getFilteredItems(slashQuery!, skills);
150
+ if (items[selectedIndex]) {
151
+ selectItem(items[selectedIndex]);
98
152
  }
99
153
  return;
100
154
  }
@@ -117,54 +171,112 @@ export function ChatInput(props: ChatInputProps) {
117
171
  checkSlash();
118
172
  }
119
173
 
174
+ function handlePaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
175
+ var items = e.clipboardData.items;
176
+ for (var i = 0; i < items.length; i++) {
177
+ if (items[i].type.startsWith("image/")) {
178
+ e.preventDefault();
179
+ var file = items[i].getAsFile();
180
+ if (file && attachmentsHook.canAttach) {
181
+ attachmentsHook.addFile(file);
182
+ }
183
+ return;
184
+ }
185
+ }
186
+
187
+ var text = e.clipboardData.getData("text/plain");
188
+ if (text && text.split("\n").length >= 10) {
189
+ e.preventDefault();
190
+ if (attachmentsHook.canAttach) {
191
+ attachmentsHook.addPaste(text);
192
+ }
193
+ }
194
+ }
195
+
196
+ function handleDragEnter(e: React.DragEvent) {
197
+ e.preventDefault();
198
+ dragCounter.current++;
199
+ if (e.dataTransfer.types.indexOf("Files") !== -1) {
200
+ setIsDragging(true);
201
+ }
202
+ }
203
+
204
+ function handleDragLeave(e: React.DragEvent) {
205
+ e.preventDefault();
206
+ dragCounter.current--;
207
+ if (dragCounter.current === 0) {
208
+ setIsDragging(false);
209
+ }
210
+ }
211
+
212
+ function handleDragOver(e: React.DragEvent) {
213
+ e.preventDefault();
214
+ }
215
+
216
+ function handleDrop(e: React.DragEvent) {
217
+ e.preventDefault();
218
+ dragCounter.current = 0;
219
+ setIsDragging(false);
220
+ var files = e.dataTransfer.files;
221
+ for (var i = 0; i < files.length; i++) {
222
+ if (attachmentsHook.canAttach) {
223
+ attachmentsHook.addFile(files[i]);
224
+ }
225
+ }
226
+ }
227
+
228
+ function handleVoiceStart() {
229
+ savedTextRef.current = textareaRef.current?.value || "";
230
+ voice.start();
231
+ }
232
+
233
+ function handleVoiceStop() {
234
+ var transcript = voice.stop();
235
+ if (transcript && textareaRef.current) {
236
+ var el = textareaRef.current;
237
+ var existing = savedTextRef.current;
238
+ el.value = existing ? existing + " " + transcript : transcript;
239
+ el.style.height = "auto";
240
+ el.style.height = Math.min(el.scrollHeight, 160) + "px";
241
+ }
242
+ }
243
+
244
+ function handleVoiceCancel() {
245
+ voice.cancel();
246
+ if (textareaRef.current) {
247
+ textareaRef.current.value = savedTextRef.current;
248
+ }
249
+ }
250
+
120
251
  function submit() {
121
252
  var el = textareaRef.current;
122
- if (!el) {
123
- return;
124
- }
253
+ if (!el) return;
125
254
  var text = el.value.trim();
126
- if (!text || props.disabled) {
127
- return;
128
- }
129
- props.onSend(text);
255
+ if ((!text && attachmentsHook.attachments.length === 0) || props.disabled || attachmentsHook.hasUploading) return;
256
+ props.onSend(text, attachmentsHook.readyIds);
130
257
  el.value = "";
131
258
  el.style.height = "auto";
132
259
  setSlashQuery(null);
260
+ attachmentsHook.clearAll();
133
261
  }
134
262
 
135
263
  return (
136
- <div className="relative">
264
+ <div
265
+ className="relative"
266
+ onDragEnter={handleDragEnter}
267
+ onDragLeave={handleDragLeave}
268
+ onDragOver={handleDragOver}
269
+ onDrop={handleDrop}
270
+ >
137
271
  {isOpen && (
138
- <div
139
- ref={popupRef}
140
- role="listbox"
141
- aria-label="Slash commands"
142
- className="absolute left-0 right-0 bottom-[calc(100%+6px)] max-h-[320px] overflow-y-auto rounded-lg border border-base-content/10 bg-base-300 shadow-lg z-50"
143
- >
144
- {filtered.map(function (skill, i) {
145
- return (
146
- <button
147
- key={skill.name}
148
- data-active={i === selectedIndex}
149
- onMouseDown={function (e) {
150
- e.preventDefault();
151
- selectSkill(skill.name);
152
- }}
153
- onMouseEnter={function () { setSelectedIndex(i); }}
154
- className={
155
- "flex w-full items-center gap-3 px-3.5 py-2.5 text-left transition-colors " +
156
- (i === selectedIndex ? "bg-primary/10" : "hover:bg-base-content/5")
157
- }
158
- >
159
- <span className="font-mono text-[12px] text-primary/90 whitespace-nowrap flex-shrink-0">
160
- /{skill.name}
161
- </span>
162
- <span className="text-[11px] text-base-content/40 truncate min-w-0">
163
- {skill.description}
164
- </span>
165
- </button>
166
- );
167
- })}
272
+ <div ref={popupRef}>
273
+ <CommandPalette
274
+ query={slashQuery!}
275
+ skills={skills}
276
+ selectedIndex={selectedIndex}
277
+ onSelect={selectItem}
278
+ onHover={setSelectedIndex}
279
+ />
168
280
  </div>
169
281
  )}
170
282
 
@@ -179,12 +291,32 @@ export function ChatInput(props: ChatInputProps) {
179
291
  </div>
180
292
  )}
181
293
 
294
+ <input
295
+ ref={fileInputRef}
296
+ type="file"
297
+ multiple
298
+ className="hidden"
299
+ onChange={function (e) {
300
+ var files = e.target.files;
301
+ if (files) {
302
+ for (var i = 0; i < files.length; i++) {
303
+ if (attachmentsHook.canAttach) {
304
+ attachmentsHook.addFile(files[i]);
305
+ }
306
+ }
307
+ }
308
+ e.target.value = "";
309
+ }}
310
+ />
311
+
182
312
  <div
183
313
  className={
184
314
  "border rounded-xl bg-base-300/60 overflow-hidden transition-all duration-150 " +
185
- (props.disabled
186
- ? "border-base-content/10 opacity-60"
187
- : "border-primary/20 focus-within:border-primary/40 focus-within:shadow-[0_0_0_3px_oklch(from_var(--color-primary)_l_c_h/0.1)]")
315
+ (isDragging
316
+ ? "border-primary/40 shadow-[0_0_0_3px_oklch(from_var(--color-primary)_l_c_h/0.1)]"
317
+ : props.disabled
318
+ ? "border-base-content/10 opacity-60"
319
+ : "border-primary/20 focus-within:border-primary/40 focus-within:shadow-[0_0_0_3px_oklch(from_var(--color-primary)_l_c_h/0.1)]")
188
320
  }
189
321
  >
190
322
  <div className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 border-b border-base-content/8 font-mono text-[10px]">
@@ -192,24 +324,62 @@ export function ChatInput(props: ChatInputProps) {
192
324
  <span className="flex-1" />
193
325
  <span className="text-base-content/20">{modKey}+K commands</span>
194
326
  </div>
327
+
328
+ <AttachmentChips
329
+ attachments={attachmentsHook.attachments}
330
+ onRemove={attachmentsHook.removeAttachment}
331
+ onRetry={attachmentsHook.retryAttachment}
332
+ />
333
+
195
334
  <div className="flex items-center gap-2 px-3.5 py-2.5">
196
- <div className="flex-1 min-w-0 relative">
197
- <span className="absolute left-0 top-[1px] text-primary/50 font-mono text-[14px] leading-relaxed select-none pointer-events-none">›</span>
198
- <textarea
199
- ref={textareaRef}
200
- aria-label="Message input"
201
- placeholder={props.disabled ? "Claude is responding..." : "Message Claude..."}
202
- disabled={props.disabled}
203
- onKeyDown={handleKeyDown}
204
- onInput={handleInput}
205
- rows={1}
206
- style={{ padding: "1px 0 0 16px", margin: 0, border: "none" }}
335
+ <div className="flex gap-1 flex-shrink-0">
336
+ <button
337
+ aria-label="Attach file"
338
+ disabled={!attachmentsHook.canAttach}
339
+ onClick={function () { fileInputRef.current?.click(); }}
207
340
  className={
208
- "w-full resize-none bg-transparent text-base-content text-[14px] leading-relaxed max-h-[160px] overflow-y-auto outline-none placeholder:text-base-content/30 " +
209
- (props.disabled ? "cursor-not-allowed" : "cursor-text")
341
+ "w-7 h-7 rounded-md flex items-center justify-center transition-colors " +
342
+ (attachmentsHook.canAttach
343
+ ? "text-base-content/30 hover:text-base-content/50 border border-base-content/10 hover:border-base-content/20"
344
+ : "text-base-content/15 cursor-not-allowed")
210
345
  }
346
+ title={attachmentsHook.canAttach ? "Attach file" : "Maximum attachments reached"}
347
+ >
348
+ <Paperclip size={13} />
349
+ </button>
350
+ <VoiceRecorder
351
+ isRecording={voice.isRecording}
352
+ isSupported={voice.isSupported}
353
+ isSpeaking={voice.isSpeaking}
354
+ elapsed={voice.elapsed}
355
+ interimTranscript={voice.interimTranscript}
356
+ onStart={handleVoiceStart}
357
+ onStop={handleVoiceStop}
358
+ onCancel={handleVoiceCancel}
211
359
  />
212
360
  </div>
361
+
362
+ {voice.isRecording ? null : (
363
+ <div className="flex-1 min-w-0 relative">
364
+ <span className="absolute left-0 top-[1px] text-primary/50 font-mono text-[14px] leading-relaxed select-none pointer-events-none">›</span>
365
+ <textarea
366
+ ref={textareaRef}
367
+ aria-label="Message input"
368
+ placeholder={props.disabled ? (props.disabledPlaceholder || "Claude is responding...") : "Message Claude..."}
369
+ disabled={props.disabled}
370
+ onKeyDown={handleKeyDown}
371
+ onInput={handleInput}
372
+ onPaste={handlePaste}
373
+ rows={1}
374
+ style={{ padding: "1px 0 0 16px", margin: 0, border: "none" }}
375
+ className={
376
+ "w-full resize-none bg-transparent text-base-content text-[14px] leading-relaxed max-h-[160px] overflow-y-auto outline-none placeholder:text-base-content/30 " +
377
+ (props.disabled ? "cursor-not-allowed" : "cursor-text")
378
+ }
379
+ />
380
+ </div>
381
+ )}
382
+
213
383
  <div className="flex items-center gap-1.5 flex-shrink-0">
214
384
  <button
215
385
  ref={settingsBtnRef}
@@ -222,11 +392,12 @@ export function ChatInput(props: ChatInputProps) {
222
392
  <span className="text-[10px] text-base-content/20 font-mono hidden sm:block">⏎ send</span>
223
393
  <button
224
394
  aria-label="Send message"
225
- disabled={props.disabled}
395
+ disabled={props.disabled || attachmentsHook.hasUploading}
226
396
  onClick={submit}
397
+ title={attachmentsHook.hasUploading ? "Uploading..." : "Send message"}
227
398
  className={
228
399
  "w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 transition-all duration-150 outline-none " +
229
- (props.disabled
400
+ (props.disabled || attachmentsHook.hasUploading
230
401
  ? "bg-base-content/5 text-base-content/20 cursor-not-allowed"
231
402
  : "bg-primary text-primary-content hover:bg-primary/80 cursor-pointer focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-base-300")
232
403
  }
@@ -236,6 +407,12 @@ export function ChatInput(props: ChatInputProps) {
236
407
  </div>
237
408
  </div>
238
409
  </div>
410
+
411
+ {isDragging && (
412
+ <div className="absolute inset-0 rounded-xl border-2 border-dashed border-primary/40 bg-primary/5 flex items-center justify-center z-40 pointer-events-none">
413
+ <span className="text-[13px] text-primary/60 font-mono">Drop files to attach</span>
414
+ </div>
415
+ )}
239
416
  </div>
240
417
  );
241
418
  }