@comergehq/studio 0.1.15 → 0.1.17
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 +615 -177
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +609 -171
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -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/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/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 +43 -6
- package/src/studio/ui/ChatPanel.tsx +11 -0
- package/src/studio/ui/StudioOverlay.tsx +10 -0
package/package.json
CHANGED
|
@@ -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';
|
|
@@ -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}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { EditQueueItem } from '../../data/apps/edit-queue/types';
|
|
4
|
+
import { editQueueRepository } from '../../data/apps/edit-queue/repository';
|
|
5
|
+
import { useForegroundSignal } from './useForegroundSignal';
|
|
6
|
+
|
|
7
|
+
export type UseEditQueueResult = {
|
|
8
|
+
items: EditQueueItem[];
|
|
9
|
+
loading: boolean;
|
|
10
|
+
error: Error | null;
|
|
11
|
+
refetch: () => Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function useEditQueue(appId: string): UseEditQueueResult {
|
|
15
|
+
const [items, setItems] = React.useState<EditQueueItem[]>([]);
|
|
16
|
+
const [loading, setLoading] = React.useState(false);
|
|
17
|
+
const [error, setError] = React.useState<Error | null>(null);
|
|
18
|
+
const activeRequestIdRef = React.useRef(0);
|
|
19
|
+
const foregroundSignal = useForegroundSignal(Boolean(appId));
|
|
20
|
+
|
|
21
|
+
const upsertSorted = React.useCallback((prev: EditQueueItem[], nextItem: EditQueueItem) => {
|
|
22
|
+
const next = prev.some((x) => x.id === nextItem.id)
|
|
23
|
+
? prev.map((x) => (x.id === nextItem.id ? nextItem : x))
|
|
24
|
+
: [...prev, nextItem];
|
|
25
|
+
next.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
|
|
26
|
+
return next;
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
const refetch = React.useCallback(async () => {
|
|
30
|
+
if (!appId) {
|
|
31
|
+
setItems([]);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const requestId = ++activeRequestIdRef.current;
|
|
35
|
+
setLoading(true);
|
|
36
|
+
setError(null);
|
|
37
|
+
try {
|
|
38
|
+
const list = await editQueueRepository.list(appId);
|
|
39
|
+
if (activeRequestIdRef.current !== requestId) return;
|
|
40
|
+
setItems([...list].sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))));
|
|
41
|
+
} catch (e) {
|
|
42
|
+
if (activeRequestIdRef.current !== requestId) return;
|
|
43
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
44
|
+
setItems([]);
|
|
45
|
+
} finally {
|
|
46
|
+
if (activeRequestIdRef.current === requestId) setLoading(false);
|
|
47
|
+
}
|
|
48
|
+
}, [appId]);
|
|
49
|
+
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
void refetch();
|
|
52
|
+
}, [refetch]);
|
|
53
|
+
|
|
54
|
+
React.useEffect(() => {
|
|
55
|
+
if (!appId) return;
|
|
56
|
+
const unsubscribe = editQueueRepository.subscribeEditQueue(appId, {
|
|
57
|
+
onInsert: (item) => setItems((prev) => upsertSorted(prev, item)),
|
|
58
|
+
onUpdate: (item) => setItems((prev) => upsertSorted(prev, item)),
|
|
59
|
+
onDelete: (item) => setItems((prev) => prev.filter((x) => x.id !== item.id)),
|
|
60
|
+
});
|
|
61
|
+
return unsubscribe;
|
|
62
|
+
}, [appId, upsertSorted, foregroundSignal]);
|
|
63
|
+
|
|
64
|
+
React.useEffect(() => {
|
|
65
|
+
if (!appId) return;
|
|
66
|
+
if (foregroundSignal <= 0) return;
|
|
67
|
+
void refetch();
|
|
68
|
+
}, [appId, foregroundSignal, refetch]);
|
|
69
|
+
|
|
70
|
+
return { items, loading, error, refetch };
|
|
71
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { UpdateEditQueueItemRequest } from '../../data/apps/edit-queue/types';
|
|
4
|
+
import { editQueueRepository } from '../../data/apps/edit-queue/repository';
|
|
5
|
+
|
|
6
|
+
export type UseEditQueueActionsResult = {
|
|
7
|
+
update: (queueItemId: string, payload: UpdateEditQueueItemRequest) => Promise<void>;
|
|
8
|
+
cancel: (queueItemId: string) => Promise<void>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function useEditQueueActions(appId: string): UseEditQueueActionsResult {
|
|
12
|
+
const update = React.useCallback(
|
|
13
|
+
async (queueItemId: string, payload: UpdateEditQueueItemRequest) => {
|
|
14
|
+
if (!appId) return;
|
|
15
|
+
await editQueueRepository.update(appId, queueItemId, payload);
|
|
16
|
+
},
|
|
17
|
+
[appId]
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const cancel = React.useCallback(
|
|
21
|
+
async (queueItemId: string) => {
|
|
22
|
+
if (!appId) return;
|
|
23
|
+
await editQueueRepository.cancel(appId, queueItemId);
|
|
24
|
+
},
|
|
25
|
+
[appId]
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return { update, cancel };
|
|
29
|
+
}
|