@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.
Files changed (95) hide show
  1. package/.serena/project.yml +138 -0
  2. package/bun.lock +705 -2
  3. package/client/index.html +1 -13
  4. package/client/package.json +6 -1
  5. package/client/src/App.tsx +2 -0
  6. package/client/src/commands.ts +36 -0
  7. package/client/src/components/chat/AttachmentChips.tsx +116 -0
  8. package/client/src/components/chat/ChatInput.tsx +250 -73
  9. package/client/src/components/chat/ChatView.tsx +242 -10
  10. package/client/src/components/chat/CommandPalette.tsx +162 -0
  11. package/client/src/components/chat/Message.tsx +23 -2
  12. package/client/src/components/chat/PromptQuestion.tsx +271 -0
  13. package/client/src/components/chat/TodoCard.tsx +57 -0
  14. package/client/src/components/chat/ToolResultRenderer.tsx +2 -1
  15. package/client/src/components/chat/VoiceRecorder.tsx +85 -0
  16. package/client/src/components/project-settings/ProjectClaude.tsx +14 -0
  17. package/client/src/components/project-settings/ProjectMemory.tsx +12 -2
  18. package/client/src/components/project-settings/ProjectNotifications.tsx +48 -0
  19. package/client/src/components/project-settings/ProjectRules.tsx +10 -1
  20. package/client/src/components/project-settings/ProjectSettingsView.tsx +6 -0
  21. package/client/src/components/settings/Appearance.tsx +1 -0
  22. package/client/src/components/settings/ClaudeSettings.tsx +24 -0
  23. package/client/src/components/settings/Editor.tsx +123 -0
  24. package/client/src/components/settings/GlobalMcp.tsx +10 -1
  25. package/client/src/components/settings/GlobalMemory.tsx +19 -0
  26. package/client/src/components/settings/GlobalRules.tsx +149 -0
  27. package/client/src/components/settings/GlobalSkills.tsx +10 -0
  28. package/client/src/components/settings/Notifications.tsx +88 -0
  29. package/client/src/components/settings/SettingsView.tsx +12 -0
  30. package/client/src/components/settings/skill-shared.tsx +2 -1
  31. package/client/src/components/setup/SetupWizard.tsx +1 -1
  32. package/client/src/components/sidebar/AddProjectModal.tsx +3 -2
  33. package/client/src/components/sidebar/NodeSettingsModal.tsx +23 -1
  34. package/client/src/components/sidebar/ProjectDropdown.tsx +176 -27
  35. package/client/src/components/sidebar/SettingsSidebar.tsx +11 -1
  36. package/client/src/components/sidebar/Sidebar.tsx +35 -2
  37. package/client/src/components/sidebar/UserIsland.tsx +18 -7
  38. package/client/src/components/ui/UpdatePrompt.tsx +47 -0
  39. package/client/src/components/workspace/FileBrowser.tsx +174 -0
  40. package/client/src/components/workspace/FileTree.tsx +129 -0
  41. package/client/src/components/workspace/FileViewer.tsx +211 -0
  42. package/client/src/components/workspace/NoteCard.tsx +119 -0
  43. package/client/src/components/workspace/NotesView.tsx +102 -0
  44. package/client/src/components/workspace/ScheduledTasksView.tsx +117 -0
  45. package/client/src/components/workspace/SplitPane.tsx +81 -0
  46. package/client/src/components/workspace/TabBar.tsx +185 -0
  47. package/client/src/components/workspace/TaskCard.tsx +158 -0
  48. package/client/src/components/workspace/TaskEditModal.tsx +114 -0
  49. package/client/src/components/{panels/Terminal.tsx → workspace/TerminalInstance.tsx} +50 -7
  50. package/client/src/components/workspace/TerminalView.tsx +110 -0
  51. package/client/src/components/workspace/WorkspaceView.tsx +116 -0
  52. package/client/src/hooks/useAttachments.ts +280 -0
  53. package/client/src/hooks/useEditorConfig.ts +28 -0
  54. package/client/src/hooks/useIdleDetection.ts +44 -0
  55. package/client/src/hooks/useInstallPrompt.ts +53 -0
  56. package/client/src/hooks/useNotifications.ts +54 -0
  57. package/client/src/hooks/useOnline.ts +6 -0
  58. package/client/src/hooks/useSession.ts +110 -4
  59. package/client/src/hooks/useSpinnerVerb.ts +36 -0
  60. package/client/src/hooks/useSwipeDrawer.ts +275 -0
  61. package/client/src/hooks/useVoiceRecorder.ts +123 -0
  62. package/client/src/hooks/useWorkspace.ts +48 -0
  63. package/client/src/providers/WebSocketProvider.tsx +18 -0
  64. package/client/src/router.tsx +48 -20
  65. package/client/src/stores/session.ts +136 -0
  66. package/client/src/stores/sidebar.ts +3 -2
  67. package/client/src/stores/workspace.ts +254 -0
  68. package/client/src/styles/global.css +131 -0
  69. package/client/src/utils/editorUrl.ts +62 -0
  70. package/client/vite.config.ts +53 -1
  71. package/package.json +1 -1
  72. package/server/src/daemon.ts +11 -1
  73. package/server/src/features/scheduler.ts +23 -0
  74. package/server/src/features/sticky-notes.ts +5 -3
  75. package/server/src/handlers/attachment.ts +172 -0
  76. package/server/src/handlers/chat.ts +43 -2
  77. package/server/src/handlers/editor.ts +40 -0
  78. package/server/src/handlers/fs.ts +10 -2
  79. package/server/src/handlers/memory.ts +3 -0
  80. package/server/src/handlers/notes.ts +4 -2
  81. package/server/src/handlers/scheduler.ts +18 -1
  82. package/server/src/handlers/session.ts +14 -8
  83. package/server/src/handlers/settings.ts +37 -2
  84. package/server/src/handlers/terminal.ts +13 -6
  85. package/server/src/project/pty-worker.cjs +83 -0
  86. package/server/src/project/sdk-bridge.ts +266 -11
  87. package/server/src/project/terminal.ts +78 -34
  88. package/shared/src/messages.ts +145 -4
  89. package/shared/src/models.ts +27 -1
  90. package/shared/src/project-settings.ts +1 -1
  91. package/tp.js +19 -0
  92. package/client/public/manifest.json +0 -24
  93. package/client/public/sw.js +0 -61
  94. package/client/src/components/panels/FileBrowser.tsx +0 -241
  95. package/client/src/components/panels/StickyNotes.tsx +0 -187
@@ -12,6 +12,7 @@ import type {
12
12
  ChatStatusMessage,
13
13
  ChatContextUsageMessage,
14
14
  ChatContextBreakdownMessage,
15
+ ChatPromptSuggestionMessage,
15
16
  SessionHistoryMessage,
16
17
  ServerMessage,
17
18
  } from "@lattice/shared";
@@ -40,18 +41,37 @@ import {
40
41
  getStreamGeneration,
41
42
  mergeToolResults,
42
43
  setWasInterrupted,
44
+ setPromptSuggestion,
45
+ setFailedInput,
46
+ enqueueMessage,
47
+ dequeueMessage,
48
+ removeQueuedMessage,
49
+ updateQueuedMessage,
50
+ clearMessageQueue,
51
+ setSessionBusy,
52
+ addPromptQuestion,
53
+ addTodoUpdate,
54
+ setIsPlanMode,
43
55
  } from "../stores/session";
44
56
  import type { SessionState } from "../stores/session";
45
57
 
46
58
  var subscriptionsActive = 0;
47
59
  var activeStreamGeneration = 0;
60
+ var lastSentText: string | null = null;
61
+ var lastUsedModel: string | undefined = undefined;
62
+ var lastUsedEffort: string | undefined = undefined;
48
63
 
49
64
  export type { SessionState };
50
65
 
51
66
  export interface UseSessionReturn extends SessionState {
52
- sendMessage: (text: string, model?: string, effort?: string) => void;
67
+ sendMessage: (text: string, attachmentIds?: string[], model?: string, effort?: string) => void;
53
68
  activateSession: (projectSlug: string, sessionId: string) => void;
69
+ clearFailedInput: () => void;
54
70
  lastReadIndex: number | null;
71
+ enqueueMessage: (text: string) => void;
72
+ removeQueuedMessage: (index: number) => void;
73
+ updateQueuedMessage: (index: number, text: string) => void;
74
+ clearMessageQueue: () => void;
55
75
  }
56
76
 
57
77
  export function useSession(): UseSessionReturn {
@@ -60,6 +80,7 @@ export function useSession(): UseSessionReturn {
60
80
  var { send, subscribe, unsubscribe } = useWebSocket();
61
81
  var sendRef = useRef(send);
62
82
  sendRef.current = send;
83
+ var sendMessageRef = useRef(function (_text: string, _attachmentIds?: string[], _model?: string, _effort?: string) {});
63
84
 
64
85
  function activateSession(projectSlug: string, sessionId: string) {
65
86
  setActiveSession(projectSlug, sessionId);
@@ -67,12 +88,15 @@ export function useSession(): UseSessionReturn {
67
88
  sendRef.current({ type: "session:activate", projectSlug, sessionId });
68
89
  }
69
90
 
70
- function sendMessage(text: string, model?: string, effort?: string) {
91
+ function sendMessage(text: string, attachmentIds?: string[], model?: string, effort?: string) {
71
92
  var currentSessionId = getSessionStore().state.activeSessionId;
72
- if (!currentSessionId || !text.trim()) {
93
+ if (!currentSessionId || (!text.trim() && (!attachmentIds || attachmentIds.length === 0))) {
73
94
  return;
74
95
  }
75
96
  var msg = { type: "chat:send" as const, text: text } as ChatSendMessage & { model?: string; effort?: string };
97
+ if (attachmentIds && attachmentIds.length > 0) {
98
+ msg.attachmentIds = attachmentIds;
99
+ }
76
100
  if (model && model !== "default") {
77
101
  msg.model = model;
78
102
  }
@@ -80,10 +104,18 @@ export function useSession(): UseSessionReturn {
80
104
  msg.effort = effort;
81
105
  }
82
106
  activeStreamGeneration = getStreamGeneration();
83
- sendRef.current(msg as ChatSendMessage);
107
+ lastSentText = text;
108
+ lastUsedModel = model;
109
+ lastUsedEffort = effort;
110
+ setFailedInput(null);
111
+ setPromptSuggestion(null);
84
112
  setIsProcessing(true);
113
+ setSessionBusy(false);
114
+ sendRef.current(msg as ChatSendMessage);
85
115
  }
86
116
 
117
+ sendMessageRef.current = sendMessage;
118
+
87
119
  useEffect(function () {
88
120
  subscriptionsActive++;
89
121
  if (subscriptionsActive > 1) {
@@ -147,6 +179,7 @@ export function useSession(): UseSessionReturn {
147
179
  function handleDone(msg: ServerMessage) {
148
180
  if (isStaleStream()) return;
149
181
  var m = msg as { type: string; cost: number; duration: number; sessionId?: string };
182
+ lastSentText = null;
150
183
  setIsProcessing(false);
151
184
  setCurrentStatus(null);
152
185
  setCurrentAssistantUuid(null);
@@ -155,6 +188,14 @@ export function useSession(): UseSessionReturn {
155
188
  if (activeId) {
156
189
  markSessionRead(activeId, getSessionStore().state.messages.length);
157
190
  }
191
+ var queue = getSessionStore().state.messageQueue;
192
+ if (queue.length > 0) {
193
+ var combined = queue.join("\n\n");
194
+ clearMessageQueue();
195
+ setTimeout(function () {
196
+ sendMessageRef.current(combined, [], lastUsedModel, lastUsedEffort);
197
+ }, 100);
198
+ }
158
199
  }
159
200
 
160
201
  function handleError(msg: ServerMessage) {
@@ -163,6 +204,10 @@ export function useSession(): UseSessionReturn {
163
204
  setIsProcessing(false);
164
205
  setCurrentStatus(null);
165
206
  setCurrentAssistantUuid(null);
207
+ if (lastSentText) {
208
+ setFailedInput(lastSentText);
209
+ lastSentText = null;
210
+ }
166
211
  if (m.message) {
167
212
  addSessionMessage({
168
213
  type: "assistant",
@@ -243,6 +288,8 @@ export function useSession(): UseSessionReturn {
243
288
  lastReadIndex: null,
244
289
  historyLoading: false,
245
290
  wasInterrupted: m.interrupted || false,
291
+ isBusy: m.busy || false,
292
+ isPlanMode: false,
246
293
  };
247
294
  });
248
295
  var storedIndex = getLastReadIndex(m.sessionId);
@@ -266,6 +313,43 @@ export function useSession(): UseSessionReturn {
266
313
  setSessionMessages(m.messages);
267
314
  }
268
315
 
316
+ function handlePromptSuggestion(msg: ServerMessage) {
317
+ var m = msg as ChatPromptSuggestionMessage;
318
+ setPromptSuggestion(m.suggestion);
319
+ }
320
+
321
+ function handleSessionBusy(msg: ServerMessage) {
322
+ var m = msg as { type: string; sessionId: string; busy: boolean };
323
+ var sessionState = getSessionStore().state;
324
+ if (m.sessionId === sessionState.activeSessionId) {
325
+ if (m.busy && sessionState.isProcessing) {
326
+ return;
327
+ }
328
+ setSessionBusy(m.busy);
329
+ }
330
+ }
331
+
332
+ function handlePromptRequest(msg: ServerMessage) {
333
+ if (isStaleStream()) return;
334
+ var m = msg as { type: string; requestId: string; questions: Array<{ question: string; header: string; options: Array<{ label: string; description: string; preview?: string }>; multiSelect: boolean }> };
335
+ addPromptQuestion(m.requestId, m.questions);
336
+ }
337
+
338
+ function handlePromptResolved(_msg: ServerMessage) {
339
+ // No-op — client already updated state when it sent the response
340
+ }
341
+
342
+ function handleTodoUpdate(msg: ServerMessage) {
343
+ if (isStaleStream()) return;
344
+ var m = msg as { type: string; todos: Array<{ id: string; content: string; status: string; priority: string }> };
345
+ addTodoUpdate(m.todos);
346
+ }
347
+
348
+ function handlePlanMode(msg: ServerMessage) {
349
+ var m = msg as { type: string; active: boolean };
350
+ setIsPlanMode(m.active);
351
+ }
352
+
269
353
  subscribe("chat:user_message", handleUserMessage);
270
354
  subscribe("chat:delta", handleDelta);
271
355
  subscribe("chat:tool_start", handleToolStart);
@@ -278,6 +362,12 @@ export function useSession(): UseSessionReturn {
278
362
  subscribe("chat:context_usage", handleContextUsage);
279
363
  subscribe("chat:context_breakdown", handleContextBreakdown);
280
364
  subscribe("session:history", handleHistory);
365
+ subscribe("chat:prompt_suggestion", handlePromptSuggestion);
366
+ subscribe("session:busy", handleSessionBusy);
367
+ subscribe("chat:prompt_request", handlePromptRequest);
368
+ subscribe("chat:prompt_resolved", handlePromptResolved);
369
+ subscribe("chat:todo_update", handleTodoUpdate);
370
+ subscribe("chat:plan_mode", handlePlanMode);
281
371
 
282
372
  return function () {
283
373
  subscriptionsActive--;
@@ -293,6 +383,12 @@ export function useSession(): UseSessionReturn {
293
383
  unsubscribe("chat:context_usage", handleContextUsage);
294
384
  unsubscribe("chat:context_breakdown", handleContextBreakdown);
295
385
  unsubscribe("session:history", handleHistory);
386
+ unsubscribe("chat:prompt_suggestion", handlePromptSuggestion);
387
+ unsubscribe("session:busy", handleSessionBusy);
388
+ unsubscribe("chat:prompt_request", handlePromptRequest);
389
+ unsubscribe("chat:prompt_resolved", handlePromptResolved);
390
+ unsubscribe("chat:todo_update", handleTodoUpdate);
391
+ unsubscribe("chat:plan_mode", handlePlanMode);
296
392
  };
297
393
  }, [subscribe, unsubscribe]);
298
394
 
@@ -304,6 +400,7 @@ export function useSession(): UseSessionReturn {
304
400
  activeSessionTitle: state.activeSessionTitle,
305
401
  sendMessage,
306
402
  activateSession,
403
+ clearFailedInput: function () { setFailedInput(null); },
307
404
  currentStatus: state.currentStatus,
308
405
  contextUsage: state.contextUsage,
309
406
  contextBreakdown: state.contextBreakdown,
@@ -313,5 +410,14 @@ export function useSession(): UseSessionReturn {
313
410
  lastReadIndex: state.lastReadIndex,
314
411
  historyLoading: state.historyLoading,
315
412
  wasInterrupted: state.wasInterrupted,
413
+ promptSuggestion: state.promptSuggestion,
414
+ failedInput: state.failedInput,
415
+ messageQueue: state.messageQueue,
416
+ isBusy: state.isBusy,
417
+ isPlanMode: state.isPlanMode,
418
+ enqueueMessage,
419
+ removeQueuedMessage,
420
+ updateQueuedMessage,
421
+ clearMessageQueue,
316
422
  };
317
423
  }
@@ -0,0 +1,36 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useWebSocket } from "./useWebSocket";
3
+ import type { ServerMessage, SettingsDataMessage } from "@lattice/shared";
4
+
5
+ var DEFAULT_VERBS = ["Thinking", "Analyzing", "Processing", "Computing", "Evaluating"];
6
+
7
+ export function useSpinnerVerb(active: boolean): string {
8
+ var [verbs, setVerbs] = useState<string[]>(DEFAULT_VERBS);
9
+ var [currentVerb, setCurrentVerb] = useState(DEFAULT_VERBS[0]);
10
+ var ws = useWebSocket();
11
+
12
+ useEffect(function () {
13
+ function handleSettings(msg: ServerMessage) {
14
+ if (msg.type !== "settings:data") return;
15
+ var data = msg as SettingsDataMessage;
16
+ if (data.spinnerVerbs && data.spinnerVerbs.length > 0) {
17
+ setVerbs(data.spinnerVerbs);
18
+ }
19
+ }
20
+ ws.subscribe("settings:data", handleSettings);
21
+ return function () { ws.unsubscribe("settings:data", handleSettings); };
22
+ }, [ws]);
23
+
24
+ useEffect(function () {
25
+ if (!active) return;
26
+ function pickRandom() {
27
+ var idx = Math.floor(Math.random() * verbs.length);
28
+ setCurrentVerb(verbs[idx]);
29
+ }
30
+ pickRandom();
31
+ var timer = setInterval(pickRandom, 3000);
32
+ return function () { clearInterval(timer); };
33
+ }, [active, verbs]);
34
+
35
+ return currentVerb;
36
+ }
@@ -0,0 +1,275 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { RefObject } from "react";
3
+
4
+ var LOCK_THRESHOLD = 15;
5
+ var SNAP_THRESHOLD = 0.35; // fraction of drawer width to snap open/close
6
+ var VELOCITY_THRESHOLD = 0.4; // px/ms — fast flick overrides position
7
+ var TRANSITION = "translate 0.25s cubic-bezier(0.4, 0, 0.2, 1)";
8
+ var OVERLAY_TRANSITION = "opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1)";
9
+ var CONTENT_TRANSITION = "transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)";
10
+
11
+ /**
12
+ * Discord-style drag-follow drawer. The sidebar follows your finger
13
+ * in real time and snaps open/closed on release based on position + velocity.
14
+ *
15
+ * Works from anywhere on the page. Vertical scrolling is not interrupted.
16
+ */
17
+ export function useSwipeDrawer(
18
+ drawerSideRef: RefObject<HTMLElement | null>,
19
+ isOpen: boolean,
20
+ onOpen: () => void,
21
+ onClose: () => void,
22
+ ) {
23
+ var stateRef = useRef({ isOpen: isOpen, onOpen: onOpen, onClose: onClose });
24
+ stateRef.current = { isOpen: isOpen, onOpen: onOpen, onClose: onClose };
25
+
26
+ useEffect(function () {
27
+ var startX = 0;
28
+ var startY = 0;
29
+ var lastX = 0;
30
+ var lastTime = 0;
31
+ var velocity = 0;
32
+ var tracking = false;
33
+ var dragging = false; // true once direction locked to horizontal
34
+ var direction: "h" | "v" | null = null;
35
+
36
+ var panel: HTMLElement | null = null;
37
+ var overlay: HTMLElement | null = null;
38
+ var content: HTMLElement | null = null;
39
+ var drawerWidth = 0;
40
+
41
+ function getElements() {
42
+ var side = drawerSideRef.current;
43
+ if (!side) return false;
44
+ // panel = the sidebar content (not the overlay)
45
+ panel = side.querySelector(":scope > *:not(.drawer-overlay)") as HTMLElement | null;
46
+ overlay = side.querySelector(":scope > .drawer-overlay") as HTMLElement | null;
47
+ // content = the main content area (sibling of drawer-side)
48
+ var parent = side.parentElement;
49
+ if (parent) {
50
+ content = parent.querySelector(":scope > .drawer-content") as HTMLElement | null;
51
+ }
52
+ if (panel) {
53
+ drawerWidth = panel.getBoundingClientRect().width;
54
+ }
55
+ return !!panel;
56
+ }
57
+
58
+ function setDragStyles(offsetX: number) {
59
+ if (!panel) return;
60
+ // offsetX: 0 = fully open, -drawerWidth = fully closed
61
+ var clamped = Math.max(-drawerWidth, Math.min(0, offsetX));
62
+ var progress = 1 + clamped / drawerWidth; // 0 = closed, 1 = open
63
+
64
+ panel.style.transition = "none";
65
+ panel.style.translate = clamped + "px";
66
+
67
+ if (overlay) {
68
+ overlay.style.transition = "none";
69
+ overlay.style.opacity = String(progress);
70
+ }
71
+
72
+ if (content) {
73
+ var scale = 1 - progress * 0.03;
74
+ content.style.transition = "none";
75
+ content.style.transform = "scale(" + scale + ")";
76
+ content.style.transformOrigin = "left center";
77
+ }
78
+ }
79
+
80
+ function clearDragStyles() {
81
+ if (panel) {
82
+ panel.style.transition = "";
83
+ panel.style.translate = "";
84
+ }
85
+ if (overlay) {
86
+ overlay.style.transition = "";
87
+ overlay.style.opacity = "";
88
+ }
89
+ if (content) {
90
+ content.style.transition = "";
91
+ content.style.transform = "";
92
+ content.style.transformOrigin = "";
93
+ }
94
+ }
95
+
96
+ function animateToOpen() {
97
+ if (!panel) return;
98
+ panel.style.transition = TRANSITION;
99
+ panel.style.translate = "0%";
100
+ if (overlay) {
101
+ overlay.style.transition = OVERLAY_TRANSITION;
102
+ overlay.style.opacity = "1";
103
+ }
104
+ if (content) {
105
+ content.style.transition = CONTENT_TRANSITION;
106
+ content.style.transform = "scale(0.97)";
107
+ content.style.transformOrigin = "left center";
108
+ }
109
+ }
110
+
111
+ function animateToClosed() {
112
+ if (!panel) return;
113
+ panel.style.transition = TRANSITION;
114
+ panel.style.translate = "-100%";
115
+ if (overlay) {
116
+ overlay.style.transition = OVERLAY_TRANSITION;
117
+ overlay.style.opacity = "0";
118
+ }
119
+ if (content) {
120
+ content.style.transition = CONTENT_TRANSITION;
121
+ content.style.transform = "scale(1)";
122
+ content.style.transformOrigin = "left center";
123
+ }
124
+ }
125
+
126
+ function cleanupAfterAnimation() {
127
+ // After the CSS transition finishes, remove inline styles
128
+ // so the checkbox-driven DaisyUI styles take over again
129
+ setTimeout(function () {
130
+ clearDragStyles();
131
+ }, 280);
132
+ }
133
+
134
+ function onTouchStart(e: TouchEvent) {
135
+ if (window.innerWidth >= 1024) return;
136
+ var t = e.touches[0];
137
+ startX = t.clientX;
138
+ startY = t.clientY;
139
+ lastX = t.clientX;
140
+ lastTime = Date.now();
141
+ velocity = 0;
142
+ tracking = true;
143
+ dragging = false;
144
+ direction = null;
145
+ }
146
+
147
+ function onTouchMove(e: TouchEvent) {
148
+ if (!tracking) return;
149
+ var t = e.touches[0];
150
+ var now = Date.now();
151
+
152
+ // Calculate velocity
153
+ var dt = now - lastTime;
154
+ if (dt > 0) {
155
+ velocity = (t.clientX - lastX) / dt;
156
+ }
157
+ lastX = t.clientX;
158
+ lastTime = now;
159
+
160
+ // Lock direction
161
+ if (direction === null) {
162
+ var dx = Math.abs(t.clientX - startX);
163
+ var dy = Math.abs(t.clientY - startY);
164
+ if (dx >= LOCK_THRESHOLD || dy >= LOCK_THRESHOLD) {
165
+ if (dx > dy) {
166
+ direction = "h";
167
+ dragging = true;
168
+ if (!getElements()) {
169
+ tracking = false;
170
+ return;
171
+ }
172
+ // Make drawer-side visible during drag if it's currently hidden
173
+ var side = drawerSideRef.current;
174
+ if (side) {
175
+ side.style.visibility = "visible";
176
+ side.style.pointerEvents = "auto";
177
+ side.style.opacity = "1";
178
+ }
179
+ } else {
180
+ direction = "v";
181
+ tracking = false;
182
+ return;
183
+ }
184
+ } else {
185
+ return;
186
+ }
187
+ }
188
+
189
+ if (!dragging) return;
190
+
191
+ var deltaX = t.clientX - startX;
192
+ var isOpen = stateRef.current.isOpen;
193
+
194
+ if (isOpen) {
195
+ // Dragging from open position: offset relative to 0 (fully open)
196
+ setDragStyles(Math.min(0, deltaX));
197
+ } else {
198
+ // Dragging from closed position: offset relative to -drawerWidth
199
+ setDragStyles(-drawerWidth + Math.max(0, deltaX));
200
+ }
201
+ }
202
+
203
+ function onTouchEnd() {
204
+ if (!tracking || !dragging) {
205
+ tracking = false;
206
+ direction = null;
207
+ return;
208
+ }
209
+
210
+ var deltaX = lastX - startX;
211
+ var isOpen = stateRef.current.isOpen;
212
+
213
+ tracking = false;
214
+ dragging = false;
215
+ direction = null;
216
+
217
+ // Decide: snap open or closed?
218
+ // Fast flick overrides position
219
+ var shouldOpen: boolean;
220
+
221
+ if (Math.abs(velocity) > VELOCITY_THRESHOLD) {
222
+ shouldOpen = velocity > 0;
223
+ } else if (isOpen) {
224
+ // Currently open: close if dragged past threshold
225
+ shouldOpen = deltaX > -drawerWidth * SNAP_THRESHOLD;
226
+ } else {
227
+ // Currently closed: open if dragged past threshold
228
+ shouldOpen = deltaX > drawerWidth * SNAP_THRESHOLD;
229
+ }
230
+
231
+ if (shouldOpen) {
232
+ animateToOpen();
233
+ if (!isOpen) stateRef.current.onOpen();
234
+ } else {
235
+ animateToClosed();
236
+ if (isOpen) stateRef.current.onClose();
237
+ }
238
+
239
+ cleanupAfterAnimation();
240
+ }
241
+
242
+ function onTouchCancel() {
243
+ if (dragging) {
244
+ clearDragStyles();
245
+ // Restore drawer-side to CSS-driven state
246
+ var side = drawerSideRef.current;
247
+ if (side) {
248
+ side.style.visibility = "";
249
+ side.style.pointerEvents = "";
250
+ side.style.opacity = "";
251
+ }
252
+ if (content) {
253
+ content.style.transition = "";
254
+ content.style.transform = "";
255
+ content.style.transformOrigin = "";
256
+ }
257
+ }
258
+ tracking = false;
259
+ dragging = false;
260
+ direction = null;
261
+ }
262
+
263
+ document.addEventListener("touchstart", onTouchStart, { passive: true });
264
+ document.addEventListener("touchmove", onTouchMove, { passive: true });
265
+ document.addEventListener("touchend", onTouchEnd, { passive: true });
266
+ document.addEventListener("touchcancel", onTouchCancel, { passive: true });
267
+
268
+ return function () {
269
+ document.removeEventListener("touchstart", onTouchStart);
270
+ document.removeEventListener("touchmove", onTouchMove);
271
+ document.removeEventListener("touchend", onTouchEnd);
272
+ document.removeEventListener("touchcancel", onTouchCancel);
273
+ };
274
+ }, [drawerSideRef]);
275
+ }
@@ -0,0 +1,123 @@
1
+ import { useState, useCallback, useRef } from "react";
2
+
3
+ export interface UseVoiceRecorderReturn {
4
+ isRecording: boolean;
5
+ isSupported: boolean;
6
+ isSpeaking: boolean;
7
+ elapsed: number;
8
+ interimTranscript: string;
9
+ start: () => void;
10
+ stop: () => string;
11
+ cancel: () => void;
12
+ }
13
+
14
+ export function useVoiceRecorder(): UseVoiceRecorderReturn {
15
+ var [isRecording, setIsRecording] = useState(false);
16
+ var [isSpeaking, setIsSpeaking] = useState(false);
17
+ var [elapsed, setElapsed] = useState(0);
18
+ var [interimTranscript, setInterimTranscript] = useState("");
19
+
20
+ var recognitionRef = useRef<SpeechRecognition | null>(null);
21
+ var timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
22
+ var finalTranscriptRef = useRef("");
23
+ var startTimeRef = useRef(0);
24
+
25
+ var SpeechRecognitionClass = typeof window !== "undefined"
26
+ ? (window as unknown as { SpeechRecognition?: typeof SpeechRecognition; webkitSpeechRecognition?: typeof SpeechRecognition }).SpeechRecognition
27
+ || (window as unknown as { webkitSpeechRecognition?: typeof SpeechRecognition }).webkitSpeechRecognition
28
+ : undefined;
29
+
30
+ var isSupported = !!SpeechRecognitionClass;
31
+
32
+ var cleanup = useCallback(function () {
33
+ if (timerRef.current) {
34
+ clearInterval(timerRef.current);
35
+ timerRef.current = null;
36
+ }
37
+ setIsRecording(false);
38
+ setIsSpeaking(false);
39
+ setElapsed(0);
40
+ setInterimTranscript("");
41
+ recognitionRef.current = null;
42
+ }, []);
43
+
44
+ var start = useCallback(function () {
45
+ if (!SpeechRecognitionClass || isRecording) return;
46
+
47
+ var recognition = new SpeechRecognitionClass();
48
+ recognition.continuous = true;
49
+ recognition.interimResults = true;
50
+ recognition.lang = navigator.language || "en-US";
51
+
52
+ recognitionRef.current = recognition;
53
+ finalTranscriptRef.current = "";
54
+
55
+ recognition.onresult = function (event: SpeechRecognitionEvent) {
56
+ var interim = "";
57
+ var final = "";
58
+ for (var i = 0; i < event.results.length; i++) {
59
+ var result = event.results[i];
60
+ if (result.isFinal) {
61
+ final += result[0].transcript;
62
+ } else {
63
+ interim += result[0].transcript;
64
+ }
65
+ }
66
+ finalTranscriptRef.current = final;
67
+ setInterimTranscript(final + interim);
68
+ };
69
+
70
+ recognition.onspeechstart = function () {
71
+ setIsSpeaking(true);
72
+ };
73
+
74
+ recognition.onspeechend = function () {
75
+ setIsSpeaking(false);
76
+ };
77
+
78
+ recognition.onerror = function (event: SpeechRecognitionErrorEvent) {
79
+ console.error("[voice] Recognition error:", event.error);
80
+ cleanup();
81
+ };
82
+
83
+ recognition.onend = function () {
84
+ cleanup();
85
+ };
86
+
87
+ recognition.start();
88
+ setIsRecording(true);
89
+ startTimeRef.current = Date.now();
90
+
91
+ timerRef.current = setInterval(function () {
92
+ setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
93
+ }, 1000);
94
+ }, [SpeechRecognitionClass, isRecording, cleanup]);
95
+
96
+ var stop = useCallback(function (): string {
97
+ if (recognitionRef.current) {
98
+ recognitionRef.current.stop();
99
+ }
100
+ var transcript = finalTranscriptRef.current || interimTranscript;
101
+ cleanup();
102
+ return transcript;
103
+ }, [interimTranscript, cleanup]);
104
+
105
+ var cancel = useCallback(function () {
106
+ if (recognitionRef.current) {
107
+ recognitionRef.current.abort();
108
+ }
109
+ finalTranscriptRef.current = "";
110
+ cleanup();
111
+ }, [cleanup]);
112
+
113
+ return {
114
+ isRecording,
115
+ isSupported,
116
+ isSpeaking,
117
+ elapsed,
118
+ interimTranscript,
119
+ start,
120
+ stop,
121
+ cancel,
122
+ };
123
+ }
@@ -0,0 +1,48 @@
1
+ import { useStore } from "@tanstack/react-store";
2
+ import {
3
+ getWorkspaceStore,
4
+ openTab,
5
+ closeTab,
6
+ setActiveTab,
7
+ resetWorkspace,
8
+ splitPane,
9
+ closePane,
10
+ setPaneActiveTab,
11
+ setSplitRatio,
12
+ setActivePaneId,
13
+ } from "../stores/workspace";
14
+ import type { WorkspaceState, TabType } from "../stores/workspace";
15
+
16
+ export interface UseWorkspaceReturn extends WorkspaceState {
17
+ activeTabId: string;
18
+ openTab: (type: TabType) => void;
19
+ closeTab: (tabId: string) => void;
20
+ setActiveTab: (tabId: string) => void;
21
+ resetWorkspace: () => void;
22
+ splitPane: (tabId: string, direction: "horizontal" | "vertical") => void;
23
+ closePane: (paneId: string) => void;
24
+ setPaneActiveTab: (paneId: string, tabId: string) => void;
25
+ setSplitRatio: (ratio: number) => void;
26
+ setActivePaneId: (paneId: string) => void;
27
+ }
28
+
29
+ export function useWorkspace(): UseWorkspaceReturn {
30
+ var state = useStore(getWorkspaceStore(), function (s) { return s; });
31
+ return {
32
+ tabs: state.tabs,
33
+ panes: state.panes,
34
+ activePaneId: state.activePaneId,
35
+ splitDirection: state.splitDirection,
36
+ splitRatio: state.splitRatio,
37
+ activeTabId: state.panes.find(function (p) { return p.id === state.activePaneId; })?.activeTabId ?? state.panes[0]?.activeTabId ?? "chat",
38
+ openTab: openTab,
39
+ closeTab: closeTab,
40
+ setActiveTab: setActiveTab,
41
+ resetWorkspace: resetWorkspace,
42
+ splitPane: splitPane,
43
+ closePane: closePane,
44
+ setPaneActiveTab: setPaneActiveTab,
45
+ setSplitRatio: setSplitRatio,
46
+ setActivePaneId: setActivePaneId,
47
+ };
48
+ }