@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.
@@ -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
+
@@ -129,6 +129,7 @@ export function ChatPanel({
129
129
  messages={messages}
130
130
  showTypingIndicator={showTypingIndicator}
131
131
  topBanner={topBanner}
132
+ composerHorizontalPadding={0}
132
133
  listRef={listRef}
133
134
  onNearBottomChange={setNearBottom}
134
135
  overlay={
@@ -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 closeSheet = React.useCallback(() => {
108
- setSheetOpen(false);
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={setSheetOpen}>
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={chatMessages}
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={onSendChat}
240
+ onSend={optimistic.onSend}
228
241
  />
229
242
  }
230
243
  />