@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comergehq/studio",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Comerge studio",
5
5
  "main": "src/index.ts",
6
6
  "module": "dist/index.mjs",
@@ -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 overlayBottom = composerHeight + footerBottomPadding + theme.spacing.lg;
59
- const bottomInset = composerHeight + footerBottomPadding + theme.spacing.xl;
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
+ }
@@ -21,7 +21,8 @@ export type AgentCreateAppResult = {
21
21
  export type AgentEditAppResult = {
22
22
  threadId: string;
23
23
  appId: string;
24
- sandboxExternalId: string;
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 = hasNoOutcomeAfterLastHuman(thread.raw);
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
+ }