@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,110 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Plus, X } from "lucide-react";
|
|
3
|
+
import { TerminalInstance } from "./TerminalInstance";
|
|
4
|
+
|
|
5
|
+
interface TerminalTab {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
var nextTermNum = 1;
|
|
11
|
+
|
|
12
|
+
function makeTab(): TerminalTab {
|
|
13
|
+
var num = nextTermNum++;
|
|
14
|
+
return { id: `term-${num}-${Date.now()}`, label: `Terminal ${num}` };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function TerminalView() {
|
|
18
|
+
var initialTab = makeTab();
|
|
19
|
+
var [tabs, setTabs] = useState<TerminalTab[]>([initialTab]);
|
|
20
|
+
var [activeId, setActiveId] = useState<string>(initialTab.id);
|
|
21
|
+
|
|
22
|
+
function addTab() {
|
|
23
|
+
var tab = makeTab();
|
|
24
|
+
setTabs(function(prev) { return [...prev, tab]; });
|
|
25
|
+
setActiveId(tab.id);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function closeTab(id: string) {
|
|
29
|
+
setTabs(function(prev) {
|
|
30
|
+
if (prev.length === 1) {
|
|
31
|
+
var replacement = makeTab();
|
|
32
|
+
setActiveId(replacement.id);
|
|
33
|
+
return [replacement];
|
|
34
|
+
}
|
|
35
|
+
var next = prev.filter(function(t) { return t.id !== id; });
|
|
36
|
+
if (id === activeId) {
|
|
37
|
+
var idx = prev.findIndex(function(t) { return t.id === id; });
|
|
38
|
+
var newActive = next[Math.min(idx, next.length - 1)];
|
|
39
|
+
setActiveId(newActive.id);
|
|
40
|
+
}
|
|
41
|
+
return next;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="flex flex-col h-full w-full overflow-hidden">
|
|
47
|
+
<div role="tablist" className="flex items-center h-8 bg-base-200 border-b border-base-content/15 flex-shrink-0 overflow-x-auto">
|
|
48
|
+
{tabs.map(function(tab) {
|
|
49
|
+
var isActive = tab.id === activeId;
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
key={tab.id}
|
|
53
|
+
tabIndex={0}
|
|
54
|
+
role="tab"
|
|
55
|
+
aria-selected={isActive}
|
|
56
|
+
className={[
|
|
57
|
+
"flex items-center gap-1 px-3 h-full text-[12px] cursor-pointer select-none border-r border-base-content/15 flex-shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:ring-offset-base-200",
|
|
58
|
+
isActive
|
|
59
|
+
? "bg-base-100 text-base-content"
|
|
60
|
+
: "text-base-content/50 hover:text-base-content hover:bg-base-100/50",
|
|
61
|
+
].join(" ")}
|
|
62
|
+
onClick={function() { setActiveId(tab.id); }}
|
|
63
|
+
onKeyDown={function(e) {
|
|
64
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
setActiveId(tab.id);
|
|
67
|
+
}
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<span>{tab.label}</span>
|
|
71
|
+
{tabs.length > 1 && (
|
|
72
|
+
<button
|
|
73
|
+
className="ml-1 rounded hover:bg-base-300 p-1 sm:p-0.5 outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:ring-offset-base-200"
|
|
74
|
+
aria-label={"Close " + tab.label}
|
|
75
|
+
onClick={function(e) {
|
|
76
|
+
e.stopPropagation();
|
|
77
|
+
closeTab(tab.id);
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<X className="!size-3" />
|
|
81
|
+
</button>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
})}
|
|
86
|
+
<button
|
|
87
|
+
className="flex items-center justify-center w-10 sm:w-8 h-full text-base-content/50 hover:text-base-content hover:bg-base-100/50 flex-shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:ring-offset-base-200"
|
|
88
|
+
aria-label="New terminal"
|
|
89
|
+
onClick={addTab}
|
|
90
|
+
title="New terminal"
|
|
91
|
+
>
|
|
92
|
+
<Plus className="!size-4" />
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
<div className="flex-1 min-h-0 relative">
|
|
96
|
+
{tabs.map(function(tab) {
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
key={tab.id}
|
|
100
|
+
className="absolute inset-0"
|
|
101
|
+
style={{ display: tab.id === activeId ? "block" : "none" }}
|
|
102
|
+
>
|
|
103
|
+
<TerminalInstance instanceId={tab.id} visible={tab.id === activeId} />
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
})}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { WifiOff } from "lucide-react";
|
|
3
|
+
import { useWorkspace } from "../../hooks/useWorkspace";
|
|
4
|
+
import { useOnline } from "../../hooks/useOnline";
|
|
5
|
+
import { TabBar } from "./TabBar";
|
|
6
|
+
import { SplitPane } from "./SplitPane";
|
|
7
|
+
import { ChatView } from "../chat/ChatView";
|
|
8
|
+
import { TerminalView } from "./TerminalView";
|
|
9
|
+
import { FileBrowser } from "./FileBrowser";
|
|
10
|
+
import { NotesView } from "./NotesView";
|
|
11
|
+
import { ScheduledTasksView } from "./ScheduledTasksView";
|
|
12
|
+
import type { Pane, Tab } from "../../stores/workspace";
|
|
13
|
+
|
|
14
|
+
var TAB_COMPONENTS: Record<string, () => React.JSX.Element> = {
|
|
15
|
+
chat: ChatView,
|
|
16
|
+
files: FileBrowser,
|
|
17
|
+
terminal: TerminalView,
|
|
18
|
+
notes: NotesView,
|
|
19
|
+
tasks: ScheduledTasksView,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function PaneContent({ pane, tabs, isActive, onFocus }: {
|
|
23
|
+
pane: Pane;
|
|
24
|
+
tabs: Tab[];
|
|
25
|
+
isActive: boolean;
|
|
26
|
+
onFocus: () => void;
|
|
27
|
+
}) {
|
|
28
|
+
var online = useOnline();
|
|
29
|
+
var paneTabs = pane.tabIds.map(function (id) {
|
|
30
|
+
return tabs.find(function (t) { return t.id === id; });
|
|
31
|
+
}).filter(function (t): t is Tab { return t != null; });
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
className="flex flex-col h-full w-full overflow-hidden"
|
|
36
|
+
onClick={onFocus}
|
|
37
|
+
>
|
|
38
|
+
<TabBar paneId={pane.id} isActivePane={isActive} />
|
|
39
|
+
{!online && (
|
|
40
|
+
<div className="flex items-center gap-2 px-3 py-1.5 bg-warning/10 border-b border-warning/20 flex-shrink-0">
|
|
41
|
+
<WifiOff size={13} className="text-warning flex-shrink-0" />
|
|
42
|
+
<span className="text-[12px] text-warning">Disconnected — viewing only</span>
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
<div className="flex-1 min-h-0 relative">
|
|
46
|
+
{paneTabs.map(function (tab) {
|
|
47
|
+
var Component = TAB_COMPONENTS[tab.type];
|
|
48
|
+
if (!Component) return null;
|
|
49
|
+
var isTabActive = tab.id === pane.activeTabId;
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
key={tab.id}
|
|
53
|
+
className="absolute inset-0"
|
|
54
|
+
style={{ display: isTabActive ? "flex" : "none", flexDirection: "column" }}
|
|
55
|
+
>
|
|
56
|
+
<Component />
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
})}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function WorkspaceView() {
|
|
66
|
+
var { tabs, panes, activePaneId, splitDirection, splitRatio, setSplitRatio, setActivePaneId } = useWorkspace();
|
|
67
|
+
var online = useOnline();
|
|
68
|
+
|
|
69
|
+
if (!splitDirection || panes.length < 2) {
|
|
70
|
+
var singlePane = panes[0];
|
|
71
|
+
return (
|
|
72
|
+
<div className="flex flex-col h-full w-full overflow-hidden">
|
|
73
|
+
<TabBar paneId={singlePane?.id} />
|
|
74
|
+
{!online && (
|
|
75
|
+
<div className="flex items-center gap-2 px-3 py-1.5 bg-warning/10 border-b border-warning/20 flex-shrink-0 order-0 sm:order-none">
|
|
76
|
+
<WifiOff size={13} className="text-warning flex-shrink-0" />
|
|
77
|
+
<span className="text-[12px] text-warning">Disconnected — viewing only</span>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
<div className="flex-1 min-h-0 relative order-0 sm:order-none">
|
|
81
|
+
{tabs.map(function (tab) {
|
|
82
|
+
var Component = TAB_COMPONENTS[tab.type];
|
|
83
|
+
if (!Component) return null;
|
|
84
|
+
var isActive = singlePane ? tab.id === singlePane.activeTabId : tab.id === "chat";
|
|
85
|
+
return (
|
|
86
|
+
<div
|
|
87
|
+
key={tab.id}
|
|
88
|
+
className="absolute inset-0"
|
|
89
|
+
style={{ display: isActive ? "flex" : "none", flexDirection: "column" }}
|
|
90
|
+
>
|
|
91
|
+
<Component />
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<SplitPane direction={splitDirection} ratio={splitRatio} onRatioChange={setSplitRatio}>
|
|
102
|
+
<PaneContent
|
|
103
|
+
pane={panes[0]}
|
|
104
|
+
tabs={tabs}
|
|
105
|
+
isActive={activePaneId === panes[0].id}
|
|
106
|
+
onFocus={function () { setActivePaneId(panes[0].id); }}
|
|
107
|
+
/>
|
|
108
|
+
<PaneContent
|
|
109
|
+
pane={panes[1]}
|
|
110
|
+
tabs={tabs}
|
|
111
|
+
isActive={activePaneId === panes[1].id}
|
|
112
|
+
onFocus={function () { setActivePaneId(panes[1].id); }}
|
|
113
|
+
/>
|
|
114
|
+
</SplitPane>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useStore } from "@tanstack/react-store";
|
|
3
|
+
import { useWebSocket } from "./useWebSocket";
|
|
4
|
+
import type { ServerMessage } from "@lattice/shared";
|
|
5
|
+
import type { AnalyticsPeriod, AnalyticsScope } from "@lattice/shared";
|
|
6
|
+
import {
|
|
7
|
+
getAnalyticsStore,
|
|
8
|
+
setAnalyticsData,
|
|
9
|
+
setAnalyticsLoading,
|
|
10
|
+
setAnalyticsError,
|
|
11
|
+
setAnalyticsPeriod,
|
|
12
|
+
setAnalyticsScope,
|
|
13
|
+
} from "../stores/analytics";
|
|
14
|
+
import type { AnalyticsState } from "../stores/analytics";
|
|
15
|
+
|
|
16
|
+
export function useAnalytics(): AnalyticsState & {
|
|
17
|
+
setPeriod: (period: AnalyticsPeriod) => void;
|
|
18
|
+
setScope: (scope: AnalyticsScope, projectSlug?: string) => void;
|
|
19
|
+
refresh: () => void;
|
|
20
|
+
} {
|
|
21
|
+
var store = getAnalyticsStore();
|
|
22
|
+
var state = useStore(store, function (s) { return s; });
|
|
23
|
+
var { send, subscribe, unsubscribe } = useWebSocket();
|
|
24
|
+
var sendRef = useRef(send);
|
|
25
|
+
sendRef.current = send;
|
|
26
|
+
|
|
27
|
+
function requestAnalytics(forceRefresh?: boolean) {
|
|
28
|
+
var s = getAnalyticsStore().state;
|
|
29
|
+
setAnalyticsLoading(true);
|
|
30
|
+
sendRef.current({
|
|
31
|
+
type: "analytics:request",
|
|
32
|
+
requestId: crypto.randomUUID(),
|
|
33
|
+
scope: s.scope,
|
|
34
|
+
projectSlug: s.projectSlug || undefined,
|
|
35
|
+
period: s.period,
|
|
36
|
+
forceRefresh: forceRefresh,
|
|
37
|
+
} as any);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
useEffect(function () {
|
|
41
|
+
function handleData(msg: ServerMessage) {
|
|
42
|
+
var m = msg as { type: string; data: any };
|
|
43
|
+
setAnalyticsData(m.data);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function handleError(msg: ServerMessage) {
|
|
47
|
+
var m = msg as { type: string; message: string };
|
|
48
|
+
setAnalyticsError(m.message);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
subscribe("analytics:data", handleData);
|
|
52
|
+
subscribe("analytics:error", handleError);
|
|
53
|
+
|
|
54
|
+
return function () {
|
|
55
|
+
unsubscribe("analytics:data", handleData);
|
|
56
|
+
unsubscribe("analytics:error", handleError);
|
|
57
|
+
};
|
|
58
|
+
}, [subscribe, unsubscribe]);
|
|
59
|
+
|
|
60
|
+
useEffect(function () {
|
|
61
|
+
requestAnalytics();
|
|
62
|
+
}, [state.period, state.scope, state.projectSlug]);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
data: state.data,
|
|
66
|
+
loading: state.loading,
|
|
67
|
+
error: state.error,
|
|
68
|
+
period: state.period,
|
|
69
|
+
scope: state.scope,
|
|
70
|
+
projectSlug: state.projectSlug,
|
|
71
|
+
setPeriod: setAnalyticsPeriod,
|
|
72
|
+
setScope: setAnalyticsScope,
|
|
73
|
+
refresh: function () { requestAnalytics(true); },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from "react";
|
|
2
|
+
import { useWebSocket } from "./useWebSocket";
|
|
3
|
+
import type { ServerMessage } from "@lattice/shared";
|
|
4
|
+
|
|
5
|
+
var CHUNK_SIZE = 64 * 1024;
|
|
6
|
+
var MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
7
|
+
var MAX_ATTACHMENTS = 20;
|
|
8
|
+
var CHUNK_TIMEOUT_MS = 10000;
|
|
9
|
+
|
|
10
|
+
export type AttachmentStatus = "uploading" | "ready" | "failed";
|
|
11
|
+
|
|
12
|
+
export interface ClientAttachment {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
type: "file" | "image" | "paste";
|
|
16
|
+
mimeType: string;
|
|
17
|
+
size: number;
|
|
18
|
+
lineCount?: number;
|
|
19
|
+
status: AttachmentStatus;
|
|
20
|
+
progress: number;
|
|
21
|
+
error?: string;
|
|
22
|
+
previewUrl?: string;
|
|
23
|
+
content?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UseAttachmentsReturn {
|
|
27
|
+
attachments: ClientAttachment[];
|
|
28
|
+
addFile: (file: File) => void;
|
|
29
|
+
addPaste: (text: string) => void;
|
|
30
|
+
removeAttachment: (id: string) => void;
|
|
31
|
+
retryAttachment: (id: string) => void;
|
|
32
|
+
clearAll: () => void;
|
|
33
|
+
readyIds: string[];
|
|
34
|
+
hasUploading: boolean;
|
|
35
|
+
canAttach: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function guessMimeType(file: File): string {
|
|
39
|
+
if (file.type) return file.type;
|
|
40
|
+
var ext = file.name.split(".").pop()?.toLowerCase() || "";
|
|
41
|
+
var map: Record<string, string> = {
|
|
42
|
+
ts: "application/typescript",
|
|
43
|
+
tsx: "application/typescript",
|
|
44
|
+
js: "application/javascript",
|
|
45
|
+
jsx: "application/javascript",
|
|
46
|
+
json: "application/json",
|
|
47
|
+
yaml: "application/yaml",
|
|
48
|
+
yml: "application/yaml",
|
|
49
|
+
md: "text/markdown",
|
|
50
|
+
txt: "text/plain",
|
|
51
|
+
csv: "text/csv",
|
|
52
|
+
py: "text/x-python",
|
|
53
|
+
rs: "text/x-rust",
|
|
54
|
+
go: "text/x-go",
|
|
55
|
+
rb: "text/x-ruby",
|
|
56
|
+
sh: "text/x-shellscript",
|
|
57
|
+
css: "text/css",
|
|
58
|
+
html: "text/html",
|
|
59
|
+
xml: "application/xml",
|
|
60
|
+
svg: "image/svg+xml",
|
|
61
|
+
png: "image/png",
|
|
62
|
+
jpg: "image/jpeg",
|
|
63
|
+
jpeg: "image/jpeg",
|
|
64
|
+
gif: "image/gif",
|
|
65
|
+
webp: "image/webp",
|
|
66
|
+
};
|
|
67
|
+
return map[ext] || "application/octet-stream";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isImageType(mime: string): boolean {
|
|
71
|
+
return mime.startsWith("image/") && mime !== "image/svg+xml";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function useAttachments(): UseAttachmentsReturn {
|
|
75
|
+
var [attachments, setAttachments] = useState<ClientAttachment[]>([]);
|
|
76
|
+
var { send, subscribe, unsubscribe } = useWebSocket();
|
|
77
|
+
var pendingResolvers = useRef(new Map<string, { resolve: () => void; reject: (err: string) => void; timer: ReturnType<typeof setTimeout> }>());
|
|
78
|
+
var fileCache = useRef(new Map<string, File>());
|
|
79
|
+
|
|
80
|
+
var updateAttachment = useCallback(function (id: string, updates: Partial<ClientAttachment>) {
|
|
81
|
+
setAttachments(function (prev) {
|
|
82
|
+
return prev.map(function (a) {
|
|
83
|
+
return a.id === id ? { ...a, ...updates } : a;
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
var uploadFile = useCallback(function (attachment: ClientAttachment, file: File) {
|
|
89
|
+
var reader = new FileReader();
|
|
90
|
+
reader.onload = function () {
|
|
91
|
+
var buffer = reader.result as ArrayBuffer;
|
|
92
|
+
var bytes = new Uint8Array(buffer);
|
|
93
|
+
var totalChunks = Math.ceil(bytes.length / CHUNK_SIZE);
|
|
94
|
+
|
|
95
|
+
var chunkIndex = 0;
|
|
96
|
+
|
|
97
|
+
function sendNextChunk() {
|
|
98
|
+
if (chunkIndex >= totalChunks) {
|
|
99
|
+
send({
|
|
100
|
+
type: "attachment:complete",
|
|
101
|
+
attachmentId: attachment.id,
|
|
102
|
+
attachmentType: attachment.type,
|
|
103
|
+
name: attachment.name,
|
|
104
|
+
mimeType: attachment.mimeType,
|
|
105
|
+
size: attachment.size,
|
|
106
|
+
lineCount: attachment.lineCount,
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
var start = chunkIndex * CHUNK_SIZE;
|
|
112
|
+
var end = Math.min(start + CHUNK_SIZE, bytes.length);
|
|
113
|
+
var chunk = bytes.slice(start, end);
|
|
114
|
+
var base64 = btoa(String.fromCharCode.apply(null, chunk as unknown as number[]));
|
|
115
|
+
|
|
116
|
+
send({
|
|
117
|
+
type: "attachment:chunk",
|
|
118
|
+
attachmentId: attachment.id,
|
|
119
|
+
chunkIndex,
|
|
120
|
+
totalChunks,
|
|
121
|
+
data: base64,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
var currentChunk = chunkIndex;
|
|
125
|
+
var timer = setTimeout(function () {
|
|
126
|
+
pendingResolvers.current.delete(attachment.id + ":" + currentChunk);
|
|
127
|
+
updateAttachment(attachment.id, { status: "failed", error: "Upload timed out" });
|
|
128
|
+
}, CHUNK_TIMEOUT_MS);
|
|
129
|
+
|
|
130
|
+
pendingResolvers.current.set(attachment.id + ":" + currentChunk, {
|
|
131
|
+
resolve: function () {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
chunkIndex++;
|
|
134
|
+
var progress = Math.round((chunkIndex / totalChunks) * 100);
|
|
135
|
+
updateAttachment(attachment.id, { progress });
|
|
136
|
+
sendNextChunk();
|
|
137
|
+
},
|
|
138
|
+
reject: function (err: string) {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
updateAttachment(attachment.id, { status: "failed", error: err });
|
|
141
|
+
},
|
|
142
|
+
timer,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function handleProgress(msg: ServerMessage) {
|
|
147
|
+
var m = msg as { type: string; attachmentId: string; received: number; total: number };
|
|
148
|
+
if (m.attachmentId !== attachment.id) return;
|
|
149
|
+
var key = attachment.id + ":" + (m.received - 1);
|
|
150
|
+
var resolver = pendingResolvers.current.get(key);
|
|
151
|
+
if (resolver) {
|
|
152
|
+
pendingResolvers.current.delete(key);
|
|
153
|
+
resolver.resolve();
|
|
154
|
+
}
|
|
155
|
+
if (m.received === m.total) {
|
|
156
|
+
updateAttachment(attachment.id, { status: "ready", progress: 100 });
|
|
157
|
+
unsubscribe("attachment:progress", handleProgress);
|
|
158
|
+
unsubscribe("attachment:error", handleError);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function handleError(msg: ServerMessage) {
|
|
163
|
+
var m = msg as { type: string; attachmentId: string; error: string };
|
|
164
|
+
if (m.attachmentId !== attachment.id) return;
|
|
165
|
+
updateAttachment(attachment.id, { status: "failed", error: m.error });
|
|
166
|
+
unsubscribe("attachment:progress", handleProgress);
|
|
167
|
+
unsubscribe("attachment:error", handleError);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
subscribe("attachment:progress", handleProgress);
|
|
171
|
+
subscribe("attachment:error", handleError);
|
|
172
|
+
sendNextChunk();
|
|
173
|
+
};
|
|
174
|
+
reader.readAsArrayBuffer(file);
|
|
175
|
+
}, [send, subscribe, unsubscribe, updateAttachment]);
|
|
176
|
+
|
|
177
|
+
var addFile = useCallback(function (file: File) {
|
|
178
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (attachments.length >= MAX_ATTACHMENTS) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
var id = crypto.randomUUID();
|
|
186
|
+
var mime = guessMimeType(file);
|
|
187
|
+
var type: "file" | "image" = isImageType(mime) ? "image" : "file";
|
|
188
|
+
|
|
189
|
+
var previewUrl: string | undefined;
|
|
190
|
+
if (type === "image") {
|
|
191
|
+
previewUrl = URL.createObjectURL(file);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
var att: ClientAttachment = {
|
|
195
|
+
id,
|
|
196
|
+
name: file.name,
|
|
197
|
+
type,
|
|
198
|
+
mimeType: mime,
|
|
199
|
+
size: file.size,
|
|
200
|
+
status: "uploading",
|
|
201
|
+
progress: 0,
|
|
202
|
+
previewUrl,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
fileCache.current.set(id, file);
|
|
206
|
+
setAttachments(function (prev) { return [...prev, att]; });
|
|
207
|
+
uploadFile(att, file);
|
|
208
|
+
}, [attachments.length, uploadFile]);
|
|
209
|
+
|
|
210
|
+
var addPaste = useCallback(function (text: string) {
|
|
211
|
+
if (attachments.length >= MAX_ATTACHMENTS) return;
|
|
212
|
+
|
|
213
|
+
var id = crypto.randomUUID();
|
|
214
|
+
var blob = new Blob([text], { type: "text/plain" });
|
|
215
|
+
var file = new File([blob], "pasted-text.txt", { type: "text/plain" });
|
|
216
|
+
var lineCount = text.split("\n").length;
|
|
217
|
+
|
|
218
|
+
var att: ClientAttachment = {
|
|
219
|
+
id,
|
|
220
|
+
name: "Pasted text",
|
|
221
|
+
type: "paste",
|
|
222
|
+
mimeType: "text/plain",
|
|
223
|
+
size: blob.size,
|
|
224
|
+
lineCount,
|
|
225
|
+
status: "uploading",
|
|
226
|
+
progress: 0,
|
|
227
|
+
content: text,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
fileCache.current.set(id, file);
|
|
231
|
+
setAttachments(function (prev) { return [...prev, att]; });
|
|
232
|
+
uploadFile(att, file);
|
|
233
|
+
}, [attachments.length, uploadFile]);
|
|
234
|
+
|
|
235
|
+
var removeAttachment = useCallback(function (id: string) {
|
|
236
|
+
setAttachments(function (prev) {
|
|
237
|
+
var removed = prev.find(function (a) { return a.id === id; });
|
|
238
|
+
if (removed && removed.previewUrl) {
|
|
239
|
+
URL.revokeObjectURL(removed.previewUrl);
|
|
240
|
+
}
|
|
241
|
+
return prev.filter(function (a) { return a.id !== id; });
|
|
242
|
+
});
|
|
243
|
+
fileCache.current.delete(id);
|
|
244
|
+
}, []);
|
|
245
|
+
|
|
246
|
+
var retryAttachment = useCallback(function (id: string) {
|
|
247
|
+
var file = fileCache.current.get(id);
|
|
248
|
+
var att = attachments.find(function (a) { return a.id === id; });
|
|
249
|
+
if (!file || !att) return;
|
|
250
|
+
updateAttachment(id, { status: "uploading", progress: 0, error: undefined });
|
|
251
|
+
uploadFile(att, file);
|
|
252
|
+
}, [attachments, uploadFile, updateAttachment]);
|
|
253
|
+
|
|
254
|
+
var clearAll = useCallback(function () {
|
|
255
|
+
attachments.forEach(function (a) {
|
|
256
|
+
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
|
|
257
|
+
});
|
|
258
|
+
setAttachments([]);
|
|
259
|
+
fileCache.current.clear();
|
|
260
|
+
}, [attachments]);
|
|
261
|
+
|
|
262
|
+
var readyIds = attachments
|
|
263
|
+
.filter(function (a) { return a.status === "ready"; })
|
|
264
|
+
.map(function (a) { return a.id; });
|
|
265
|
+
|
|
266
|
+
var hasUploading = attachments.some(function (a) { return a.status === "uploading"; });
|
|
267
|
+
var canAttach = attachments.length < MAX_ATTACHMENTS;
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
attachments,
|
|
271
|
+
addFile,
|
|
272
|
+
addPaste,
|
|
273
|
+
removeAttachment,
|
|
274
|
+
retryAttachment,
|
|
275
|
+
clearAll,
|
|
276
|
+
readyIds,
|
|
277
|
+
hasUploading,
|
|
278
|
+
canAttach,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useWebSocket } from "./useWebSocket";
|
|
3
|
+
import type { ServerMessage, SettingsDataMessage } from "@lattice/shared";
|
|
4
|
+
|
|
5
|
+
export function useEditorConfig() {
|
|
6
|
+
var ws = useWebSocket();
|
|
7
|
+
var [editorType, setEditorType] = useState("vscode");
|
|
8
|
+
var [wslDistro, setWslDistro] = useState<string | undefined>(undefined);
|
|
9
|
+
|
|
10
|
+
useEffect(function () {
|
|
11
|
+
function handleSettings(msg: ServerMessage) {
|
|
12
|
+
if (msg.type !== "settings:data") return;
|
|
13
|
+
var data = msg as SettingsDataMessage;
|
|
14
|
+
var cfg = data.config as any;
|
|
15
|
+
if (cfg.editor?.type) {
|
|
16
|
+
setEditorType(cfg.editor.type);
|
|
17
|
+
}
|
|
18
|
+
if (data.wslDistro) {
|
|
19
|
+
setWslDistro(data.wslDistro);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
ws.subscribe("settings:data", handleSettings);
|
|
23
|
+
ws.send({ type: "settings:get" });
|
|
24
|
+
return function () { ws.unsubscribe("settings:data", handleSettings); };
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
return { editorType: editorType, wslDistro: wslDistro };
|
|
28
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
var IDLE_TIMEOUT = 60 * 1000;
|
|
4
|
+
|
|
5
|
+
export function useIdleDetection(): boolean {
|
|
6
|
+
var [isIdle, setIsIdle] = useState(false);
|
|
7
|
+
var timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
8
|
+
|
|
9
|
+
useEffect(function () {
|
|
10
|
+
function resetTimer() {
|
|
11
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
12
|
+
setIsIdle(false);
|
|
13
|
+
timerRef.current = setTimeout(function () {
|
|
14
|
+
setIsIdle(true);
|
|
15
|
+
}, IDLE_TIMEOUT);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function handleVisibilityChange() {
|
|
19
|
+
if (document.hidden) {
|
|
20
|
+
setIsIdle(true);
|
|
21
|
+
} else {
|
|
22
|
+
resetTimer();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
var events = ["mousemove", "keydown", "mousedown", "touchstart", "scroll"];
|
|
27
|
+
events.forEach(function (event) {
|
|
28
|
+
document.addEventListener(event, resetTimer, { passive: true });
|
|
29
|
+
});
|
|
30
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
31
|
+
|
|
32
|
+
resetTimer();
|
|
33
|
+
|
|
34
|
+
return function () {
|
|
35
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
36
|
+
events.forEach(function (event) {
|
|
37
|
+
document.removeEventListener(event, resetTimer);
|
|
38
|
+
});
|
|
39
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
40
|
+
};
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
return isIdle;
|
|
44
|
+
}
|