@comergehq/studio 0.1.13 → 0.1.16
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 +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +820 -192
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +814 -186
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/components/chat/ChatPage.tsx +19 -2
- package/src/components/chat/ChatQueue.tsx +163 -0
- package/src/data/agent/types.ts +2 -1
- package/src/data/apps/bundles/remote.ts +17 -0
- package/src/data/apps/bundles/repository.ts +14 -0
- package/src/data/apps/bundles/types.ts +15 -0
- package/src/data/apps/edit-queue/remote.ts +45 -0
- package/src/data/apps/edit-queue/repository.ts +136 -0
- package/src/data/apps/edit-queue/types.ts +31 -0
- package/src/studio/ComergeStudio.tsx +70 -2
- package/src/studio/hooks/useBundleManager.ts +273 -22
- package/src/studio/hooks/useEditQueue.ts +71 -0
- package/src/studio/hooks/useEditQueueActions.ts +29 -0
- package/src/studio/hooks/useOptimisticChatMessages.ts +4 -2
- package/src/studio/hooks/useStudioActions.ts +14 -2
- package/src/studio/hooks/useThreadMessages.ts +39 -5
- package/src/studio/ui/ChatPanel.tsx +11 -0
- package/src/studio/ui/StudioOverlay.tsx +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comergehq/studio",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
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
|
+
"react-native-zip-archive": "*",
|
|
71
72
|
"react-native-view-shot": "*"
|
|
72
73
|
},
|
|
73
74
|
"peerDependenciesMeta": {}
|
|
@@ -13,6 +13,7 @@ export type ChatPageProps = {
|
|
|
13
13
|
showTypingIndicator?: boolean;
|
|
14
14
|
renderMessageContent?: ChatMessageListProps['renderMessageContent'];
|
|
15
15
|
topBanner?: React.ReactNode;
|
|
16
|
+
composerTop?: React.ReactNode;
|
|
16
17
|
composer: Omit<ChatComposerProps, 'attachments'> & {
|
|
17
18
|
attachments?: ChatComposerProps['attachments'];
|
|
18
19
|
};
|
|
@@ -32,6 +33,7 @@ export function ChatPage({
|
|
|
32
33
|
showTypingIndicator,
|
|
33
34
|
renderMessageContent,
|
|
34
35
|
topBanner,
|
|
36
|
+
composerTop,
|
|
35
37
|
composer,
|
|
36
38
|
overlay,
|
|
37
39
|
style,
|
|
@@ -42,6 +44,7 @@ export function ChatPage({
|
|
|
42
44
|
const theme = useTheme();
|
|
43
45
|
const insets = useSafeAreaInsets();
|
|
44
46
|
const [composerHeight, setComposerHeight] = React.useState(0);
|
|
47
|
+
const [composerTopHeight, setComposerTopHeight] = React.useState(0);
|
|
45
48
|
const [keyboardVisible, setKeyboardVisible] = React.useState(false);
|
|
46
49
|
|
|
47
50
|
React.useEffect(() => {
|
|
@@ -55,8 +58,9 @@ export function ChatPage({
|
|
|
55
58
|
}, []);
|
|
56
59
|
|
|
57
60
|
const footerBottomPadding = Platform.OS === 'ios' ? (keyboardVisible ? 0 : insets.bottom) : insets.bottom + 10;
|
|
58
|
-
const
|
|
59
|
-
const
|
|
61
|
+
const totalComposerHeight = composerHeight + composerTopHeight;
|
|
62
|
+
const overlayBottom = totalComposerHeight + footerBottomPadding + theme.spacing.lg;
|
|
63
|
+
const bottomInset = totalComposerHeight + footerBottomPadding + theme.spacing.xl;
|
|
60
64
|
|
|
61
65
|
const resolvedOverlay = React.useMemo(() => {
|
|
62
66
|
if (!overlay) return null;
|
|
@@ -66,6 +70,11 @@ export function ChatPage({
|
|
|
66
70
|
style: [prevStyle, { bottom: overlayBottom }],
|
|
67
71
|
});
|
|
68
72
|
}, [overlay, overlayBottom]);
|
|
73
|
+
|
|
74
|
+
React.useEffect(() => {
|
|
75
|
+
if (composerTop) return;
|
|
76
|
+
setComposerTopHeight(0);
|
|
77
|
+
}, [composerTop]);
|
|
69
78
|
return (
|
|
70
79
|
<View style={[{ flex: 1 }, style]}>
|
|
71
80
|
{header ? <View>{header}</View> : null}
|
|
@@ -99,6 +108,14 @@ export function ChatPage({
|
|
|
99
108
|
paddingBottom: footerBottomPadding,
|
|
100
109
|
}}
|
|
101
110
|
>
|
|
111
|
+
{composerTop ? (
|
|
112
|
+
<View
|
|
113
|
+
style={{ marginBottom: theme.spacing.sm }}
|
|
114
|
+
onLayout={(e) => setComposerTopHeight(e.nativeEvent.layout.height)}
|
|
115
|
+
>
|
|
116
|
+
{composerTop}
|
|
117
|
+
</View>
|
|
118
|
+
) : null}
|
|
102
119
|
<ChatComposer
|
|
103
120
|
{...composer}
|
|
104
121
|
attachments={composer.attachments ?? []}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ActivityIndicator, Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import type { EditQueueItem } from '../../data/apps/edit-queue/types';
|
|
5
|
+
import { useTheme } from '../../theme';
|
|
6
|
+
import { withAlpha } from '../utils/color';
|
|
7
|
+
import { Text } from '../primitives/Text';
|
|
8
|
+
import { IconClose } from '../icons/StudioIcons';
|
|
9
|
+
|
|
10
|
+
export type ChatQueueProps = {
|
|
11
|
+
items: EditQueueItem[];
|
|
12
|
+
onRemove?: (id: string) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type ExpansionState = Record<string, boolean>;
|
|
16
|
+
type CollapsedState = Record<string, string>;
|
|
17
|
+
type RemovalState = Record<string, boolean>;
|
|
18
|
+
|
|
19
|
+
export function ChatQueue({ items, onRemove }: ChatQueueProps) {
|
|
20
|
+
const theme = useTheme();
|
|
21
|
+
const [expanded, setExpanded] = React.useState<ExpansionState>({});
|
|
22
|
+
const [canExpand, setCanExpand] = React.useState<ExpansionState>({});
|
|
23
|
+
const [collapsedText, setCollapsedText] = React.useState<CollapsedState>({});
|
|
24
|
+
const [removing, setRemoving] = React.useState<RemovalState>({});
|
|
25
|
+
|
|
26
|
+
const buildCollapsedText = React.useCallback((lines: { text: string }[]) => {
|
|
27
|
+
const line1 = lines[0]?.text ?? '';
|
|
28
|
+
const line2 = lines[1]?.text ?? '';
|
|
29
|
+
const moreLabel = 'more';
|
|
30
|
+
const reserve = `… ${moreLabel}`.length;
|
|
31
|
+
let trimmedLine2 = line2;
|
|
32
|
+
|
|
33
|
+
if (trimmedLine2.length > reserve) {
|
|
34
|
+
trimmedLine2 = trimmedLine2.slice(0, Math.max(0, trimmedLine2.length - reserve));
|
|
35
|
+
} else {
|
|
36
|
+
trimmedLine2 = '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
trimmedLine2 = trimmedLine2.replace(/\s+$/, '');
|
|
40
|
+
return `${line1}\n${trimmedLine2}… `;
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
React.useEffect(() => {
|
|
44
|
+
if (items.length === 0) return;
|
|
45
|
+
const ids = new Set(items.map((item) => item.id));
|
|
46
|
+
setExpanded((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id))));
|
|
47
|
+
setCanExpand((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id))));
|
|
48
|
+
setCollapsedText((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id))));
|
|
49
|
+
setRemoving((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id))));
|
|
50
|
+
}, [items]);
|
|
51
|
+
|
|
52
|
+
if (items.length === 0) return null;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<View
|
|
56
|
+
style={{
|
|
57
|
+
borderWidth: 1,
|
|
58
|
+
borderColor: theme.colors.border,
|
|
59
|
+
borderRadius: theme.radii.lg,
|
|
60
|
+
marginHorizontal: theme.spacing.md,
|
|
61
|
+
padding: theme.spacing.md,
|
|
62
|
+
backgroundColor: 'transparent',
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<Text variant="caption" style={{ marginBottom: theme.spacing.sm }}>
|
|
66
|
+
Queue
|
|
67
|
+
</Text>
|
|
68
|
+
<View style={{ gap: theme.spacing.sm }}>
|
|
69
|
+
{items.map((item) => {
|
|
70
|
+
const isExpanded = Boolean(expanded[item.id]);
|
|
71
|
+
const showToggle = Boolean(canExpand[item.id]);
|
|
72
|
+
const prompt = item.prompt ?? '';
|
|
73
|
+
const moreLabel = 'more';
|
|
74
|
+
const displayPrompt =
|
|
75
|
+
!isExpanded && showToggle && collapsedText[item.id] ? collapsedText[item.id] : prompt;
|
|
76
|
+
const isRemoving = Boolean(removing[item.id]);
|
|
77
|
+
return (
|
|
78
|
+
<View
|
|
79
|
+
key={item.id}
|
|
80
|
+
style={{
|
|
81
|
+
flexDirection: 'row',
|
|
82
|
+
alignItems: 'flex-start',
|
|
83
|
+
gap: theme.spacing.sm,
|
|
84
|
+
paddingHorizontal: theme.spacing.md,
|
|
85
|
+
paddingVertical: theme.spacing.sm,
|
|
86
|
+
borderRadius: theme.radii.md,
|
|
87
|
+
backgroundColor: withAlpha(theme.colors.surface, theme.scheme === 'dark' ? 0.8 : 0.9),
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<View style={{ flex: 1 }}>
|
|
91
|
+
{!canExpand[item.id] ? (
|
|
92
|
+
<Text
|
|
93
|
+
style={{ position: 'absolute', opacity: 0, zIndex: -1, width: '100%' }}
|
|
94
|
+
onTextLayout={(e) => {
|
|
95
|
+
const lines = e.nativeEvent?.lines;
|
|
96
|
+
if (!lines) return;
|
|
97
|
+
if (lines.length > 2) {
|
|
98
|
+
setCanExpand((prev) => ({ ...prev, [item.id]: true }));
|
|
99
|
+
setCollapsedText((prev) => ({
|
|
100
|
+
...prev,
|
|
101
|
+
[item.id]: buildCollapsedText(lines),
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{prompt}
|
|
107
|
+
</Text>
|
|
108
|
+
) : null}
|
|
109
|
+
<Text
|
|
110
|
+
variant="bodyMuted"
|
|
111
|
+
numberOfLines={isExpanded ? undefined : 2}
|
|
112
|
+
>
|
|
113
|
+
{displayPrompt}
|
|
114
|
+
{!isExpanded && showToggle ? (
|
|
115
|
+
<Text
|
|
116
|
+
color={theme.colors.text}
|
|
117
|
+
onPress={() => setExpanded((prev) => ({ ...prev, [item.id]: true }))}
|
|
118
|
+
suppressHighlighting
|
|
119
|
+
>
|
|
120
|
+
{moreLabel}
|
|
121
|
+
</Text>
|
|
122
|
+
) : null}
|
|
123
|
+
</Text>
|
|
124
|
+
{showToggle && isExpanded ? (
|
|
125
|
+
<Pressable
|
|
126
|
+
onPress={() => setExpanded((prev) => ({ ...prev, [item.id]: false }))}
|
|
127
|
+
hitSlop={6}
|
|
128
|
+
style={{ alignSelf: 'flex-start', marginTop: 4 }}
|
|
129
|
+
>
|
|
130
|
+
<Text variant="captionMuted" color={theme.colors.text}>
|
|
131
|
+
less
|
|
132
|
+
</Text>
|
|
133
|
+
</Pressable>
|
|
134
|
+
) : null}
|
|
135
|
+
</View>
|
|
136
|
+
<Pressable
|
|
137
|
+
onPress={() => {
|
|
138
|
+
if (!onRemove || isRemoving) return;
|
|
139
|
+
setRemoving((prev) => ({ ...prev, [item.id]: true }));
|
|
140
|
+
Promise.resolve(onRemove(item.id)).finally(() => {
|
|
141
|
+
setRemoving((prev) => {
|
|
142
|
+
if (!prev[item.id]) return prev;
|
|
143
|
+
const { [item.id]: _removed, ...rest } = prev;
|
|
144
|
+
return rest;
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}}
|
|
148
|
+
hitSlop={8}
|
|
149
|
+
style={{ alignSelf: 'center' }}
|
|
150
|
+
>
|
|
151
|
+
{isRemoving ? (
|
|
152
|
+
<ActivityIndicator size="small" color={theme.colors.text} />
|
|
153
|
+
) : (
|
|
154
|
+
<IconClose size={14} colorToken="text" />
|
|
155
|
+
)}
|
|
156
|
+
</Pressable>
|
|
157
|
+
</View>
|
|
158
|
+
);
|
|
159
|
+
})}
|
|
160
|
+
</View>
|
|
161
|
+
</View>
|
|
162
|
+
);
|
|
163
|
+
}
|
package/src/data/agent/types.ts
CHANGED
|
@@ -21,7 +21,8 @@ export type AgentCreateAppResult = {
|
|
|
21
21
|
export type AgentEditAppResult = {
|
|
22
22
|
threadId: string;
|
|
23
23
|
appId: string;
|
|
24
|
-
|
|
24
|
+
queueItemId?: string | null;
|
|
25
|
+
queuePosition?: number | null;
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
import type { AttachmentMeta } from '../../data/attachment/types';
|
|
@@ -11,6 +11,11 @@ export interface BundlesRemoteDataSource {
|
|
|
11
11
|
bundleId: string,
|
|
12
12
|
options?: { redirect?: boolean }
|
|
13
13
|
): Promise<ServiceResponse<{ url: string; redirect: boolean }>>;
|
|
14
|
+
getSignedAssetsDownloadUrl(
|
|
15
|
+
appId: string,
|
|
16
|
+
bundleId: string,
|
|
17
|
+
options?: { redirect?: boolean; kind?: string }
|
|
18
|
+
): Promise<ServiceResponse<{ url: string; redirect: boolean }>>;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
class BundlesRemoteDataSourceImpl extends BaseRemote implements BundlesRemoteDataSource {
|
|
@@ -40,6 +45,18 @@ class BundlesRemoteDataSourceImpl extends BaseRemote implements BundlesRemoteDat
|
|
|
40
45
|
);
|
|
41
46
|
return data;
|
|
42
47
|
}
|
|
48
|
+
|
|
49
|
+
async getSignedAssetsDownloadUrl(
|
|
50
|
+
appId: string,
|
|
51
|
+
bundleId: string,
|
|
52
|
+
options?: { redirect?: boolean; kind?: string }
|
|
53
|
+
): Promise<ServiceResponse<{ url: string; redirect: boolean }>> {
|
|
54
|
+
const { data } = await api.get<ServiceResponse<{ url: string; redirect: boolean }>>(
|
|
55
|
+
`/v1/apps/${encodeURIComponent(appId)}/bundles/${encodeURIComponent(bundleId)}/assets/download`,
|
|
56
|
+
{ params: { redirect: options?.redirect ?? false, kind: options?.kind } }
|
|
57
|
+
);
|
|
58
|
+
return data;
|
|
59
|
+
}
|
|
43
60
|
}
|
|
44
61
|
|
|
45
62
|
export const bundlesRemoteDataSource: BundlesRemoteDataSource = new BundlesRemoteDataSourceImpl();
|
|
@@ -7,6 +7,11 @@ export interface BundlesRepository {
|
|
|
7
7
|
initiate(appId: string, payload: InitiateBundleRequest): Promise<Bundle>;
|
|
8
8
|
getById(appId: string, bundleId: string): Promise<Bundle>;
|
|
9
9
|
getSignedDownloadUrl(appId: string, bundleId: string, options?: { redirect?: boolean }): Promise<{ url: string; redirect: boolean }>;
|
|
10
|
+
getSignedAssetsDownloadUrl(
|
|
11
|
+
appId: string,
|
|
12
|
+
bundleId: string,
|
|
13
|
+
options?: { redirect?: boolean; kind?: string }
|
|
14
|
+
): Promise<{ url: string; redirect: boolean }>;
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
class BundlesRepositoryImpl extends BaseRepository implements BundlesRepository {
|
|
@@ -28,6 +33,15 @@ class BundlesRepositoryImpl extends BaseRepository implements BundlesRepository
|
|
|
28
33
|
const res = await this.remote.getSignedDownloadUrl(appId, bundleId, options);
|
|
29
34
|
return this.unwrapOrThrow(res);
|
|
30
35
|
}
|
|
36
|
+
|
|
37
|
+
async getSignedAssetsDownloadUrl(
|
|
38
|
+
appId: string,
|
|
39
|
+
bundleId: string,
|
|
40
|
+
options?: { redirect?: boolean; kind?: string }
|
|
41
|
+
): Promise<{ url: string; redirect: boolean }> {
|
|
42
|
+
const res = await this.remote.getSignedAssetsDownloadUrl(appId, bundleId, options);
|
|
43
|
+
return this.unwrapOrThrow(res);
|
|
44
|
+
}
|
|
31
45
|
}
|
|
32
46
|
|
|
33
47
|
export const bundlesRepository: BundlesRepository = new BundlesRepositoryImpl(bundlesRemoteDataSource);
|
|
@@ -2,6 +2,20 @@ export type Platform = 'ios' | 'android';
|
|
|
2
2
|
|
|
3
3
|
export type BundleStatus = 'pending' | 'building' | 'succeeded' | 'failed';
|
|
4
4
|
|
|
5
|
+
export type BundleAssetKind = 'metro-assets' | string;
|
|
6
|
+
|
|
7
|
+
export type BundleAsset = {
|
|
8
|
+
id: string;
|
|
9
|
+
kind: BundleAssetKind;
|
|
10
|
+
storageBucket: string;
|
|
11
|
+
storageKey: string;
|
|
12
|
+
contentType: string | null;
|
|
13
|
+
size: number | null;
|
|
14
|
+
checksumSha256: string | null;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
updatedAt: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
5
19
|
export type Bundle = {
|
|
6
20
|
id: string;
|
|
7
21
|
appId: string;
|
|
@@ -15,6 +29,7 @@ export type Bundle = {
|
|
|
15
29
|
createdAt: string;
|
|
16
30
|
updatedAt: string;
|
|
17
31
|
expiresAt: string | null;
|
|
32
|
+
assets?: BundleAsset[];
|
|
18
33
|
};
|
|
19
34
|
|
|
20
35
|
export type InitiateBundleRequest = {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { api } from '../../../core/services/http';
|
|
2
|
+
import type { ServiceResponse } from '../../types';
|
|
3
|
+
import { BaseRemote } from '../../base-remote';
|
|
4
|
+
import type { EditQueueItem, EditQueueListResponse, UpdateEditQueueItemRequest } from './types';
|
|
5
|
+
|
|
6
|
+
export interface EditQueueRemoteDataSource {
|
|
7
|
+
list(appId: string): Promise<ServiceResponse<EditQueueListResponse>>;
|
|
8
|
+
update(
|
|
9
|
+
appId: string,
|
|
10
|
+
queueItemId: string,
|
|
11
|
+
payload: UpdateEditQueueItemRequest
|
|
12
|
+
): Promise<ServiceResponse<EditQueueItem>>;
|
|
13
|
+
cancel(appId: string, queueItemId: string): Promise<ServiceResponse<EditQueueItem>>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class EditQueueRemoteDataSourceImpl extends BaseRemote implements EditQueueRemoteDataSource {
|
|
17
|
+
async list(appId: string): Promise<ServiceResponse<EditQueueListResponse>> {
|
|
18
|
+
const { data } = await api.get<ServiceResponse<EditQueueListResponse>>(
|
|
19
|
+
`/v1/apps/${encodeURIComponent(appId)}/edit-queue`
|
|
20
|
+
);
|
|
21
|
+
return data;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async update(
|
|
25
|
+
appId: string,
|
|
26
|
+
queueItemId: string,
|
|
27
|
+
payload: UpdateEditQueueItemRequest
|
|
28
|
+
): Promise<ServiceResponse<EditQueueItem>> {
|
|
29
|
+
const { data } = await api.patch<ServiceResponse<EditQueueItem>>(
|
|
30
|
+
`/v1/apps/${encodeURIComponent(appId)}/edit-queue/${encodeURIComponent(queueItemId)}`,
|
|
31
|
+
payload
|
|
32
|
+
);
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async cancel(appId: string, queueItemId: string): Promise<ServiceResponse<EditQueueItem>> {
|
|
37
|
+
const { data } = await api.delete<ServiceResponse<EditQueueItem>>(
|
|
38
|
+
`/v1/apps/${encodeURIComponent(appId)}/edit-queue/${encodeURIComponent(queueItemId)}`
|
|
39
|
+
);
|
|
40
|
+
return data;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const editQueueRemoteDataSource: EditQueueRemoteDataSource =
|
|
45
|
+
new EditQueueRemoteDataSourceImpl();
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { EditQueueRemoteDataSource } from './remote';
|
|
2
|
+
import { editQueueRemoteDataSource } from './remote';
|
|
3
|
+
import type { EditQueueItem, EditQueueListResponse, UpdateEditQueueItemRequest } from './types';
|
|
4
|
+
import { BaseRepository } from '../../base-repository';
|
|
5
|
+
import { getSupabaseClient } from '../../../core/services/supabase';
|
|
6
|
+
import type { AttachmentMeta } from '../../attachment/types';
|
|
7
|
+
|
|
8
|
+
type DbAppJobQueueRow = {
|
|
9
|
+
id: string;
|
|
10
|
+
app_id: string;
|
|
11
|
+
kind: string;
|
|
12
|
+
status: EditQueueItem['status'];
|
|
13
|
+
payload: Record<string, unknown> | null;
|
|
14
|
+
created_at: string;
|
|
15
|
+
updated_at: string;
|
|
16
|
+
run_after: string | null;
|
|
17
|
+
priority: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ACTIVE_STATUSES: EditQueueItem['status'][] = ['pending'];
|
|
21
|
+
|
|
22
|
+
function toString(value: unknown): string | null {
|
|
23
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toAttachments(value: unknown): AttachmentMeta[] {
|
|
27
|
+
return Array.isArray(value) ? (value as AttachmentMeta[]) : [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function mapQueueItem(row: DbAppJobQueueRow): EditQueueItem {
|
|
31
|
+
const payload = (row.payload ?? {}) as Record<string, unknown>;
|
|
32
|
+
return {
|
|
33
|
+
id: row.id,
|
|
34
|
+
status: row.status,
|
|
35
|
+
prompt: toString(payload.trimmedPrompt),
|
|
36
|
+
messageId: toString(payload.messageId),
|
|
37
|
+
attachments: toAttachments(payload.attachments),
|
|
38
|
+
createdAt: row.created_at,
|
|
39
|
+
updatedAt: row.updated_at,
|
|
40
|
+
runAfter: row.run_after,
|
|
41
|
+
priority: row.priority,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface EditQueueRepository {
|
|
46
|
+
list(appId: string): Promise<EditQueueItem[]>;
|
|
47
|
+
update(appId: string, queueItemId: string, payload: UpdateEditQueueItemRequest): Promise<EditQueueItem>;
|
|
48
|
+
cancel(appId: string, queueItemId: string): Promise<EditQueueItem>;
|
|
49
|
+
subscribeEditQueue(
|
|
50
|
+
appId: string,
|
|
51
|
+
handlers: {
|
|
52
|
+
onInsert?: (item: EditQueueItem) => void;
|
|
53
|
+
onUpdate?: (item: EditQueueItem) => void;
|
|
54
|
+
onDelete?: (item: EditQueueItem) => void;
|
|
55
|
+
}
|
|
56
|
+
): () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class EditQueueRepositoryImpl extends BaseRepository implements EditQueueRepository {
|
|
60
|
+
constructor(private readonly remote: EditQueueRemoteDataSource) {
|
|
61
|
+
super();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async list(appId: string): Promise<EditQueueItem[]> {
|
|
65
|
+
const res = await this.remote.list(appId);
|
|
66
|
+
const data = this.unwrapOrThrow(res) as EditQueueListResponse;
|
|
67
|
+
return data.items ?? [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async update(
|
|
71
|
+
appId: string,
|
|
72
|
+
queueItemId: string,
|
|
73
|
+
payload: UpdateEditQueueItemRequest
|
|
74
|
+
): Promise<EditQueueItem> {
|
|
75
|
+
const res = await this.remote.update(appId, queueItemId, payload);
|
|
76
|
+
return this.unwrapOrThrow(res);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async cancel(appId: string, queueItemId: string): Promise<EditQueueItem> {
|
|
80
|
+
const res = await this.remote.cancel(appId, queueItemId);
|
|
81
|
+
return this.unwrapOrThrow(res);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
subscribeEditQueue(
|
|
85
|
+
appId: string,
|
|
86
|
+
handlers: {
|
|
87
|
+
onInsert?: (item: EditQueueItem) => void;
|
|
88
|
+
onUpdate?: (item: EditQueueItem) => void;
|
|
89
|
+
onDelete?: (item: EditQueueItem) => void;
|
|
90
|
+
}
|
|
91
|
+
): () => void {
|
|
92
|
+
const supabase = getSupabaseClient();
|
|
93
|
+
const channel = supabase
|
|
94
|
+
.channel(`edit-queue:app:${appId}`)
|
|
95
|
+
.on(
|
|
96
|
+
'postgres_changes',
|
|
97
|
+
{ event: 'INSERT', schema: 'public', table: 'app_job_queue', filter: `app_id=eq.${appId}` },
|
|
98
|
+
(payload) => {
|
|
99
|
+
const row = payload.new as DbAppJobQueueRow;
|
|
100
|
+
if (row.kind !== 'edit') return;
|
|
101
|
+
const item = mapQueueItem(row);
|
|
102
|
+
if (!ACTIVE_STATUSES.includes(item.status)) return;
|
|
103
|
+
handlers.onInsert?.(item);
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
.on(
|
|
107
|
+
'postgres_changes',
|
|
108
|
+
{ event: 'UPDATE', schema: 'public', table: 'app_job_queue', filter: `app_id=eq.${appId}` },
|
|
109
|
+
(payload) => {
|
|
110
|
+
const row = payload.new as DbAppJobQueueRow;
|
|
111
|
+
if (row.kind !== 'edit') return;
|
|
112
|
+
const item = mapQueueItem(row);
|
|
113
|
+
if (ACTIVE_STATUSES.includes(item.status)) handlers.onUpdate?.(item);
|
|
114
|
+
else handlers.onDelete?.(item);
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
.on(
|
|
118
|
+
'postgres_changes',
|
|
119
|
+
{ event: 'DELETE', schema: 'public', table: 'app_job_queue', filter: `app_id=eq.${appId}` },
|
|
120
|
+
(payload) => {
|
|
121
|
+
const row = payload.old as DbAppJobQueueRow;
|
|
122
|
+
if (row.kind !== 'edit') return;
|
|
123
|
+
handlers.onDelete?.(mapQueueItem(row));
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
.subscribe();
|
|
127
|
+
|
|
128
|
+
return () => {
|
|
129
|
+
supabase.removeChannel(channel);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const editQueueRepository: EditQueueRepository = new EditQueueRepositoryImpl(
|
|
135
|
+
editQueueRemoteDataSource
|
|
136
|
+
);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AttachmentMeta } from '../../attachment/types';
|
|
2
|
+
|
|
3
|
+
export type EditQueueStatus =
|
|
4
|
+
| 'pending'
|
|
5
|
+
| 'enqueued'
|
|
6
|
+
| 'processing'
|
|
7
|
+
| 'succeeded'
|
|
8
|
+
| 'failed'
|
|
9
|
+
| 'cancelled';
|
|
10
|
+
|
|
11
|
+
export type EditQueueItem = {
|
|
12
|
+
id: string;
|
|
13
|
+
status: EditQueueStatus;
|
|
14
|
+
prompt: string | null;
|
|
15
|
+
messageId: string | null;
|
|
16
|
+
attachments: AttachmentMeta[];
|
|
17
|
+
createdAt: string;
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
runAfter: string | null;
|
|
20
|
+
priority: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type EditQueueListResponse = {
|
|
24
|
+
items: EditQueueItem[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type UpdateEditQueueItemRequest = {
|
|
28
|
+
prompt?: string;
|
|
29
|
+
attachments?: AttachmentMeta[];
|
|
30
|
+
runAfter?: string | null;
|
|
31
|
+
};
|
|
@@ -11,10 +11,11 @@ import type { EmbeddedBaseBundles } from './hooks/useBundleManager';
|
|
|
11
11
|
import { useMergeRequests } from './hooks/useMergeRequests';
|
|
12
12
|
import { useAttachmentUpload } from './hooks/useAttachmentUpload';
|
|
13
13
|
import { useStudioActions } from './hooks/useStudioActions';
|
|
14
|
-
import { hasNoOutcomeAfterLastHuman } from './lib/chat';
|
|
15
14
|
import { RuntimeRenderer } from './ui/RuntimeRenderer';
|
|
16
15
|
import { StudioOverlay } from './ui/StudioOverlay';
|
|
17
16
|
import { LiquidGlassResetProvider } from '../components/utils/liquidGlassReset';
|
|
17
|
+
import { useEditQueue } from './hooks/useEditQueue';
|
|
18
|
+
import { useEditQueueActions } from './hooks/useEditQueueActions';
|
|
18
19
|
|
|
19
20
|
export type ComergeStudioProps = {
|
|
20
21
|
appId: string;
|
|
@@ -175,6 +176,17 @@ function ComergeStudioInner({
|
|
|
175
176
|
|
|
176
177
|
const threadId = app?.threadId ?? '';
|
|
177
178
|
const thread = useThreadMessages(threadId);
|
|
179
|
+
const editQueue = useEditQueue(activeAppId);
|
|
180
|
+
const editQueueActions = useEditQueueActions(activeAppId);
|
|
181
|
+
const [lastEditQueueInfo, setLastEditQueueInfo] = React.useState<{
|
|
182
|
+
queueItemId?: string | null;
|
|
183
|
+
queuePosition?: number | null;
|
|
184
|
+
} | null>(null);
|
|
185
|
+
const lastEditQueueInfoRef = React.useRef<{
|
|
186
|
+
queueItemId?: string | null;
|
|
187
|
+
queuePosition?: number | null;
|
|
188
|
+
} | null>(null);
|
|
189
|
+
const [suppressQueueUntilResponse, setSuppressQueueUntilResponse] = React.useState(false);
|
|
178
190
|
|
|
179
191
|
const mergeRequests = useMergeRequests({ appId: activeAppId });
|
|
180
192
|
const hasOpenOutgoingMr = React.useMemo(() => {
|
|
@@ -188,6 +200,14 @@ function ComergeStudioInner({
|
|
|
188
200
|
|
|
189
201
|
const uploader = useAttachmentUpload();
|
|
190
202
|
|
|
203
|
+
const updateLastEditQueueInfo = React.useCallback(
|
|
204
|
+
(info: { queueItemId?: string | null; queuePosition?: number | null } | null) => {
|
|
205
|
+
lastEditQueueInfoRef.current = info;
|
|
206
|
+
setLastEditQueueInfo(info);
|
|
207
|
+
},
|
|
208
|
+
[]
|
|
209
|
+
);
|
|
210
|
+
|
|
191
211
|
const actions = useStudioActions({
|
|
192
212
|
userId,
|
|
193
213
|
app,
|
|
@@ -203,9 +223,25 @@ function ComergeStudioInner({
|
|
|
203
223
|
}
|
|
204
224
|
},
|
|
205
225
|
uploadAttachments: uploader.uploadBase64Images,
|
|
226
|
+
onEditStart: () => {
|
|
227
|
+
if (editQueue.items.length === 0) {
|
|
228
|
+
setSuppressQueueUntilResponse(true);
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
onEditQueued: (info) => {
|
|
232
|
+
updateLastEditQueueInfo(info);
|
|
233
|
+
if (info.queuePosition !== 1) {
|
|
234
|
+
setSuppressQueueUntilResponse(false);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
onEditFinished: () => {
|
|
238
|
+
if (lastEditQueueInfoRef.current?.queuePosition !== 1) {
|
|
239
|
+
setSuppressQueueUntilResponse(false);
|
|
240
|
+
}
|
|
241
|
+
},
|
|
206
242
|
});
|
|
207
243
|
|
|
208
|
-
const chatSendDisabled =
|
|
244
|
+
const chatSendDisabled = false;
|
|
209
245
|
|
|
210
246
|
const [processingMrId, setProcessingMrId] = React.useState<string | null>(null);
|
|
211
247
|
const [testingMrId, setTestingMrId] = React.useState<string | null>(null);
|
|
@@ -218,6 +254,36 @@ function ComergeStudioInner({
|
|
|
218
254
|
return payloadType !== 'outcome';
|
|
219
255
|
}, [thread.raw]);
|
|
220
256
|
|
|
257
|
+
React.useEffect(() => {
|
|
258
|
+
updateLastEditQueueInfo(null);
|
|
259
|
+
setSuppressQueueUntilResponse(false);
|
|
260
|
+
}, [activeAppId, updateLastEditQueueInfo]);
|
|
261
|
+
|
|
262
|
+
React.useEffect(() => {
|
|
263
|
+
if (!lastEditQueueInfo?.queueItemId) return;
|
|
264
|
+
const stillPresent = editQueue.items.some((item) => item.id === lastEditQueueInfo.queueItemId);
|
|
265
|
+
if (!stillPresent) {
|
|
266
|
+
updateLastEditQueueInfo(null);
|
|
267
|
+
setSuppressQueueUntilResponse(false);
|
|
268
|
+
}
|
|
269
|
+
}, [editQueue.items, lastEditQueueInfo?.queueItemId]);
|
|
270
|
+
|
|
271
|
+
const chatQueueItems = React.useMemo(() => {
|
|
272
|
+
if (suppressQueueUntilResponse && editQueue.items.length <= 1) {
|
|
273
|
+
return [];
|
|
274
|
+
}
|
|
275
|
+
if (!lastEditQueueInfo || lastEditQueueInfo.queuePosition !== 1 || !lastEditQueueInfo.queueItemId) {
|
|
276
|
+
return editQueue.items;
|
|
277
|
+
}
|
|
278
|
+
if (
|
|
279
|
+
editQueue.items.length === 1 &&
|
|
280
|
+
editQueue.items[0]?.id === lastEditQueueInfo.queueItemId
|
|
281
|
+
) {
|
|
282
|
+
return [];
|
|
283
|
+
}
|
|
284
|
+
return editQueue.items;
|
|
285
|
+
}, [editQueue.items, lastEditQueueInfo, suppressQueueUntilResponse]);
|
|
286
|
+
|
|
221
287
|
return (
|
|
222
288
|
<View style={[{ flex: 1 }, style]}>
|
|
223
289
|
<View ref={captureTargetRef} style={{ flex: 1 }} collapsable={false}>
|
|
@@ -283,6 +349,8 @@ function ComergeStudioInner({
|
|
|
283
349
|
chatSending={actions.sending}
|
|
284
350
|
chatShowTypingIndicator={chatShowTypingIndicator}
|
|
285
351
|
onSendChat={(text, attachments) => actions.sendEdit({ prompt: text, attachments })}
|
|
352
|
+
chatQueueItems={chatQueueItems}
|
|
353
|
+
onRemoveQueueItem={(id) => editQueueActions.cancel(id)}
|
|
286
354
|
onNavigateHome={onNavigateHome}
|
|
287
355
|
showBubble={showBubble}
|
|
288
356
|
studioControlOptions={studioControlOptions}
|