@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.
- package/bun.lock +776 -2
- package/client/index.html +1 -13
- package/client/package.json +7 -1
- package/client/src/App.tsx +2 -0
- package/client/src/commands.ts +36 -0
- package/client/src/components/analytics/AnalyticsView.tsx +61 -0
- package/client/src/components/analytics/ChartCard.tsx +22 -0
- package/client/src/components/analytics/PeriodSelector.tsx +42 -0
- package/client/src/components/analytics/QuickStats.tsx +99 -0
- package/client/src/components/analytics/charts/CostAreaChart.tsx +83 -0
- package/client/src/components/analytics/charts/CostDistributionChart.tsx +62 -0
- package/client/src/components/analytics/charts/CostDonutChart.tsx +93 -0
- package/client/src/components/analytics/charts/CumulativeCostChart.tsx +62 -0
- package/client/src/components/analytics/charts/SessionBubbleChart.tsx +122 -0
- package/client/src/components/chat/AttachmentChips.tsx +116 -0
- package/client/src/components/chat/ChatInput.tsx +250 -73
- package/client/src/components/chat/ChatView.tsx +242 -10
- package/client/src/components/chat/CommandPalette.tsx +162 -0
- package/client/src/components/chat/Message.tsx +23 -2
- package/client/src/components/chat/PromptQuestion.tsx +271 -0
- package/client/src/components/chat/TodoCard.tsx +57 -0
- package/client/src/components/chat/ToolResultRenderer.tsx +2 -1
- package/client/src/components/chat/VoiceRecorder.tsx +85 -0
- package/client/src/components/dashboard/DashboardView.tsx +5 -0
- package/client/src/components/project-settings/ProjectMemory.tsx +12 -2
- package/client/src/components/project-settings/ProjectNotifications.tsx +48 -0
- package/client/src/components/project-settings/ProjectRules.tsx +10 -1
- package/client/src/components/project-settings/ProjectSettingsView.tsx +6 -0
- package/client/src/components/settings/Appearance.tsx +1 -0
- package/client/src/components/settings/ClaudeSettings.tsx +10 -0
- package/client/src/components/settings/Editor.tsx +123 -0
- package/client/src/components/settings/GlobalMcp.tsx +10 -1
- package/client/src/components/settings/GlobalMemory.tsx +19 -0
- package/client/src/components/settings/GlobalRules.tsx +149 -0
- package/client/src/components/settings/GlobalSkills.tsx +10 -0
- package/client/src/components/settings/Notifications.tsx +88 -0
- package/client/src/components/settings/SettingsView.tsx +12 -0
- package/client/src/components/settings/skill-shared.tsx +2 -1
- package/client/src/components/setup/SetupWizard.tsx +1 -1
- package/client/src/components/sidebar/NodeSettingsModal.tsx +23 -1
- package/client/src/components/sidebar/ProjectDropdown.tsx +176 -27
- package/client/src/components/sidebar/SettingsSidebar.tsx +11 -1
- package/client/src/components/sidebar/Sidebar.tsx +43 -2
- package/client/src/components/sidebar/UserIsland.tsx +18 -7
- package/client/src/components/ui/UpdatePrompt.tsx +47 -0
- package/client/src/components/workspace/FileBrowser.tsx +174 -0
- package/client/src/components/workspace/FileTree.tsx +129 -0
- package/client/src/components/workspace/FileViewer.tsx +211 -0
- package/client/src/components/workspace/NoteCard.tsx +119 -0
- package/client/src/components/workspace/NotesView.tsx +102 -0
- package/client/src/components/workspace/ScheduledTasksView.tsx +117 -0
- package/client/src/components/workspace/SplitPane.tsx +81 -0
- package/client/src/components/workspace/TabBar.tsx +185 -0
- package/client/src/components/workspace/TaskCard.tsx +158 -0
- package/client/src/components/workspace/TaskEditModal.tsx +114 -0
- package/client/src/components/{panels/Terminal.tsx → workspace/TerminalInstance.tsx} +50 -7
- package/client/src/components/workspace/TerminalView.tsx +110 -0
- package/client/src/components/workspace/WorkspaceView.tsx +116 -0
- package/client/src/hooks/useAnalytics.ts +75 -0
- package/client/src/hooks/useAttachments.ts +280 -0
- package/client/src/hooks/useEditorConfig.ts +28 -0
- package/client/src/hooks/useIdleDetection.ts +44 -0
- package/client/src/hooks/useInstallPrompt.ts +53 -0
- package/client/src/hooks/useNotifications.ts +54 -0
- package/client/src/hooks/useOnline.ts +6 -0
- package/client/src/hooks/useSession.ts +110 -4
- package/client/src/hooks/useSpinnerVerb.ts +36 -0
- package/client/src/hooks/useSwipeDrawer.ts +275 -0
- package/client/src/hooks/useVoiceRecorder.ts +123 -0
- package/client/src/hooks/useWorkspace.ts +48 -0
- package/client/src/providers/WebSocketProvider.tsx +18 -0
- package/client/src/router.tsx +52 -20
- package/client/src/stores/analytics.ts +54 -0
- package/client/src/stores/session.ts +136 -0
- package/client/src/stores/sidebar.ts +11 -2
- package/client/src/stores/workspace.ts +254 -0
- package/client/src/styles/global.css +123 -0
- package/client/src/utils/editorUrl.ts +62 -0
- package/client/vite.config.ts +54 -1
- package/package.json +1 -1
- package/server/src/analytics/engine.ts +491 -0
- package/server/src/daemon.ts +12 -1
- package/server/src/features/scheduler.ts +23 -0
- package/server/src/features/sticky-notes.ts +5 -3
- package/server/src/handlers/analytics.ts +34 -0
- package/server/src/handlers/attachment.ts +172 -0
- package/server/src/handlers/chat.ts +43 -2
- package/server/src/handlers/editor.ts +40 -0
- package/server/src/handlers/fs.ts +10 -2
- package/server/src/handlers/memory.ts +3 -0
- package/server/src/handlers/notes.ts +4 -2
- package/server/src/handlers/scheduler.ts +18 -1
- package/server/src/handlers/session.ts +14 -8
- package/server/src/handlers/settings.ts +37 -2
- package/server/src/handlers/terminal.ts +13 -6
- package/server/src/project/pty-worker.cjs +83 -0
- package/server/src/project/sdk-bridge.ts +266 -11
- package/server/src/project/session.ts +4 -4
- package/server/src/project/terminal.ts +78 -34
- package/shared/src/analytics.ts +24 -0
- package/shared/src/index.ts +1 -0
- package/shared/src/messages.ts +173 -4
- package/shared/src/models.ts +27 -1
- package/shared/src/project-settings.ts +1 -1
- package/tp.js +19 -0
- package/client/public/manifest.json +0 -24
- package/client/public/sw.js +0 -61
- package/client/src/components/panels/FileBrowser.tsx +0 -241
- 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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 &&
|
|
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
|
|
116
|
+
function selectItem(item: { name: string; args?: string; category: string; handler: string }) {
|
|
75
117
|
var el = textareaRef.current;
|
|
76
118
|
if (!el) return;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 :
|
|
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 <
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
264
|
+
<div
|
|
265
|
+
className="relative"
|
|
266
|
+
onDragEnter={handleDragEnter}
|
|
267
|
+
onDragLeave={handleDragLeave}
|
|
268
|
+
onDragOver={handleDragOver}
|
|
269
|
+
onDrop={handleDrop}
|
|
270
|
+
>
|
|
137
271
|
{isOpen && (
|
|
138
|
-
<div
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
(
|
|
186
|
-
? "border-
|
|
187
|
-
:
|
|
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
|
|
197
|
-
<
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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-
|
|
209
|
-
(
|
|
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
|
}
|