@comergehq/studio 0.1.26 → 0.1.28
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.d.mts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1576 -601
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1292 -317
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/components/chat/AgentProgressCard.tsx +82 -0
- package/src/components/chat/BundleProgressCard.tsx +75 -0
- package/src/components/chat/ChatMessageBubble.tsx +48 -4
- package/src/components/chat/ChatMessageList.tsx +56 -36
- package/src/components/comments/useAppComments.ts +12 -0
- package/src/data/agent-progress/repository.ts +179 -0
- package/src/data/agent-progress/types.ts +67 -0
- package/src/studio/ComergeStudio.tsx +23 -2
- package/src/studio/analytics/client.ts +103 -0
- package/src/studio/analytics/events.ts +98 -0
- package/src/studio/analytics/track.ts +237 -0
- package/src/studio/bootstrap/StudioBootstrap.tsx +8 -2
- package/src/studio/bootstrap/useStudioBootstrap.ts +18 -2
- package/src/studio/hooks/useAgentRunProgress.ts +357 -0
- package/src/studio/hooks/useAppStats.ts +14 -2
- package/src/studio/hooks/useBundleManager.ts +43 -5
- package/src/studio/hooks/useMergeRequests.ts +63 -14
- package/src/studio/hooks/useStudioActions.ts +34 -1
- package/src/studio/ui/ChatPanel.tsx +13 -2
- package/src/studio/ui/PreviewPanel.tsx +10 -0
- package/src/studio/ui/RuntimeRenderer.tsx +6 -1
- package/src/studio/ui/StudioOverlay.tsx +3 -0
- package/src/studio/ui/preview-panel/usePreviewPanelData.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comergehq/studio",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"description": "Comerge studio",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"react-native": "*",
|
|
69
69
|
"react-native-safe-area-context": "*",
|
|
70
70
|
"react-native-svg": "*",
|
|
71
|
+
"mixpanel-react-native": "*",
|
|
71
72
|
"react-native-zip-archive": "*",
|
|
72
73
|
"react-native-view-shot": "*"
|
|
73
74
|
},
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import type { AgentRunProgressView } from '../../studio/hooks/useAgentRunProgress';
|
|
5
|
+
import { useTheme } from '../../theme';
|
|
6
|
+
import { Text } from '../primitives/Text';
|
|
7
|
+
import { withAlpha } from '../utils/color';
|
|
8
|
+
|
|
9
|
+
export type AgentProgressCardProps = {
|
|
10
|
+
progress: AgentRunProgressView;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function titleForPhase(phase: AgentRunProgressView['phase']): string {
|
|
14
|
+
if (phase === 'planning') return 'Planning';
|
|
15
|
+
if (phase === 'reasoning') return 'Reasoning';
|
|
16
|
+
if (phase === 'analyzing') return 'Analyzing';
|
|
17
|
+
if (phase === 'editing') return 'Editing';
|
|
18
|
+
if (phase === 'executing') return 'Executing';
|
|
19
|
+
if (phase === 'validating') return 'Validating';
|
|
20
|
+
if (phase === 'finalizing') return 'Finalizing';
|
|
21
|
+
if (phase === 'working') return 'Working';
|
|
22
|
+
return 'Working';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function titleForStatus(status: AgentRunProgressView['status']): string {
|
|
26
|
+
if (status === 'succeeded') return 'Completed';
|
|
27
|
+
if (status === 'failed') return 'Failed';
|
|
28
|
+
if (status === 'cancelled') return 'Cancelled';
|
|
29
|
+
return 'In Progress';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function AgentProgressCard({ progress }: AgentProgressCardProps) {
|
|
33
|
+
const theme = useTheme();
|
|
34
|
+
const statusLabel = titleForStatus(progress.status);
|
|
35
|
+
const phaseLabel = titleForPhase(progress.phase);
|
|
36
|
+
const subtitle = progress.latestMessage || `Agent is ${phaseLabel.toLowerCase()}...`;
|
|
37
|
+
const todo = progress.todoSummary;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<View
|
|
41
|
+
style={{
|
|
42
|
+
borderWidth: 1,
|
|
43
|
+
borderColor: theme.colors.border,
|
|
44
|
+
borderRadius: theme.radii.lg,
|
|
45
|
+
marginHorizontal: theme.spacing.md,
|
|
46
|
+
padding: theme.spacing.md,
|
|
47
|
+
backgroundColor: withAlpha(theme.colors.surface, theme.scheme === 'dark' ? 0.84 : 0.94),
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
|
51
|
+
<Text variant="caption">{statusLabel}</Text>
|
|
52
|
+
<Text variant="captionMuted">{phaseLabel}</Text>
|
|
53
|
+
</View>
|
|
54
|
+
|
|
55
|
+
<Text variant="bodyMuted">{subtitle}</Text>
|
|
56
|
+
|
|
57
|
+
{progress.changedFilesCount > 0 ? (
|
|
58
|
+
<Text variant="captionMuted" style={{ marginTop: 8 }}>
|
|
59
|
+
Updated files: {progress.changedFilesCount}
|
|
60
|
+
</Text>
|
|
61
|
+
) : null}
|
|
62
|
+
|
|
63
|
+
{progress.recentFiles.length > 0 ? (
|
|
64
|
+
<View style={{ marginTop: 6 }}>
|
|
65
|
+
{progress.recentFiles.map((path) => (
|
|
66
|
+
<Text key={path} variant="captionMuted" numberOfLines={1}>
|
|
67
|
+
• {path}
|
|
68
|
+
</Text>
|
|
69
|
+
))}
|
|
70
|
+
</View>
|
|
71
|
+
) : null}
|
|
72
|
+
|
|
73
|
+
{todo ? (
|
|
74
|
+
<Text variant="captionMuted" style={{ marginTop: 8 }}>
|
|
75
|
+
Todos: {todo.completed}/{todo.total} complete
|
|
76
|
+
{todo.currentTask ? ` • ${todo.currentTask}` : ''}
|
|
77
|
+
</Text>
|
|
78
|
+
) : null}
|
|
79
|
+
</View>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import type { AgentBundleProgressView } from '../../studio/hooks/useAgentRunProgress';
|
|
5
|
+
import { useTheme } from '../../theme';
|
|
6
|
+
import { Text } from '../primitives/Text';
|
|
7
|
+
import { withAlpha } from '../utils/color';
|
|
8
|
+
|
|
9
|
+
export type BundleProgressCardProps = {
|
|
10
|
+
progress: AgentBundleProgressView;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function titleForStatus(status: AgentBundleProgressView['status']): string {
|
|
14
|
+
if (status === 'succeeded') return 'Completed';
|
|
15
|
+
if (status === 'failed') return 'Failed';
|
|
16
|
+
return 'In Progress';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function BundleProgressCard({ progress }: BundleProgressCardProps) {
|
|
20
|
+
const theme = useTheme();
|
|
21
|
+
const statusLabel = titleForStatus(progress.status);
|
|
22
|
+
const percent = Math.round(Math.max(0, Math.min(1, progress.progressValue)) * 100);
|
|
23
|
+
const fillColor =
|
|
24
|
+
progress.status === 'failed'
|
|
25
|
+
? theme.colors.danger
|
|
26
|
+
: progress.status === 'succeeded'
|
|
27
|
+
? theme.colors.success
|
|
28
|
+
: theme.colors.warning;
|
|
29
|
+
const detail = progress.errorMessage || progress.phaseLabel;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<View
|
|
33
|
+
accessible
|
|
34
|
+
accessibilityRole="progressbar"
|
|
35
|
+
accessibilityLabel={`Bundle progress ${statusLabel}`}
|
|
36
|
+
accessibilityValue={{ min: 0, max: 100, now: percent, text: `${percent}%` }}
|
|
37
|
+
style={{
|
|
38
|
+
borderWidth: 1,
|
|
39
|
+
borderColor: theme.colors.border,
|
|
40
|
+
borderRadius: theme.radii.lg,
|
|
41
|
+
marginHorizontal: theme.spacing.md,
|
|
42
|
+
padding: theme.spacing.md,
|
|
43
|
+
backgroundColor: withAlpha(theme.colors.surface, theme.scheme === 'dark' ? 0.84 : 0.94),
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
|
47
|
+
<Text variant="caption">{statusLabel}</Text>
|
|
48
|
+
<Text variant="captionMuted">{percent}%</Text>
|
|
49
|
+
</View>
|
|
50
|
+
|
|
51
|
+
<View
|
|
52
|
+
style={{
|
|
53
|
+
width: '100%',
|
|
54
|
+
height: 8,
|
|
55
|
+
borderRadius: 999,
|
|
56
|
+
backgroundColor: withAlpha(theme.colors.border, theme.scheme === 'dark' ? 0.5 : 0.6),
|
|
57
|
+
overflow: 'hidden',
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<View
|
|
61
|
+
style={{
|
|
62
|
+
width: `${percent}%`,
|
|
63
|
+
height: '100%',
|
|
64
|
+
backgroundColor: fillColor,
|
|
65
|
+
}}
|
|
66
|
+
/>
|
|
67
|
+
</View>
|
|
68
|
+
|
|
69
|
+
<Text variant="captionMuted" numberOfLines={1} style={{ marginTop: 8, minHeight: 16 }}>
|
|
70
|
+
{detail}
|
|
71
|
+
</Text>
|
|
72
|
+
</View>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
@@ -17,11 +17,33 @@ export type ChatMessageBubbleProps = {
|
|
|
17
17
|
renderContent?: (message: ChatMessage) => React.ReactNode;
|
|
18
18
|
isLast?: boolean;
|
|
19
19
|
retrying?: boolean;
|
|
20
|
-
|
|
20
|
+
onRetryMessage?: (messageId: string) => void;
|
|
21
21
|
style?: ViewStyle;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
function areMessageMetaEqual(a: ChatMessage['meta'], b: ChatMessage['meta']): boolean {
|
|
25
|
+
if (a === b) return true;
|
|
26
|
+
if (!a || !b) return a === b;
|
|
27
|
+
return (
|
|
28
|
+
a.kind === b.kind &&
|
|
29
|
+
a.event === b.event &&
|
|
30
|
+
a.status === b.status &&
|
|
31
|
+
a.mergeRequestId === b.mergeRequestId &&
|
|
32
|
+
a.sourceAppId === b.sourceAppId &&
|
|
33
|
+
a.targetAppId === b.targetAppId &&
|
|
34
|
+
a.appId === b.appId &&
|
|
35
|
+
a.threadId === b.threadId
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ChatMessageBubbleInner({
|
|
40
|
+
message,
|
|
41
|
+
renderContent,
|
|
42
|
+
isLast,
|
|
43
|
+
retrying,
|
|
44
|
+
onRetryMessage,
|
|
45
|
+
style,
|
|
46
|
+
}: ChatMessageBubbleProps) {
|
|
25
47
|
const theme = useTheme();
|
|
26
48
|
const metaEvent = message.meta?.event ?? null;
|
|
27
49
|
const metaStatus = message.meta?.status ?? null;
|
|
@@ -41,8 +63,11 @@ export function ChatMessageBubble({ message, renderContent, isLast, retrying, on
|
|
|
41
63
|
|
|
42
64
|
const bodyColor =
|
|
43
65
|
metaStatus === 'success' ? theme.colors.success : metaStatus === 'error' ? theme.colors.danger : undefined;
|
|
44
|
-
const showRetry = Boolean(
|
|
66
|
+
const showRetry = Boolean(onRetryMessage) && isLast && metaStatus === 'error' && message.author === 'human';
|
|
45
67
|
const retryLabel = retrying ? 'Retrying...' : 'Retry';
|
|
68
|
+
const handleRetryPress = React.useCallback(() => {
|
|
69
|
+
onRetryMessage?.(message.id);
|
|
70
|
+
}, [message.id, onRetryMessage]);
|
|
46
71
|
|
|
47
72
|
return (
|
|
48
73
|
<View style={[align, style]}>
|
|
@@ -77,7 +102,7 @@ export function ChatMessageBubble({ message, renderContent, isLast, retrying, on
|
|
|
77
102
|
<Button
|
|
78
103
|
variant="ghost"
|
|
79
104
|
size="sm"
|
|
80
|
-
onPress={
|
|
105
|
+
onPress={handleRetryPress}
|
|
81
106
|
disabled={retrying}
|
|
82
107
|
style={{ borderColor: theme.colors.danger }}
|
|
83
108
|
accessibilityLabel="Retry send"
|
|
@@ -100,4 +125,23 @@ export function ChatMessageBubble({ message, renderContent, isLast, retrying, on
|
|
|
100
125
|
);
|
|
101
126
|
}
|
|
102
127
|
|
|
128
|
+
function areEqual(prev: ChatMessageBubbleProps, next: ChatMessageBubbleProps): boolean {
|
|
129
|
+
return (
|
|
130
|
+
prev.message.id === next.message.id &&
|
|
131
|
+
prev.message.author === next.message.author &&
|
|
132
|
+
prev.message.content === next.message.content &&
|
|
133
|
+
prev.message.kind === next.message.kind &&
|
|
134
|
+
String(prev.message.createdAt) === String(next.message.createdAt) &&
|
|
135
|
+
areMessageMetaEqual(prev.message.meta, next.message.meta) &&
|
|
136
|
+
prev.renderContent === next.renderContent &&
|
|
137
|
+
prev.isLast === next.isLast &&
|
|
138
|
+
prev.retrying === next.retrying &&
|
|
139
|
+
prev.onRetryMessage === next.onRetryMessage &&
|
|
140
|
+
prev.style === next.style
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const ChatMessageBubble = React.memo(ChatMessageBubbleInner, areEqual);
|
|
145
|
+
ChatMessageBubble.displayName = 'ChatMessageBubble';
|
|
146
|
+
|
|
103
147
|
|
|
@@ -54,6 +54,7 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
|
|
|
54
54
|
return [...messages].reverse();
|
|
55
55
|
}, [messages]);
|
|
56
56
|
const lastMessageId = messages.length > 0 ? messages[messages.length - 1]!.id : null;
|
|
57
|
+
const keyExtractor = React.useCallback((m: ChatMessage) => m.id, []);
|
|
57
58
|
|
|
58
59
|
const scrollToBottom = React.useCallback((options?: { animated?: boolean }) => {
|
|
59
60
|
const animated = options?.animated ?? true;
|
|
@@ -99,51 +100,70 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
|
|
|
99
100
|
return undefined;
|
|
100
101
|
}, [showTypingIndicator, scrollToBottom]);
|
|
101
102
|
|
|
103
|
+
const handleContentSizeChange = React.useCallback(() => {
|
|
104
|
+
if (initialScrollDoneRef.current) return;
|
|
105
|
+
initialScrollDoneRef.current = true;
|
|
106
|
+
lastMessageIdRef.current = messages.length > 0 ? messages[messages.length - 1]!.id : null;
|
|
107
|
+
nearBottomRef.current = true;
|
|
108
|
+
onNearBottomChange?.(true);
|
|
109
|
+
requestAnimationFrame(() => scrollToBottom({ animated: false }));
|
|
110
|
+
}, [messages, onNearBottomChange, scrollToBottom]);
|
|
111
|
+
|
|
112
|
+
const contentContainerStyle = React.useMemo(
|
|
113
|
+
() => [
|
|
114
|
+
{
|
|
115
|
+
paddingHorizontal: theme.spacing.lg,
|
|
116
|
+
paddingVertical: theme.spacing.sm,
|
|
117
|
+
},
|
|
118
|
+
contentStyle,
|
|
119
|
+
],
|
|
120
|
+
[contentStyle, theme.spacing.lg, theme.spacing.sm]
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const renderSeparator = React.useCallback(() => <View style={{ height: theme.spacing.sm }} />, [theme.spacing.sm]);
|
|
124
|
+
|
|
125
|
+
const listHeader = React.useMemo(
|
|
126
|
+
() => (
|
|
127
|
+
<View>
|
|
128
|
+
{showTypingIndicator ? (
|
|
129
|
+
<View style={{ marginTop: theme.spacing.sm, alignSelf: 'flex-start', paddingHorizontal: theme.spacing.lg }}>
|
|
130
|
+
<TypingIndicator />
|
|
131
|
+
</View>
|
|
132
|
+
) : null}
|
|
133
|
+
{bottomInset > 0 ? <View style={{ height: bottomInset }} /> : null}
|
|
134
|
+
</View>
|
|
135
|
+
),
|
|
136
|
+
[bottomInset, showTypingIndicator, theme.spacing.lg, theme.spacing.sm]
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const renderItem = React.useCallback(
|
|
140
|
+
({ item }: { item: ChatMessage }) => (
|
|
141
|
+
<ChatMessageBubble
|
|
142
|
+
message={item}
|
|
143
|
+
renderContent={renderMessageContent}
|
|
144
|
+
isLast={Boolean(lastMessageId && item.id === lastMessageId)}
|
|
145
|
+
retrying={isRetryingMessage?.(item.id) ?? false}
|
|
146
|
+
onRetryMessage={onRetryMessage}
|
|
147
|
+
/>
|
|
148
|
+
),
|
|
149
|
+
[isRetryingMessage, lastMessageId, onRetryMessage, renderMessageContent]
|
|
150
|
+
);
|
|
151
|
+
|
|
102
152
|
return (
|
|
103
153
|
<BottomSheetFlatList
|
|
104
154
|
ref={listRef}
|
|
105
155
|
inverted
|
|
106
156
|
data={data}
|
|
107
|
-
keyExtractor={
|
|
157
|
+
keyExtractor={keyExtractor}
|
|
108
158
|
keyboardShouldPersistTaps="handled"
|
|
109
159
|
onScroll={handleScroll}
|
|
110
160
|
scrollEventThrottle={16}
|
|
111
161
|
showsVerticalScrollIndicator={false}
|
|
112
|
-
onContentSizeChange={
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
onNearBottomChange?.(true);
|
|
118
|
-
requestAnimationFrame(() => scrollToBottom({ animated: false }));
|
|
119
|
-
}}
|
|
120
|
-
contentContainerStyle={[
|
|
121
|
-
{
|
|
122
|
-
paddingHorizontal: theme.spacing.lg,
|
|
123
|
-
paddingVertical: theme.spacing.sm,
|
|
124
|
-
},
|
|
125
|
-
contentStyle,
|
|
126
|
-
]}
|
|
127
|
-
ItemSeparatorComponent={() => <View style={{ height: theme.spacing.sm }} />}
|
|
128
|
-
renderItem={({ item }: { item: ChatMessage }) => (
|
|
129
|
-
<ChatMessageBubble
|
|
130
|
-
message={item}
|
|
131
|
-
renderContent={renderMessageContent}
|
|
132
|
-
isLast={Boolean(lastMessageId && item.id === lastMessageId)}
|
|
133
|
-
retrying={isRetryingMessage?.(item.id) ?? false}
|
|
134
|
-
onRetry={onRetryMessage ? () => onRetryMessage(item.id) : undefined}
|
|
135
|
-
/>
|
|
136
|
-
)}
|
|
137
|
-
ListHeaderComponent={
|
|
138
|
-
<View>
|
|
139
|
-
{showTypingIndicator ? (
|
|
140
|
-
<View style={{ marginTop: theme.spacing.sm, alignSelf: 'flex-start', paddingHorizontal: theme.spacing.lg }}>
|
|
141
|
-
<TypingIndicator />
|
|
142
|
-
</View>
|
|
143
|
-
) : null}
|
|
144
|
-
{bottomInset > 0 ? <View style={{ height: bottomInset }} /> : null}
|
|
145
|
-
</View>
|
|
146
|
-
}
|
|
162
|
+
onContentSizeChange={handleContentSizeChange}
|
|
163
|
+
contentContainerStyle={contentContainerStyle}
|
|
164
|
+
ItemSeparatorComponent={renderSeparator}
|
|
165
|
+
renderItem={renderItem}
|
|
166
|
+
ListHeaderComponent={listHeader}
|
|
147
167
|
/>
|
|
148
168
|
);
|
|
149
169
|
}
|
|
@@ -2,6 +2,7 @@ import * as React from 'react';
|
|
|
2
2
|
|
|
3
3
|
import { appCommentsRepository } from '../../data/comments/repository';
|
|
4
4
|
import type { AppComment } from '../../data/comments/types';
|
|
5
|
+
import { trackSubmitComment } from '../../studio/analytics/track';
|
|
5
6
|
|
|
6
7
|
export type UseAppCommentsResult = {
|
|
7
8
|
comments: AppComment[];
|
|
@@ -58,7 +59,18 @@ export function useAppComments(appId: string | null): UseAppCommentsResult {
|
|
|
58
59
|
try {
|
|
59
60
|
const newComment = await appCommentsRepository.create(appId, { body: trimmed, commentType: 'general' });
|
|
60
61
|
setComments((prev) => sortByCreatedAtAsc([...prev, newComment]));
|
|
62
|
+
await trackSubmitComment({
|
|
63
|
+
appId,
|
|
64
|
+
commentLength: trimmed.length,
|
|
65
|
+
success: true,
|
|
66
|
+
});
|
|
61
67
|
} catch (e) {
|
|
68
|
+
await trackSubmitComment({
|
|
69
|
+
appId,
|
|
70
|
+
commentLength: trimmed.length,
|
|
71
|
+
success: false,
|
|
72
|
+
error: e,
|
|
73
|
+
});
|
|
62
74
|
setError(e instanceof Error ? e : new Error(String(e)));
|
|
63
75
|
throw e;
|
|
64
76
|
} finally {
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { getSupabaseClient } from '../../core/services/supabase';
|
|
2
|
+
import { subscribeManagedChannel } from '../../core/services/supabase/realtimeManager';
|
|
3
|
+
import type { AgentRun, AgentRunEvent } from './types';
|
|
4
|
+
|
|
5
|
+
type DbAgentRunRow = {
|
|
6
|
+
id: string;
|
|
7
|
+
app_id: string;
|
|
8
|
+
thread_id: string;
|
|
9
|
+
queue_item_id: string | null;
|
|
10
|
+
status: AgentRun['status'];
|
|
11
|
+
current_phase: AgentRun['currentPhase'];
|
|
12
|
+
last_seq: number;
|
|
13
|
+
summary: Record<string, unknown> | null;
|
|
14
|
+
error_code: string | null;
|
|
15
|
+
error_message: string | null;
|
|
16
|
+
started_at: string;
|
|
17
|
+
finished_at: string | null;
|
|
18
|
+
created_at: string;
|
|
19
|
+
updated_at: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type DbAgentRunEventRow = {
|
|
23
|
+
id: string;
|
|
24
|
+
run_id: string;
|
|
25
|
+
app_id: string;
|
|
26
|
+
thread_id: string;
|
|
27
|
+
queue_item_id: string | null;
|
|
28
|
+
seq: number;
|
|
29
|
+
event_type: AgentRunEvent['eventType'];
|
|
30
|
+
phase: AgentRunEvent['phase'];
|
|
31
|
+
tool_name: string | null;
|
|
32
|
+
path: string | null;
|
|
33
|
+
payload: Record<string, unknown> | null;
|
|
34
|
+
created_at: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function mapRun(row: DbAgentRunRow): AgentRun {
|
|
38
|
+
return {
|
|
39
|
+
id: row.id,
|
|
40
|
+
appId: row.app_id,
|
|
41
|
+
threadId: row.thread_id,
|
|
42
|
+
queueItemId: row.queue_item_id,
|
|
43
|
+
status: row.status,
|
|
44
|
+
currentPhase: row.current_phase,
|
|
45
|
+
lastSeq: Number(row.last_seq || 0),
|
|
46
|
+
summary: (row.summary || {}) as Record<string, unknown>,
|
|
47
|
+
errorCode: row.error_code,
|
|
48
|
+
errorMessage: row.error_message,
|
|
49
|
+
startedAt: row.started_at,
|
|
50
|
+
finishedAt: row.finished_at,
|
|
51
|
+
createdAt: row.created_at,
|
|
52
|
+
updatedAt: row.updated_at,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function mapEvent(row: DbAgentRunEventRow): AgentRunEvent {
|
|
57
|
+
return {
|
|
58
|
+
id: row.id,
|
|
59
|
+
runId: row.run_id,
|
|
60
|
+
appId: row.app_id,
|
|
61
|
+
threadId: row.thread_id,
|
|
62
|
+
queueItemId: row.queue_item_id,
|
|
63
|
+
seq: Number(row.seq || 0),
|
|
64
|
+
eventType: row.event_type,
|
|
65
|
+
phase: row.phase,
|
|
66
|
+
toolName: row.tool_name,
|
|
67
|
+
path: row.path,
|
|
68
|
+
payload: (row.payload || {}) as Record<string, unknown>,
|
|
69
|
+
createdAt: row.created_at,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AgentProgressRepository {
|
|
74
|
+
getLatestRun(threadId: string): Promise<AgentRun | null>;
|
|
75
|
+
listEvents(runId: string, afterSeq?: number): Promise<AgentRunEvent[]>;
|
|
76
|
+
subscribeThreadRuns(
|
|
77
|
+
threadId: string,
|
|
78
|
+
handlers: {
|
|
79
|
+
onInsert?: (run: AgentRun) => void;
|
|
80
|
+
onUpdate?: (run: AgentRun) => void;
|
|
81
|
+
}
|
|
82
|
+
): () => void;
|
|
83
|
+
subscribeRunEvents(
|
|
84
|
+
runId: string,
|
|
85
|
+
handlers: {
|
|
86
|
+
onInsert?: (event: AgentRunEvent) => void;
|
|
87
|
+
onUpdate?: (event: AgentRunEvent) => void;
|
|
88
|
+
}
|
|
89
|
+
): () => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
class AgentProgressRepositoryImpl implements AgentProgressRepository {
|
|
93
|
+
async getLatestRun(threadId: string): Promise<AgentRun | null> {
|
|
94
|
+
if (!threadId) return null;
|
|
95
|
+
const supabase = getSupabaseClient();
|
|
96
|
+
const { data, error } = await (supabase as any)
|
|
97
|
+
.from('agent_run')
|
|
98
|
+
.select('*')
|
|
99
|
+
.eq('thread_id', threadId)
|
|
100
|
+
.order('started_at', { ascending: false })
|
|
101
|
+
.limit(1)
|
|
102
|
+
.maybeSingle();
|
|
103
|
+
if (error) throw new Error(error.message || 'Failed to fetch latest agent run');
|
|
104
|
+
if (!data) return null;
|
|
105
|
+
return mapRun(data as DbAgentRunRow);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async listEvents(runId: string, afterSeq?: number): Promise<AgentRunEvent[]> {
|
|
109
|
+
if (!runId) return [];
|
|
110
|
+
const supabase = getSupabaseClient();
|
|
111
|
+
let query = (supabase as any).from('agent_run_event').select('*').eq('run_id', runId).order('seq', { ascending: true });
|
|
112
|
+
if (typeof afterSeq === 'number' && Number.isFinite(afterSeq) && afterSeq > 0) {
|
|
113
|
+
query = query.gt('seq', afterSeq);
|
|
114
|
+
}
|
|
115
|
+
const { data, error } = await query;
|
|
116
|
+
if (error) throw new Error(error.message || 'Failed to fetch agent run events');
|
|
117
|
+
const rows = Array.isArray(data) ? (data as DbAgentRunEventRow[]) : [];
|
|
118
|
+
return rows.map(mapEvent);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
subscribeThreadRuns(
|
|
122
|
+
threadId: string,
|
|
123
|
+
handlers: {
|
|
124
|
+
onInsert?: (run: AgentRun) => void;
|
|
125
|
+
onUpdate?: (run: AgentRun) => void;
|
|
126
|
+
}
|
|
127
|
+
): () => void {
|
|
128
|
+
return subscribeManagedChannel(`agent-progress:runs:thread:${threadId}`, (channel) => {
|
|
129
|
+
channel
|
|
130
|
+
.on(
|
|
131
|
+
'postgres_changes',
|
|
132
|
+
{ event: 'INSERT', schema: 'public', table: 'agent_run', filter: `thread_id=eq.${threadId}` },
|
|
133
|
+
(payload) => {
|
|
134
|
+
const row = payload.new as DbAgentRunRow;
|
|
135
|
+
handlers.onInsert?.(mapRun(row));
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
.on(
|
|
139
|
+
'postgres_changes',
|
|
140
|
+
{ event: 'UPDATE', schema: 'public', table: 'agent_run', filter: `thread_id=eq.${threadId}` },
|
|
141
|
+
(payload) => {
|
|
142
|
+
const row = payload.new as DbAgentRunRow;
|
|
143
|
+
handlers.onUpdate?.(mapRun(row));
|
|
144
|
+
}
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
subscribeRunEvents(
|
|
150
|
+
runId: string,
|
|
151
|
+
handlers: {
|
|
152
|
+
onInsert?: (event: AgentRunEvent) => void;
|
|
153
|
+
onUpdate?: (event: AgentRunEvent) => void;
|
|
154
|
+
}
|
|
155
|
+
): () => void {
|
|
156
|
+
return subscribeManagedChannel(`agent-progress:events:run:${runId}`, (channel) => {
|
|
157
|
+
channel
|
|
158
|
+
.on(
|
|
159
|
+
'postgres_changes',
|
|
160
|
+
{ event: 'INSERT', schema: 'public', table: 'agent_run_event', filter: `run_id=eq.${runId}` },
|
|
161
|
+
(payload) => {
|
|
162
|
+
const row = payload.new as DbAgentRunEventRow;
|
|
163
|
+
handlers.onInsert?.(mapEvent(row));
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
.on(
|
|
167
|
+
'postgres_changes',
|
|
168
|
+
{ event: 'UPDATE', schema: 'public', table: 'agent_run_event', filter: `run_id=eq.${runId}` },
|
|
169
|
+
(payload) => {
|
|
170
|
+
const row = payload.new as DbAgentRunEventRow;
|
|
171
|
+
handlers.onUpdate?.(mapEvent(row));
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const agentProgressRepository: AgentProgressRepository = new AgentProgressRepositoryImpl();
|
|
179
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export type AgentRunStatus = 'running' | 'succeeded' | 'failed' | 'cancelled';
|
|
2
|
+
|
|
3
|
+
export type AgentProgressPhase =
|
|
4
|
+
| 'planning'
|
|
5
|
+
| 'reasoning'
|
|
6
|
+
| 'analyzing'
|
|
7
|
+
| 'editing'
|
|
8
|
+
| 'executing'
|
|
9
|
+
| 'working'
|
|
10
|
+
| 'validating'
|
|
11
|
+
| 'finalizing';
|
|
12
|
+
|
|
13
|
+
export type AgentRunEventType =
|
|
14
|
+
| 'run_started'
|
|
15
|
+
| 'phase_changed'
|
|
16
|
+
| 'step_started'
|
|
17
|
+
| 'file_changed'
|
|
18
|
+
| 'todo_updated'
|
|
19
|
+
| 'run_completed'
|
|
20
|
+
| 'run_failed'
|
|
21
|
+
| 'run_cancelled';
|
|
22
|
+
|
|
23
|
+
export type AgentRun = {
|
|
24
|
+
id: string;
|
|
25
|
+
appId: string;
|
|
26
|
+
threadId: string;
|
|
27
|
+
queueItemId: string | null;
|
|
28
|
+
status: AgentRunStatus;
|
|
29
|
+
currentPhase: AgentProgressPhase | null;
|
|
30
|
+
lastSeq: number;
|
|
31
|
+
summary: Record<string, unknown>;
|
|
32
|
+
errorCode: string | null;
|
|
33
|
+
errorMessage: string | null;
|
|
34
|
+
startedAt: string;
|
|
35
|
+
finishedAt: string | null;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
updatedAt: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type AgentRunEvent = {
|
|
41
|
+
id: string;
|
|
42
|
+
runId: string;
|
|
43
|
+
appId: string;
|
|
44
|
+
threadId: string;
|
|
45
|
+
queueItemId: string | null;
|
|
46
|
+
seq: number;
|
|
47
|
+
eventType: AgentRunEventType;
|
|
48
|
+
phase: AgentProgressPhase | null;
|
|
49
|
+
toolName: string | null;
|
|
50
|
+
path: string | null;
|
|
51
|
+
payload: Record<string, unknown>;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type AgentTodoSummary = {
|
|
56
|
+
total: number;
|
|
57
|
+
pending: number;
|
|
58
|
+
inProgress: number;
|
|
59
|
+
completed: number;
|
|
60
|
+
currentTask: string | null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type AgentProgressSnapshot = {
|
|
64
|
+
run: AgentRun | null;
|
|
65
|
+
events: AgentRunEvent[];
|
|
66
|
+
};
|
|
67
|
+
|