@cryptiklemur/lattice 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/bun.lock +776 -2
  2. package/client/index.html +1 -13
  3. package/client/package.json +7 -1
  4. package/client/src/App.tsx +2 -0
  5. package/client/src/commands.ts +36 -0
  6. package/client/src/components/analytics/AnalyticsView.tsx +61 -0
  7. package/client/src/components/analytics/ChartCard.tsx +22 -0
  8. package/client/src/components/analytics/PeriodSelector.tsx +42 -0
  9. package/client/src/components/analytics/QuickStats.tsx +99 -0
  10. package/client/src/components/analytics/charts/CostAreaChart.tsx +83 -0
  11. package/client/src/components/analytics/charts/CostDistributionChart.tsx +62 -0
  12. package/client/src/components/analytics/charts/CostDonutChart.tsx +93 -0
  13. package/client/src/components/analytics/charts/CumulativeCostChart.tsx +62 -0
  14. package/client/src/components/analytics/charts/SessionBubbleChart.tsx +122 -0
  15. package/client/src/components/chat/AttachmentChips.tsx +116 -0
  16. package/client/src/components/chat/ChatInput.tsx +250 -73
  17. package/client/src/components/chat/ChatView.tsx +242 -10
  18. package/client/src/components/chat/CommandPalette.tsx +162 -0
  19. package/client/src/components/chat/Message.tsx +23 -2
  20. package/client/src/components/chat/PromptQuestion.tsx +271 -0
  21. package/client/src/components/chat/TodoCard.tsx +57 -0
  22. package/client/src/components/chat/ToolResultRenderer.tsx +2 -1
  23. package/client/src/components/chat/VoiceRecorder.tsx +85 -0
  24. package/client/src/components/dashboard/DashboardView.tsx +5 -0
  25. package/client/src/components/project-settings/ProjectMemory.tsx +12 -2
  26. package/client/src/components/project-settings/ProjectNotifications.tsx +48 -0
  27. package/client/src/components/project-settings/ProjectRules.tsx +10 -1
  28. package/client/src/components/project-settings/ProjectSettingsView.tsx +6 -0
  29. package/client/src/components/settings/Appearance.tsx +1 -0
  30. package/client/src/components/settings/ClaudeSettings.tsx +10 -0
  31. package/client/src/components/settings/Editor.tsx +123 -0
  32. package/client/src/components/settings/GlobalMcp.tsx +10 -1
  33. package/client/src/components/settings/GlobalMemory.tsx +19 -0
  34. package/client/src/components/settings/GlobalRules.tsx +149 -0
  35. package/client/src/components/settings/GlobalSkills.tsx +10 -0
  36. package/client/src/components/settings/Notifications.tsx +88 -0
  37. package/client/src/components/settings/SettingsView.tsx +12 -0
  38. package/client/src/components/settings/skill-shared.tsx +2 -1
  39. package/client/src/components/setup/SetupWizard.tsx +1 -1
  40. package/client/src/components/sidebar/NodeSettingsModal.tsx +23 -1
  41. package/client/src/components/sidebar/ProjectDropdown.tsx +176 -27
  42. package/client/src/components/sidebar/SettingsSidebar.tsx +11 -1
  43. package/client/src/components/sidebar/Sidebar.tsx +43 -2
  44. package/client/src/components/sidebar/UserIsland.tsx +18 -7
  45. package/client/src/components/ui/UpdatePrompt.tsx +47 -0
  46. package/client/src/components/workspace/FileBrowser.tsx +174 -0
  47. package/client/src/components/workspace/FileTree.tsx +129 -0
  48. package/client/src/components/workspace/FileViewer.tsx +211 -0
  49. package/client/src/components/workspace/NoteCard.tsx +119 -0
  50. package/client/src/components/workspace/NotesView.tsx +102 -0
  51. package/client/src/components/workspace/ScheduledTasksView.tsx +117 -0
  52. package/client/src/components/workspace/SplitPane.tsx +81 -0
  53. package/client/src/components/workspace/TabBar.tsx +185 -0
  54. package/client/src/components/workspace/TaskCard.tsx +158 -0
  55. package/client/src/components/workspace/TaskEditModal.tsx +114 -0
  56. package/client/src/components/{panels/Terminal.tsx → workspace/TerminalInstance.tsx} +50 -7
  57. package/client/src/components/workspace/TerminalView.tsx +110 -0
  58. package/client/src/components/workspace/WorkspaceView.tsx +116 -0
  59. package/client/src/hooks/useAnalytics.ts +75 -0
  60. package/client/src/hooks/useAttachments.ts +280 -0
  61. package/client/src/hooks/useEditorConfig.ts +28 -0
  62. package/client/src/hooks/useIdleDetection.ts +44 -0
  63. package/client/src/hooks/useInstallPrompt.ts +53 -0
  64. package/client/src/hooks/useNotifications.ts +54 -0
  65. package/client/src/hooks/useOnline.ts +6 -0
  66. package/client/src/hooks/useSession.ts +110 -4
  67. package/client/src/hooks/useSpinnerVerb.ts +36 -0
  68. package/client/src/hooks/useSwipeDrawer.ts +275 -0
  69. package/client/src/hooks/useVoiceRecorder.ts +123 -0
  70. package/client/src/hooks/useWorkspace.ts +48 -0
  71. package/client/src/providers/WebSocketProvider.tsx +18 -0
  72. package/client/src/router.tsx +52 -20
  73. package/client/src/stores/analytics.ts +54 -0
  74. package/client/src/stores/session.ts +136 -0
  75. package/client/src/stores/sidebar.ts +11 -2
  76. package/client/src/stores/workspace.ts +254 -0
  77. package/client/src/styles/global.css +123 -0
  78. package/client/src/utils/editorUrl.ts +62 -0
  79. package/client/vite.config.ts +54 -1
  80. package/package.json +1 -1
  81. package/server/src/analytics/engine.ts +491 -0
  82. package/server/src/daemon.ts +12 -1
  83. package/server/src/features/scheduler.ts +23 -0
  84. package/server/src/features/sticky-notes.ts +5 -3
  85. package/server/src/handlers/analytics.ts +34 -0
  86. package/server/src/handlers/attachment.ts +172 -0
  87. package/server/src/handlers/chat.ts +43 -2
  88. package/server/src/handlers/editor.ts +40 -0
  89. package/server/src/handlers/fs.ts +10 -2
  90. package/server/src/handlers/memory.ts +3 -0
  91. package/server/src/handlers/notes.ts +4 -2
  92. package/server/src/handlers/scheduler.ts +18 -1
  93. package/server/src/handlers/session.ts +14 -8
  94. package/server/src/handlers/settings.ts +37 -2
  95. package/server/src/handlers/terminal.ts +13 -6
  96. package/server/src/project/pty-worker.cjs +83 -0
  97. package/server/src/project/sdk-bridge.ts +266 -11
  98. package/server/src/project/session.ts +4 -4
  99. package/server/src/project/terminal.ts +78 -34
  100. package/shared/src/analytics.ts +24 -0
  101. package/shared/src/index.ts +1 -0
  102. package/shared/src/messages.ts +173 -4
  103. package/shared/src/models.ts +27 -1
  104. package/shared/src/project-settings.ts +1 -1
  105. package/tp.js +19 -0
  106. package/client/public/manifest.json +0 -24
  107. package/client/public/sw.js +0 -61
  108. package/client/src/components/panels/FileBrowser.tsx +0 -241
  109. package/client/src/components/panels/StickyNotes.tsx +0 -187
@@ -0,0 +1,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
+ }