@comergehq/studio 0.1.8 → 0.1.10
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/dist/index.js +642 -427
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +363 -148
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/ChatMessageList.tsx +23 -35
- package/src/components/chat/ChatPage.tsx +26 -30
- package/src/components/studio-sheet/StudioBottomSheet.tsx +4 -19
- package/src/studio/ComergeStudio.tsx +34 -1
- package/src/studio/hooks/useAttachmentUpload.ts +51 -5
- package/src/studio/hooks/useBundleManager.ts +91 -17
- package/src/studio/hooks/useOptimisticChatMessages.ts +128 -0
- package/src/studio/ui/ChatPanel.tsx +1 -0
- package/src/studio/ui/RuntimeRenderer.tsx +7 -2
- package/src/studio/ui/StudioOverlay.tsx +19 -6
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ChatMessage } from '../../components/models/types';
|
|
4
|
+
|
|
5
|
+
export type UseOptimisticChatMessagesParams = {
|
|
6
|
+
threadId: string | null;
|
|
7
|
+
shouldForkOnEdit: boolean;
|
|
8
|
+
chatMessages: ChatMessage[];
|
|
9
|
+
onSendChat: (text: string, attachments?: string[]) => void | Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type UseOptimisticChatMessagesResult = {
|
|
13
|
+
messages: ChatMessage[];
|
|
14
|
+
onSend: (text: string, attachments?: string[]) => Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type OptimisticChatMessage = {
|
|
18
|
+
id: string;
|
|
19
|
+
content: string;
|
|
20
|
+
createdAtIso: string;
|
|
21
|
+
baseServerLastId: string | null;
|
|
22
|
+
failed: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function makeOptimisticId() {
|
|
26
|
+
return `optimistic:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 10)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toEpochMs(createdAt: ChatMessage['createdAt']): number {
|
|
30
|
+
if (createdAt == null) return 0;
|
|
31
|
+
if (typeof createdAt === 'number') return createdAt;
|
|
32
|
+
if (createdAt instanceof Date) return createdAt.getTime();
|
|
33
|
+
const t = Date.parse(String(createdAt));
|
|
34
|
+
return Number.isFinite(t) ? t : 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isOptimisticResolvedByServer(chatMessages: ChatMessage[], o: OptimisticChatMessage) {
|
|
38
|
+
if (o.failed) return false;
|
|
39
|
+
|
|
40
|
+
const normalize = (s: string) => s.trim();
|
|
41
|
+
|
|
42
|
+
let startIndex = -1;
|
|
43
|
+
if (o.baseServerLastId) {
|
|
44
|
+
startIndex = chatMessages.findIndex((m) => m.id === o.baseServerLastId);
|
|
45
|
+
}
|
|
46
|
+
const candidates = startIndex >= 0 ? chatMessages.slice(startIndex + 1) : chatMessages;
|
|
47
|
+
|
|
48
|
+
const target = normalize(o.content);
|
|
49
|
+
for (const m of candidates) {
|
|
50
|
+
if (m.author !== 'human') continue;
|
|
51
|
+
if (normalize(m.content) !== target) continue;
|
|
52
|
+
|
|
53
|
+
const serverMs = toEpochMs(m.createdAt);
|
|
54
|
+
const optimisticMs = Date.parse(o.createdAtIso);
|
|
55
|
+
if (Number.isFinite(optimisticMs) && optimisticMs > 0 && serverMs > 0) {
|
|
56
|
+
if (serverMs + 120_000 < optimisticMs) continue;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useOptimisticChatMessages({
|
|
64
|
+
threadId,
|
|
65
|
+
shouldForkOnEdit,
|
|
66
|
+
chatMessages,
|
|
67
|
+
onSendChat,
|
|
68
|
+
}: UseOptimisticChatMessagesParams): UseOptimisticChatMessagesResult {
|
|
69
|
+
const [optimisticChat, setOptimisticChat] = React.useState<OptimisticChatMessage[]>([]);
|
|
70
|
+
|
|
71
|
+
React.useEffect(() => {
|
|
72
|
+
setOptimisticChat([]);
|
|
73
|
+
}, [threadId]);
|
|
74
|
+
|
|
75
|
+
const messages = React.useMemo(() => {
|
|
76
|
+
if (!optimisticChat || optimisticChat.length === 0) return chatMessages;
|
|
77
|
+
|
|
78
|
+
const unresolved = optimisticChat.filter((o) => !isOptimisticResolvedByServer(chatMessages, o));
|
|
79
|
+
if (unresolved.length === 0) return chatMessages;
|
|
80
|
+
|
|
81
|
+
const optimisticAsChat = unresolved.map<ChatMessage>((o) => ({
|
|
82
|
+
id: o.id,
|
|
83
|
+
author: 'human',
|
|
84
|
+
content: o.content,
|
|
85
|
+
createdAt: o.createdAtIso,
|
|
86
|
+
kind: 'optimistic',
|
|
87
|
+
meta: o.failed
|
|
88
|
+
? { kind: 'optimistic', event: 'send.failed', status: 'error' }
|
|
89
|
+
: { kind: 'optimistic', event: 'send.pending', status: 'info' },
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
const merged = [...chatMessages, ...optimisticAsChat];
|
|
93
|
+
merged.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
|
|
94
|
+
return merged;
|
|
95
|
+
}, [chatMessages, optimisticChat]);
|
|
96
|
+
|
|
97
|
+
React.useEffect(() => {
|
|
98
|
+
if (optimisticChat.length === 0) return;
|
|
99
|
+
setOptimisticChat((prev) => {
|
|
100
|
+
if (prev.length === 0) return prev;
|
|
101
|
+
const next = prev.filter((o) => !isOptimisticResolvedByServer(chatMessages, o) || o.failed);
|
|
102
|
+
return next.length === prev.length ? prev : next;
|
|
103
|
+
});
|
|
104
|
+
}, [chatMessages, optimisticChat.length]);
|
|
105
|
+
|
|
106
|
+
const onSend = React.useCallback(
|
|
107
|
+
async (text: string, attachments?: string[]) => {
|
|
108
|
+
if (shouldForkOnEdit) {
|
|
109
|
+
await onSendChat(text, attachments);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const createdAtIso = new Date().toISOString();
|
|
114
|
+
const baseServerLastId = chatMessages.length > 0 ? chatMessages[chatMessages.length - 1]!.id : null;
|
|
115
|
+
const id = makeOptimisticId();
|
|
116
|
+
|
|
117
|
+
setOptimisticChat((prev) => [...prev, { id, content: text, createdAtIso, baseServerLastId, failed: false }]);
|
|
118
|
+
|
|
119
|
+
void Promise.resolve(onSendChat(text, attachments)).catch(() => {
|
|
120
|
+
setOptimisticChat((prev) => prev.map((m) => (m.id === id ? { ...m, failed: true } : m)));
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
[chatMessages, onSendChat, shouldForkOnEdit]
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return { messages, onSend };
|
|
127
|
+
}
|
|
128
|
+
|
|
@@ -8,6 +8,11 @@ import { Text } from '../../components/primitives/Text';
|
|
|
8
8
|
export type RuntimeRendererProps = {
|
|
9
9
|
appKey: string;
|
|
10
10
|
bundlePath: string | null;
|
|
11
|
+
/**
|
|
12
|
+
* When true, show the "Preparing app…" UI even if a previous bundle is available.
|
|
13
|
+
* Used to avoid briefly rendering an outdated bundle during post-edit base refresh.
|
|
14
|
+
*/
|
|
15
|
+
forcePreparing?: boolean;
|
|
11
16
|
/**
|
|
12
17
|
* Used to force a runtime remount even when bundlePath stays constant
|
|
13
18
|
* (e.g. base bundle replaced in-place).
|
|
@@ -16,8 +21,8 @@ export type RuntimeRendererProps = {
|
|
|
16
21
|
style?: ViewStyle;
|
|
17
22
|
};
|
|
18
23
|
|
|
19
|
-
export function RuntimeRenderer({ appKey, bundlePath, renderToken, style }: RuntimeRendererProps) {
|
|
20
|
-
if (!bundlePath) {
|
|
24
|
+
export function RuntimeRenderer({ appKey, bundlePath, forcePreparing, renderToken, style }: RuntimeRendererProps) {
|
|
25
|
+
if (!bundlePath || forcePreparing) {
|
|
21
26
|
return (
|
|
22
27
|
<View style={[{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 }, style]}>
|
|
23
28
|
<Text variant="bodyMuted">Preparing app…</Text>
|
|
@@ -14,6 +14,7 @@ import { ChatPanel } from './ChatPanel';
|
|
|
14
14
|
import { ConfirmMergeFlow } from './ConfirmMergeFlow';
|
|
15
15
|
import type { MergeRequestSummary } from '../../components/models/types';
|
|
16
16
|
import { useTheme } from '../../theme';
|
|
17
|
+
import { useOptimisticChatMessages } from '../hooks/useOptimisticChatMessages';
|
|
17
18
|
|
|
18
19
|
import { MergeIcon } from '../../components/icons/MergeIcon';
|
|
19
20
|
|
|
@@ -98,17 +99,29 @@ export function StudioOverlay({
|
|
|
98
99
|
const [commentsAppId, setCommentsAppId] = React.useState<string | null>(null);
|
|
99
100
|
const [commentsCount, setCommentsCount] = React.useState<number | null>(null);
|
|
100
101
|
|
|
102
|
+
const threadId = app?.threadId ?? null;
|
|
103
|
+
const optimistic = useOptimisticChatMessages({
|
|
104
|
+
threadId,
|
|
105
|
+
shouldForkOnEdit,
|
|
106
|
+
chatMessages,
|
|
107
|
+
onSendChat,
|
|
108
|
+
});
|
|
109
|
+
|
|
101
110
|
const [confirmMrId, setConfirmMrId] = React.useState<string | null>(null);
|
|
102
111
|
const confirmMr = React.useMemo(
|
|
103
112
|
() => (confirmMrId ? incomingMergeRequests.find((m) => m.id === confirmMrId) ?? null : null),
|
|
104
113
|
[confirmMrId, incomingMergeRequests]
|
|
105
114
|
);
|
|
106
115
|
|
|
107
|
-
const
|
|
108
|
-
setSheetOpen(
|
|
109
|
-
Keyboard.dismiss();
|
|
116
|
+
const handleSheetOpenChange = React.useCallback((open: boolean) => {
|
|
117
|
+
setSheetOpen(open);
|
|
118
|
+
if (!open) Keyboard.dismiss();
|
|
110
119
|
}, []);
|
|
111
120
|
|
|
121
|
+
const closeSheet = React.useCallback(() => {
|
|
122
|
+
handleSheetOpenChange(false);
|
|
123
|
+
}, [handleSheetOpenChange]);
|
|
124
|
+
|
|
112
125
|
const openSheet = React.useCallback(() => setSheetOpen(true), []);
|
|
113
126
|
|
|
114
127
|
const goToChat = React.useCallback(() => {
|
|
@@ -178,7 +191,7 @@ export function StudioOverlay({
|
|
|
178
191
|
{/* Testing glow around runtime */}
|
|
179
192
|
<EdgeGlowFrame visible={isTesting} role="accent" thickness={40} intensity={1} />
|
|
180
193
|
|
|
181
|
-
<StudioBottomSheet open={sheetOpen} onOpenChange={
|
|
194
|
+
<StudioBottomSheet open={sheetOpen} onOpenChange={handleSheetOpenChange}>
|
|
182
195
|
<StudioSheetPager
|
|
183
196
|
activePage={activePage}
|
|
184
197
|
width={width}
|
|
@@ -209,7 +222,7 @@ export function StudioOverlay({
|
|
|
209
222
|
}
|
|
210
223
|
chat={
|
|
211
224
|
<ChatPanel
|
|
212
|
-
messages={
|
|
225
|
+
messages={optimistic.messages}
|
|
213
226
|
showTypingIndicator={chatShowTypingIndicator}
|
|
214
227
|
loading={chatLoading}
|
|
215
228
|
sendDisabled={chatSendDisabled}
|
|
@@ -224,7 +237,7 @@ export function StudioOverlay({
|
|
|
224
237
|
onClose={closeSheet}
|
|
225
238
|
onNavigateHome={onNavigateHome}
|
|
226
239
|
onStartDraw={startDraw}
|
|
227
|
-
onSend={
|
|
240
|
+
onSend={optimistic.onSend}
|
|
228
241
|
/>
|
|
229
242
|
}
|
|
230
243
|
/>
|