@cryptiklemur/lattice 1.2.0 → 1.4.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/.serena/project.yml +138 -0
- package/bun.lock +705 -2
- package/client/index.html +1 -13
- package/client/package.json +6 -1
- package/client/src/App.tsx +2 -0
- package/client/src/commands.ts +36 -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/project-settings/ProjectClaude.tsx +14 -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 +24 -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/AddProjectModal.tsx +3 -2
- 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 +35 -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/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 +48 -20
- package/client/src/stores/session.ts +136 -0
- package/client/src/stores/sidebar.ts +3 -2
- package/client/src/stores/workspace.ts +254 -0
- package/client/src/styles/global.css +131 -0
- package/client/src/utils/editorUrl.ts +62 -0
- package/client/vite.config.ts +53 -1
- package/package.json +1 -1
- package/server/src/daemon.ts +11 -1
- package/server/src/features/scheduler.ts +23 -0
- package/server/src/features/sticky-notes.ts +5 -3
- 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/terminal.ts +78 -34
- package/shared/src/messages.ts +145 -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,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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
interface BeforeInstallPromptEvent extends Event {
|
|
4
|
+
prompt(): Promise<void>;
|
|
5
|
+
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function useInstallPrompt() {
|
|
9
|
+
var [promptEvent, setPromptEvent] = useState<BeforeInstallPromptEvent | null>(null);
|
|
10
|
+
var [isInstalled, setIsInstalled] = useState(false);
|
|
11
|
+
var [dismissed, setDismissed] = useState(function () {
|
|
12
|
+
return localStorage.getItem("lattice-install-dismissed") === "1";
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
useEffect(function () {
|
|
16
|
+
if (window.matchMedia("(display-mode: standalone)").matches) {
|
|
17
|
+
setIsInstalled(true);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function handleBeforeInstall(e: Event) {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
setPromptEvent(e as BeforeInstallPromptEvent);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
window.addEventListener("beforeinstallprompt", handleBeforeInstall);
|
|
27
|
+
return function () {
|
|
28
|
+
window.removeEventListener("beforeinstallprompt", handleBeforeInstall);
|
|
29
|
+
};
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
function install() {
|
|
33
|
+
if (!promptEvent) return;
|
|
34
|
+
promptEvent.prompt();
|
|
35
|
+
promptEvent.userChoice.then(function (choice) {
|
|
36
|
+
if (choice.outcome === "dismissed") {
|
|
37
|
+
setDismissed(true);
|
|
38
|
+
localStorage.setItem("lattice-install-dismissed", "1");
|
|
39
|
+
}
|
|
40
|
+
setPromptEvent(null);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function dismiss() {
|
|
45
|
+
setDismissed(true);
|
|
46
|
+
localStorage.setItem("lattice-install-dismissed", "1");
|
|
47
|
+
setPromptEvent(null);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
var canInstall = !!promptEvent && !isInstalled && !dismissed;
|
|
51
|
+
|
|
52
|
+
return { canInstall: canInstall, install: install, dismiss: dismiss };
|
|
53
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
export function useNotificationPreference() {
|
|
4
|
+
var [enabled, setEnabled] = useState(function () {
|
|
5
|
+
return localStorage.getItem("lattice-notifications-enabled") === "1";
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
var permission = typeof Notification !== "undefined" ? Notification.permission : "denied";
|
|
9
|
+
|
|
10
|
+
function toggle() {
|
|
11
|
+
if (!enabled) {
|
|
12
|
+
if (permission === "default") {
|
|
13
|
+
Notification.requestPermission().then(function (result) {
|
|
14
|
+
if (result === "granted") {
|
|
15
|
+
setEnabled(true);
|
|
16
|
+
localStorage.setItem("lattice-notifications-enabled", "1");
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
} else if (permission === "granted") {
|
|
20
|
+
setEnabled(true);
|
|
21
|
+
localStorage.setItem("lattice-notifications-enabled", "1");
|
|
22
|
+
}
|
|
23
|
+
} else {
|
|
24
|
+
setEnabled(false);
|
|
25
|
+
localStorage.setItem("lattice-notifications-enabled", "0");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { enabled: enabled, permission: permission, toggle: toggle };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function sendNotification(title: string, body: string, tag?: string, onClick?: () => void): void {
|
|
33
|
+
var allowed = localStorage.getItem("lattice-notifications-enabled") === "1";
|
|
34
|
+
if (!allowed) return;
|
|
35
|
+
if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
|
|
36
|
+
|
|
37
|
+
var notification = new Notification(title, {
|
|
38
|
+
body: body,
|
|
39
|
+
icon: "/icons/icon-192.svg",
|
|
40
|
+
tag: tag || "lattice",
|
|
41
|
+
silent: false,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
var timer = setTimeout(function () {
|
|
45
|
+
notification.close();
|
|
46
|
+
}, 10000);
|
|
47
|
+
|
|
48
|
+
notification.onclick = function () {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
window.focus();
|
|
51
|
+
notification.close();
|
|
52
|
+
if (onClick) onClick();
|
|
53
|
+
};
|
|
54
|
+
}
|