@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
|
@@ -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
|
-
|
|
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
|
+
}
|