import { useAtomValue } from "@effect/atom-react";import { useCallback, useEffect, useMemo } from "react"; import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts";import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";import { Atom } from "effect/unstable/reactivity"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata";import { convertPastedImagesToAttachments, pasteComposerClipboard, pickComposerImages,} from "../lib/composerImages";import type { DraftComposerImageAttachment } from "../lib/composerImages";import { scopedThreadKey } from "../lib/scopedEntities";import { buildThreadFeed, type QueuedThreadMessage } from "../lib/threadActivity";import { appAtomRegistry } from "../state/atom-registry";import { getEnvironmentClient } from "./environment-session-registry";import type { ConnectedEnvironmentSummary } from "../state/remote-runtime-types";import { setPendingConnectionError, useRemoteConnectionStatus,} from "../state/use-remote-environment-registry";import { useRemoteCatalog } from "../state/use-remote-catalog";import { useSelectedThreadDetail } from "../state/use-thread-detail";import { useThreadSelection } from "../state/use-thread-selection"; const draftMessageByThreadKeyAtom = Atom.make<Record<string, string>>({}).pipe( Atom.keepAlive, Atom.withLabel("mobile:thread-composer:draft-message"),); const draftAttachmentsByThreadKeyAtom = Atom.make< Record<string, ReadonlyArray<DraftComposerImageAttachment>>>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:draft-attachments")); const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe( Atom.keepAlive, Atom.withLabel("mobile:thread-composer:dispatching-message-id"),); const queuedMessagesByThreadKeyAtom = Atom.make<Record<string, ReadonlyArray<QueuedThreadMessage>>>( {},).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:queued-messages")); function setDraftMessage(threadKey: string, value: string): void { const current = appAtomRegistry.get(draftMessageByThreadKeyAtom); appAtomRegistry.set(draftMessageByThreadKeyAtom, { ...current, [threadKey]: value, });} function appendDraftAttachments( threadKey: string, attachments: ReadonlyArray<DraftComposerImageAttachment>,): void { const current = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom); appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, { ...current, [threadKey]: [...(current[threadKey] ?? []), ...attachments], });} function appendDraftMessage(threadKey: string, value: string): void { const current = appAtomRegistry.get(draftMessageByThreadKeyAtom); appAtomRegistry.set(draftMessageByThreadKeyAtom, { ...current, [threadKey]: `${current[threadKey] ?? ""}${value}`, });} export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly text: string; readonly attachments?: ReadonlyArray<DraftComposerImageAttachment>;}): void { const threadKey = scopedThreadKey(input.environmentId, input.threadId); const current = appAtomRegistry.get(draftMessageByThreadKeyAtom); const existing = current[threadKey] ?? ""; const separator = existing.trim().length > 0 && !existing.endsWith("\n") ? "\n\n" : ""; appAtomRegistry.set(draftMessageByThreadKeyAtom, { ...current, [threadKey]: `${existing}${separator}${input.text}`, }); if (input.attachments && input.attachments.length > 0) { appendDraftAttachments(threadKey, input.attachments); }} export function useThreadDraftForThread(input: { readonly environmentId?: EnvironmentId; readonly threadId?: ThreadId;}) { const draftMessageByThreadKey = useAtomValue(draftMessageByThreadKeyAtom); const draftAttachmentsByThreadKey = useAtomValue(draftAttachmentsByThreadKeyAtom); const threadKey = input.environmentId && input.threadId ? scopedThreadKey(input.environmentId, input.threadId) : null; return { draftMessage: threadKey ? (draftMessageByThreadKey[threadKey] ?? "") : "", draftAttachments: threadKey ? (draftAttachmentsByThreadKey[threadKey] ?? []) : [], };} function clearDraft(threadKey: string): void { const draftMessages = appAtomRegistry.get(draftMessageByThreadKeyAtom); const draftAttachments = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom); appAtomRegistry.set(draftMessageByThreadKeyAtom, { ...draftMessages, [threadKey]: "", }); appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, { ...draftAttachments, [threadKey]: [], });} function removeDraftImage(threadKey: string, imageId: string): void { const current = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom); appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, { ...current, [threadKey]: (current[threadKey] ?? []).filter((image) => image.id !== imageId), });} function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId);} function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void { const current = appAtomRegistry.get(dispatchingQueuedMessageIdAtom); appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current);} function enqueueQueuedMessage(message: QueuedThreadMessage): void { const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom); const threadKey = scopedThreadKey(message.environmentId, message.threadId); appAtomRegistry.set(queuedMessagesByThreadKeyAtom, { ...current, [threadKey]: [...(current[threadKey] ?? []), message], });} function removeQueuedMessage( environmentId: EnvironmentId, threadId: ThreadId, queuedMessageId: MessageId,): void { const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom); const threadKey = scopedThreadKey(environmentId, threadId); const existing = current[threadKey]; if (!existing) { return; } const nextQueue = existing.filter((entry) => entry.messageId !== queuedMessageId); const next = { ...current }; if (nextQueue.length === 0) { delete next[threadKey]; } else { next[threadKey] = nextQueue; } appAtomRegistry.set(queuedMessagesByThreadKeyAtom, next);} function useQueueDrain(input: { readonly dispatchingQueuedMessageId: MessageId | null; readonly queuedMessagesByThreadKey: Record<string, ReadonlyArray<QueuedThreadMessage>>; readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>; readonly environments: ReadonlyArray<ConnectedEnvironmentSummary>; readonly sendQueuedMessage: (message: QueuedThreadMessage) => Promise<void>;}) { const { dispatchingQueuedMessageId, environments, queuedMessagesByThreadKey, sendQueuedMessage, threads, } = input; useEffect(() => { if (dispatchingQueuedMessageId !== null) { return; } for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) { const nextQueuedMessage = queuedMessages[0]; if (!nextQueuedMessage) { continue; } const thread = threads.find( (candidate) => scopedThreadKey(candidate.environmentId, candidate.id) === threadKey, ); if (!thread) { continue; } const environment = environments.find( (candidate) => candidate.environmentId === nextQueuedMessage.environmentId, ); if (!environment || environment.connectionState !== "ready") { continue; } const threadStatus = thread.session?.status; if (threadStatus === "running" || threadStatus === "starting") { continue; } void sendQueuedMessage(nextQueuedMessage); return; } }, [ dispatchingQueuedMessageId, environments, queuedMessagesByThreadKey, sendQueuedMessage, threads, ]);} export function useThreadComposerState() { const { connectedEnvironments } = useRemoteConnectionStatus(); const { threads } = useRemoteCatalog(); const { selectedThread: selectedThreadShell } = useThreadSelection(); const selectedThread = useSelectedThreadDetail(); const draftMessageByThreadKey = useAtomValue(draftMessageByThreadKeyAtom); const draftAttachmentsByThreadKey = useAtomValue(draftAttachmentsByThreadKeyAtom); const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); const queuedMessagesByThreadKey = useAtomValue(queuedMessagesByThreadKeyAtom); const selectedThreadKey = selectedThreadShell ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id) : null; const selectedThreadQueuedMessages = useMemo( () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), [queuedMessagesByThreadKey, selectedThreadKey], ); const selectedThreadFeed = useMemo( () => selectedThread ? buildThreadFeed(selectedThread, selectedThreadQueuedMessages, dispatchingQueuedMessageId) : [], [dispatchingQueuedMessageId, selectedThread, selectedThreadQueuedMessages], ); const draftMessage = selectedThreadKey ? (draftMessageByThreadKey[selectedThreadKey] ?? "") : ""; const draftAttachments = selectedThreadKey ? (draftAttachmentsByThreadKey[selectedThreadKey] ?? []) : []; const selectedThreadQueueCount = selectedThreadQueuedMessages.length; const selectedThreadSessionActivity = useMemo(() => { if (!selectedThread?.session) { return null; } return { orchestrationStatus: selectedThread.session.status, activeTurnId: selectedThread.session.activeTurnId ?? undefined, }; }, [selectedThread]); const queuedSendStartedAt = selectedThreadQueuedMessages[0]?.createdAt ?? null; const activeWorkStartedAt = useMemo(() => { if (!selectedThread) { return null; } return deriveActiveWorkStartedAt( selectedThread.latestTurn, selectedThreadSessionActivity, queuedSendStartedAt, ); }, [queuedSendStartedAt, selectedThread, selectedThreadSessionActivity]); const activeThreadBusy = !!selectedThread && (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); const sendQueuedMessage = useCallback( async (queuedMessage: QueuedThreadMessage) => { const client = getEnvironmentClient(queuedMessage.environmentId); const thread = threads.find( (candidate) => candidate.environmentId === queuedMessage.environmentId && candidate.id === queuedMessage.threadId, ); if (!client || !thread) { return; } beginDispatchingQueuedMessage(queuedMessage.messageId); try { await client.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: queuedMessage.commandId, threadId: queuedMessage.threadId, message: { messageId: queuedMessage.messageId, role: "user", text: queuedMessage.text, attachments: queuedMessage.attachments, }, runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, createdAt: queuedMessage.createdAt, }); removeQueuedMessage( queuedMessage.environmentId, queuedMessage.threadId, queuedMessage.messageId, ); } catch (error) { removeQueuedMessage( queuedMessage.environmentId, queuedMessage.threadId, queuedMessage.messageId, ); setPendingConnectionError( error instanceof Error ? error.message : "Failed to send message.", ); } finally { finishDispatchingQueuedMessage(queuedMessage.messageId); } }, [threads], ); useQueueDrain({ dispatchingQueuedMessageId, queuedMessagesByThreadKey, threads, environments: connectedEnvironments, sendQueuedMessage, }); const onSendMessage = useCallback(() => { if (!selectedThreadShell) { return; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const text = (draftMessageByThreadKey[threadKey] ?? "").trim(); const attachments = draftAttachmentsByThreadKey[threadKey] ?? []; if (text.length === 0 && attachments.length === 0) { return; } const metadata = makeQueuedMessageMetadata(); enqueueQueuedMessage({ environmentId: selectedThreadShell.environmentId, threadId: selectedThreadShell.id, messageId: MessageId.make(metadata.messageId), commandId: CommandId.make(metadata.commandId), text, attachments, createdAt: metadata.createdAt, }); clearDraft(threadKey); }, [draftAttachmentsByThreadKey, draftMessageByThreadKey, selectedThreadShell]); const onChangeDraftMessage = useCallback( (value: string) => { if (!selectedThreadShell) { return; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); setDraftMessage(threadKey, value); }, [selectedThreadShell], ); const onPickDraftImages = useCallback(async () => { if (!selectedThreadShell) { return; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const result = await pickComposerImages({ existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0, }); if (result.images.length > 0) { appendDraftAttachments(threadKey, result.images); } if (result.error) { setPendingConnectionError(result.error); } }, [draftAttachmentsByThreadKey, selectedThreadShell]); const onPasteIntoDraft = useCallback(async () => { if (!selectedThreadShell) { return; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const result = await pasteComposerClipboard({ existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0, }); if (result.images.length > 0) { appendDraftAttachments(threadKey, result.images); } if (result.text) { appendDraftMessage(threadKey, result.text); } if (result.error) { setPendingConnectionError(result.error); } }, [draftAttachmentsByThreadKey, selectedThreadShell]); const onNativePasteImages = useCallback( async (uris: ReadonlyArray<string>) => { if (!selectedThreadShell || uris.length === 0) { return; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); try { const images = await convertPastedImagesToAttachments({ uris, existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0, }); if (images.length > 0) { appendDraftAttachments(threadKey, images); } } catch (error) { console.error("[native paste] error converting images", error); } }, [draftAttachmentsByThreadKey, selectedThreadShell], ); const onRemoveDraftImage = useCallback( (imageId: string) => { if (!selectedThreadShell) { return; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); removeDraftImage(threadKey, imageId); }, [selectedThreadShell], ); return { selectedThreadFeed, selectedThreadQueueCount, activeWorkStartedAt, draftMessage, draftAttachments, activeThreadBusy, onChangeDraftMessage, onPickDraftImages, onPasteIntoDraft, onNativePasteImages, onRemoveDraftImage, onSendMessage, };}
import { useAtomValue } from "@effect/atom-react";
import { useCallback, useEffect, useMemo } from "react";
import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";
import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts";
import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";
import { Atom } from "effect/unstable/reactivity";
import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
import {
convertPastedImagesToAttachments,
pasteComposerClipboard,
pickComposerImages,
} from "../lib/composerImages";
import type { DraftComposerImageAttachment } from "../lib/composerImages";
import { scopedThreadKey } from "../lib/scopedEntities";
import { buildThreadFeed, type QueuedThreadMessage } from "../lib/threadActivity";
import { appAtomRegistry } from "../state/atom-registry";
import { getEnvironmentClient } from "./environment-session-registry";
import type { ConnectedEnvironmentSummary } from "../state/remote-runtime-types";
import {
setPendingConnectionError,
useRemoteConnectionStatus,
} from "../state/use-remote-environment-registry";
import { useRemoteCatalog } from "../state/use-remote-catalog";
import { useSelectedThreadDetail } from "../state/use-thread-detail";
import { useThreadSelection } from "../state/use-thread-selection";
const draftMessageByThreadKeyAtom = Atom.make<Record<string, string>>({}).pipe(
Atom.keepAlive,
Atom.withLabel("mobile:thread-composer:draft-message"),
);
const draftAttachmentsByThreadKeyAtom = Atom.make<
Record<string, ReadonlyArray<DraftComposerImageAttachment>>
>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:draft-attachments"));
const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe(
Atom.keepAlive,
Atom.withLabel("mobile:thread-composer:dispatching-message-id"),
);
const queuedMessagesByThreadKeyAtom = Atom.make<Record<string, ReadonlyArray<QueuedThreadMessage>>>(
{},
).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:queued-messages"));
function setDraftMessage(threadKey: string, value: string): void {
const current = appAtomRegistry.get(draftMessageByThreadKeyAtom);
appAtomRegistry.set(draftMessageByThreadKeyAtom, {
...current,
[threadKey]: value,
});
}
function appendDraftAttachments(
threadKey: string,
attachments: ReadonlyArray<DraftComposerImageAttachment>,
): void {
const current = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom);
appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, {
...current,
[threadKey]: [...(current[threadKey] ?? []), ...attachments],
});
}
function appendDraftMessage(threadKey: string, value: string): void {
const current = appAtomRegistry.get(draftMessageByThreadKeyAtom);
appAtomRegistry.set(draftMessageByThreadKeyAtom, {
...current,
[threadKey]: `${current[threadKey] ?? ""}${value}`,
});
}
export function appendReviewCommentToDraft(input: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly text: string;
readonly attachments?: ReadonlyArray<DraftComposerImageAttachment>;
}): void {
const threadKey = scopedThreadKey(input.environmentId, input.threadId);
const current = appAtomRegistry.get(draftMessageByThreadKeyAtom);
const existing = current[threadKey] ?? "";
const separator = existing.trim().length > 0 && !existing.endsWith("\n") ? "\n\n" : "";
appAtomRegistry.set(draftMessageByThreadKeyAtom, {
...current,
[threadKey]: `${existing}${separator}${input.text}`,
});
if (input.attachments && input.attachments.length > 0) {
appendDraftAttachments(threadKey, input.attachments);
}
}
export function useThreadDraftForThread(input: {
readonly environmentId?: EnvironmentId;
readonly threadId?: ThreadId;
}) {
const draftMessageByThreadKey = useAtomValue(draftMessageByThreadKeyAtom);
const draftAttachmentsByThreadKey = useAtomValue(draftAttachmentsByThreadKeyAtom);
const threadKey =
input.environmentId && input.threadId
? scopedThreadKey(input.environmentId, input.threadId)
: null;
return {
draftMessage: threadKey ? (draftMessageByThreadKey[threadKey] ?? "") : "",
draftAttachments: threadKey ? (draftAttachmentsByThreadKey[threadKey] ?? []) : [],
};
}
function clearDraft(threadKey: string): void {
const draftMessages = appAtomRegistry.get(draftMessageByThreadKeyAtom);
const draftAttachments = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom);
appAtomRegistry.set(draftMessageByThreadKeyAtom, {
...draftMessages,
[threadKey]: "",
});
appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, {
...draftAttachments,
[threadKey]: [],
});
}
function removeDraftImage(threadKey: string, imageId: string): void {
const current = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom);
appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, {
...current,
[threadKey]: (current[threadKey] ?? []).filter((image) => image.id !== imageId),
});
}
function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void {
appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId);
}
function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void {
const current = appAtomRegistry.get(dispatchingQueuedMessageIdAtom);
appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current);
}
function enqueueQueuedMessage(message: QueuedThreadMessage): void {
const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom);
const threadKey = scopedThreadKey(message.environmentId, message.threadId);
appAtomRegistry.set(queuedMessagesByThreadKeyAtom, {
...current,
[threadKey]: [...(current[threadKey] ?? []), message],
});
}
function removeQueuedMessage(
environmentId: EnvironmentId,
threadId: ThreadId,
queuedMessageId: MessageId,
): void {
const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom);
const threadKey = scopedThreadKey(environmentId, threadId);
const existing = current[threadKey];
if (!existing) {
return;
}
const nextQueue = existing.filter((entry) => entry.messageId !== queuedMessageId);
const next = { ...current };
if (nextQueue.length === 0) {
delete next[threadKey];
} else {
next[threadKey] = nextQueue;
}
appAtomRegistry.set(queuedMessagesByThreadKeyAtom, next);
}
function useQueueDrain(input: {
readonly dispatchingQueuedMessageId: MessageId | null;
readonly queuedMessagesByThreadKey: Record<string, ReadonlyArray<QueuedThreadMessage>>;
readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>;
readonly environments: ReadonlyArray<ConnectedEnvironmentSummary>;
readonly sendQueuedMessage: (message: QueuedThreadMessage) => Promise<void>;
}) {
const {
dispatchingQueuedMessageId,
environments,
queuedMessagesByThreadKey,
sendQueuedMessage,
threads,
} = input;
useEffect(() => {
if (dispatchingQueuedMessageId !== null) {
return;
}
for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) {
const nextQueuedMessage = queuedMessages[0];
if (!nextQueuedMessage) {
continue;
}
const thread = threads.find(
(candidate) => scopedThreadKey(candidate.environmentId, candidate.id) === threadKey,
);
if (!thread) {
continue;
}
const environment = environments.find(
(candidate) => candidate.environmentId === nextQueuedMessage.environmentId,
);
if (!environment || environment.connectionState !== "ready") {
continue;
}
const threadStatus = thread.session?.status;
if (threadStatus === "running" || threadStatus === "starting") {
continue;
}
void sendQueuedMessage(nextQueuedMessage);
return;
}
}, [
dispatchingQueuedMessageId,
environments,
queuedMessagesByThreadKey,
sendQueuedMessage,
threads,
]);
}
export function useThreadComposerState() {
const { connectedEnvironments } = useRemoteConnectionStatus();
const { threads } = useRemoteCatalog();
const { selectedThread: selectedThreadShell } = useThreadSelection();
const selectedThread = useSelectedThreadDetail();
const draftMessageByThreadKey = useAtomValue(draftMessageByThreadKeyAtom);
const draftAttachmentsByThreadKey = useAtomValue(draftAttachmentsByThreadKeyAtom);
const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);
const queuedMessagesByThreadKey = useAtomValue(queuedMessagesByThreadKeyAtom);
const selectedThreadKey = selectedThreadShell
? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id)
: null;
const selectedThreadQueuedMessages = useMemo(
() => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),
[queuedMessagesByThreadKey, selectedThreadKey],
);
const selectedThreadFeed = useMemo(
() =>
selectedThread
? buildThreadFeed(selectedThread, selectedThreadQueuedMessages, dispatchingQueuedMessageId)
: [],
[dispatchingQueuedMessageId, selectedThread, selectedThreadQueuedMessages],
);
const draftMessage = selectedThreadKey ? (draftMessageByThreadKey[selectedThreadKey] ?? "") : "";
const draftAttachments = selectedThreadKey
? (draftAttachmentsByThreadKey[selectedThreadKey] ?? [])
: [];
const selectedThreadQueueCount = selectedThreadQueuedMessages.length;
const selectedThreadSessionActivity = useMemo(() => {
if (!selectedThread?.session) {
return null;
}
return {
orchestrationStatus: selectedThread.session.status,
activeTurnId: selectedThread.session.activeTurnId ?? undefined,
};
}, [selectedThread]);
const queuedSendStartedAt = selectedThreadQueuedMessages[0]?.createdAt ?? null;
const activeWorkStartedAt = useMemo(() => {
if (!selectedThread) {
return null;
}
return deriveActiveWorkStartedAt(
selectedThread.latestTurn,
selectedThreadSessionActivity,
queuedSendStartedAt,
);
}, [queuedSendStartedAt, selectedThread, selectedThreadSessionActivity]);
const activeThreadBusy =
!!selectedThread &&
(selectedThread.session?.status === "running" || selectedThread.session?.status === "starting");
const sendQueuedMessage = useCallback(
async (queuedMessage: QueuedThreadMessage) => {
const client = getEnvironmentClient(queuedMessage.environmentId);
const thread = threads.find(
(candidate) =>
candidate.environmentId === queuedMessage.environmentId &&
candidate.id === queuedMessage.threadId,
);
if (!client || !thread) {
return;
}
beginDispatchingQueuedMessage(queuedMessage.messageId);
try {
await client.orchestration.dispatchCommand({
type: "thread.turn.start",
commandId: queuedMessage.commandId,
threadId: queuedMessage.threadId,
message: {
messageId: queuedMessage.messageId,
role: "user",
text: queuedMessage.text,
attachments: queuedMessage.attachments,
},
runtimeMode: thread.runtimeMode,
interactionMode: thread.interactionMode,
createdAt: queuedMessage.createdAt,
});
removeQueuedMessage(
queuedMessage.environmentId,
queuedMessage.threadId,
queuedMessage.messageId,
);
} catch (error) {
removeQueuedMessage(
queuedMessage.environmentId,
queuedMessage.threadId,
queuedMessage.messageId,
);
setPendingConnectionError(
error instanceof Error ? error.message : "Failed to send message.",
);
} finally {
finishDispatchingQueuedMessage(queuedMessage.messageId);
}
},
[threads],
);
useQueueDrain({
dispatchingQueuedMessageId,
queuedMessagesByThreadKey,
threads,
environments: connectedEnvironments,
sendQueuedMessage,
});
const onSendMessage = useCallback(() => {
if (!selectedThreadShell) {
return;
}
const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
const text = (draftMessageByThreadKey[threadKey] ?? "").trim();
const attachments = draftAttachmentsByThreadKey[threadKey] ?? [];
if (text.length === 0 && attachments.length === 0) {
return;
}
const metadata = makeQueuedMessageMetadata();
enqueueQueuedMessage({
environmentId: selectedThreadShell.environmentId,
threadId: selectedThreadShell.id,
messageId: MessageId.make(metadata.messageId),
commandId: CommandId.make(metadata.commandId),
text,
attachments,
createdAt: metadata.createdAt,
});
clearDraft(threadKey);
}, [draftAttachmentsByThreadKey, draftMessageByThreadKey, selectedThreadShell]);
const onChangeDraftMessage = useCallback(
(value: string) => {
if (!selectedThreadShell) {
return;
}
const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
setDraftMessage(threadKey, value);
},
[selectedThreadShell],
);
const onPickDraftImages = useCallback(async () => {
if (!selectedThreadShell) {
return;
}
const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
const result = await pickComposerImages({
existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0,
});
if (result.images.length > 0) {
appendDraftAttachments(threadKey, result.images);
}
if (result.error) {
setPendingConnectionError(result.error);
}
}, [draftAttachmentsByThreadKey, selectedThreadShell]);
const onPasteIntoDraft = useCallback(async () => {
if (!selectedThreadShell) {
return;
}
const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
const result = await pasteComposerClipboard({
existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0,
});
if (result.images.length > 0) {
appendDraftAttachments(threadKey, result.images);
}
if (result.text) {
appendDraftMessage(threadKey, result.text);
}
if (result.error) {
setPendingConnectionError(result.error);
}
}, [draftAttachmentsByThreadKey, selectedThreadShell]);
const onNativePasteImages = useCallback(
async (uris: ReadonlyArray<string>) => {
if (!selectedThreadShell || uris.length === 0) {
return;
}
const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
try {
const images = await convertPastedImagesToAttachments({
uris,
existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0,
});
if (images.length > 0) {
appendDraftAttachments(threadKey, images);
}
} catch (error) {
console.error("[native paste] error converting images", error);
}
},
[draftAttachmentsByThreadKey, selectedThreadShell],
);
const onRemoveDraftImage = useCallback(
(imageId: string) => {
if (!selectedThreadShell) {
return;
}
const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
removeDraftImage(threadKey, imageId);
},
[selectedThreadShell],
);
return {
selectedThreadFeed,
selectedThreadQueueCount,
activeWorkStartedAt,
draftMessage,
draftAttachments,
activeThreadBusy,
onChangeDraftMessage,
onPickDraftImages,
onPasteIntoDraft,
onNativePasteImages,
onRemoveDraftImage,
onSendMessage,
};
}