Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 52 additions & 5 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
EnvironmentId,
ExplicitSkillInvocation,
MessageId,
ModelSelection,
OrchestrationThreadShell,
Expand All @@ -13,6 +14,7 @@ import {
serializeComposerFileLink,
type ComposerTrigger,
} from "@t3tools/shared/composerTrigger";
import { updateExplicitSkillInvocationsForTextEdit } from "@t3tools/shared/explicitSkillInvocations";
import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
import type { ReactNode } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
Expand Down Expand Up @@ -91,6 +93,7 @@ export const COMPOSER_EXPANDED_CHROME = 156;

export interface ThreadComposerProps {
readonly draftMessage: string;
readonly draftSkillInvocations: ReadonlyArray<ExplicitSkillInvocation>;
readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
readonly placeholder: string;
readonly contentMaxWidth?: number;
Expand All @@ -110,7 +113,10 @@ export interface ThreadComposerProps {
readonly environmentId: EnvironmentId;
readonly projectCwd: string | null;
readonly editorRef?: RefObject<ComposerEditorHandle | null>;
readonly onChangeDraftMessage: (value: string) => void;
readonly onChangeDraftMessage: (
value: string,
skillInvocations: ReadonlyArray<ExplicitSkillInvocation>,
) => void;
readonly onPickDraftImages: () => Promise<void>;
readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
readonly onRemoveDraftImage: (imageId: string) => void;
Expand Down Expand Up @@ -287,8 +293,20 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const settingsRoutePresentedRef = useRef(false);
const wasExpandedBeforePreviewRef = useRef(false);
const inFlightThreadIdsRef = useRef(new Set<string>());
const skillInvocationsRef = useRef<ExplicitSkillInvocation[]>([...props.draftSkillInvocations]);
const previousDraftMessageRef = useRef(props.draftMessage);
const { onExpandedChange } = props;

useEffect(() => {
previousDraftMessageRef.current = props.draftMessage;
skillInvocationsRef.current = [...props.draftSkillInvocations];
}, [
props.draftMessage,
props.draftSkillInvocations,
props.environmentId,
props.selectedThread.id,
]);

const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
// Opening and presentation count as active so the composer stays expanded
Expand Down Expand Up @@ -547,6 +565,24 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
// ── Handle command selection ──────────────────────────────
const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;

const handleDraftMessageChange = useCallback(
(nextMessage: string, addedInvocation?: ExplicitSkillInvocation) => {
const previousMessage = previousDraftMessageRef.current;
skillInvocationsRef.current = updateExplicitSkillInvocationsForTextEdit({
previousText: previousMessage,
nextText: nextMessage,
invocations: skillInvocationsRef.current,
});
if (addedInvocation) {
skillInvocationsRef.current.push(addedInvocation);
skillInvocationsRef.current.sort((left, right) => left.start - right.start);
}
previousDraftMessageRef.current = nextMessage;
onChangeDraftMessage(nextMessage, skillInvocationsRef.current);
},
[onChangeDraftMessage],
);

const handleSend = useCallback(async () => {
const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
if (inFlightThreadIdsRef.current.has(threadKey)) return;
Expand All @@ -556,6 +592,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
if (messageId === null) {
return;
}
skillInvocationsRef.current = [];
previousDraftMessageRef.current = "";
// Sending a prompt starts agent work: arm the lock-screen card while the
// app is foregrounded and the activity token can be registered. Armed
// after the send so its preference read and native Activity start don't
Expand Down Expand Up @@ -590,7 +628,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
"",
);
setComposerSelection({ start: result.cursor, end: result.cursor });
onChangeDraftMessage(result.text);
handleDraftMessageChange(result.text);
onUpdateInteractionMode(item.command);
return;
}
Expand All @@ -613,9 +651,18 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
replacement,
);
setComposerSelection({ start: result.cursor, end: result.cursor });
onChangeDraftMessage(result.text);
handleDraftMessageChange(
result.text,
item.type === "skill"
? {
name: item.skill.name,
start: composerTrigger.rangeStart,
end: composerTrigger.rangeStart + item.skill.name.length + 1,
}
: undefined,
);
},
[composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode],
[composerTrigger, draftMessage, handleDraftMessageChange, onUpdateInteractionMode],
);

// ── Model menu ───────────────────────────────────────────
Expand Down Expand Up @@ -799,7 +846,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
value={props.draftMessage}
skills={selectedProviderStatus?.skills ?? []}
selection={composerSelection}
onChangeText={props.onChangeDraftMessage}
onChangeText={handleDraftMessageChange}
onSelectionChange={handleSelectionChange}
onPasteImages={(uris) => void props.onNativePasteImages(uris)}
placeholder={props.placeholder}
Expand Down
8 changes: 7 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { HeaderHeightContext } from "@react-navigation/elements";
import type {
ApprovalRequestId,
EnvironmentId,
ExplicitSkillInvocation,
MessageId,
ModelSelection,
OrchestrationThreadShell,
Expand Down Expand Up @@ -96,6 +97,7 @@ export interface ThreadDetailScreenProps {
readonly activePendingUserInputAnswers: Record<string, string | ReadonlyArray<string>> | null;
readonly respondingUserInputId: ApprovalRequestId | null;
readonly draftMessage: string;
readonly draftSkillInvocations: ReadonlyArray<ExplicitSkillInvocation>;
readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
readonly connectionStateLabel: EnvironmentConnectionPhase;
/** Message sync status for the selected thread (drives the composer status pill). */
Expand All @@ -111,7 +113,10 @@ export interface ThreadDetailScreenProps {
readonly usesAutomaticContentInsets?: boolean;
readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
readonly onOpenConnectionEditor: () => void;
readonly onChangeDraftMessage: (value: string) => void;
readonly onChangeDraftMessage: (
value: string,
skillInvocations: ReadonlyArray<ExplicitSkillInvocation>,
) => void;
readonly onPickDraftImages: () => Promise<void>;
readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
readonly onRemoveDraftImage: (imageId: string) => void;
Expand Down Expand Up @@ -742,6 +747,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
<ThreadComposer
editorRef={composerEditorRef}
draftMessage={props.draftMessage}
draftSkillInvocations={props.draftSkillInvocations}
draftAttachments={props.draftAttachments}
placeholder="Ask the repo agent, or run a command…"
contentMaxWidth={contentMaxWidth}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,7 @@ function ThreadRouteContent(
activePendingUserInputAnswers={requests.activePendingUserInputAnswers}
respondingUserInputId={requests.respondingUserInputId}
draftMessage={composer.draftMessage}
draftSkillInvocations={composer.draftSkillInvocations}
draftAttachments={composer.draftAttachments}
connectionStateLabel={routeConnectionState}
threadSyncStatus={selectedThreadDetailState.status}
Expand Down
7 changes: 5 additions & 2 deletions apps/mobile/src/state/thread-outbox-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell
import {
CommandId,
EnvironmentId,
ExplicitSkillInvocation,
IsoDateTime,
MessageId,
ModelSelection,
Expand All @@ -21,7 +22,7 @@ import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema
import type { DraftComposerImageAttachment } from "../lib/composerImages";
import { scopedThreadKey } from "../lib/scopedEntities";

const THREAD_OUTBOX_SCHEMA_VERSION = 3;
const THREAD_OUTBOX_SCHEMA_VERSION = 4;
const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;

const QueuedThreadCreationSchema = Schema.Struct({
Expand All @@ -37,12 +38,13 @@ const QueuedThreadCreationSchema = Schema.Struct({
});

export const QueuedThreadMessageSchema = Schema.Struct({
schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION]),
schemaVersion: Schema.Literals([1, 2, 3, THREAD_OUTBOX_SCHEMA_VERSION]),
environmentId: EnvironmentId,
threadId: ThreadId,
messageId: MessageId,
commandId: CommandId,
text: Schema.String,
skillInvocations: Schema.optional(Schema.Array(ExplicitSkillInvocation)),
attachments: Schema.Array(DraftComposerImageAttachmentSchema),
modelSelection: Schema.optional(ModelSelection),
runtimeMode: Schema.optional(RuntimeMode),
Expand Down Expand Up @@ -72,6 +74,7 @@ export interface QueuedThreadMessage {
readonly messageId: MessageId;
readonly commandId: CommandId;
readonly text: string;
readonly skillInvocations?: ReadonlyArray<typeof ExplicitSkillInvocation.Type>;
readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
readonly modelSelection?: ModelSelectionType;
readonly runtimeMode?: RuntimeModeType;
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/state/thread-outbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ describe("thread outbox", () => {
},
runtimeMode: "approval-required",
interactionMode: "plan",
skillInvocations: [{ name: "review", start: 0, end: 7 }],
} satisfies QueuedThreadMessage;

expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(selectedMessage))).toEqual(
Expand Down
32 changes: 32 additions & 0 deletions apps/mobile/src/state/use-composer-drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,38 @@ describe("mobile composer drafts", () => {
).toThrow();
});

it("persists explicit skill ranges and clears them with sent content", () => {
const draftKey = "environment-1:thread-1";
const draft = decodePersistedComposerDrafts({
schemaVersion: 1,
drafts: {
[draftKey]: {
text: "$review this",
skillInvocations: [{ name: "review", start: 0, end: 7 }],
attachments: [],
},
},
})[draftKey];

expect(draft?.skillInvocations).toEqual([{ name: "review", start: 0, end: 7 }]);
expect(clearComposerDraftContentState({ [draftKey]: draft! }, draftKey)).toEqual({});
});

it("restores skill ranges when a failed send merges into matching text", () => {
const draftKey = "environment-1:thread-1";
const merged = mergeComposerDraftContentState(
{ [draftKey]: { text: "$review this", attachments: [] } },
draftKey,
{
text: "$review this",
skillInvocations: [{ name: "review", start: 0, end: 7 }],
attachments: [],
},
);

expect(merged[draftKey]?.skillInvocations).toEqual([{ name: "review", start: 0, end: 7 }]);
});

it("clears sent content without clearing the selected model or workspace", () => {
const draftKey = "environment-1:thread-1";
const draft: ComposerDraft = {
Expand Down
56 changes: 52 additions & 4 deletions apps/mobile/src/state/use-composer-drafts.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useAtomValue } from "@effect/atom-react";
import {
ModelSelection as ModelSelectionSchema,
ExplicitSkillInvocation as ExplicitSkillInvocationSchema,
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
ProviderInteractionMode as ProviderInteractionModeSchema,
RuntimeMode as RuntimeModeSchema,
type EnvironmentId,
type ExplicitSkillInvocation,
type ModelSelection,
type ProviderInteractionMode,
type RuntimeMode,
Expand Down Expand Up @@ -40,6 +42,7 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass<Compo

export interface ComposerDraft {
readonly text: string;
readonly skillInvocations?: ReadonlyArray<ExplicitSkillInvocation>;
readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
readonly importedShareIds?: ReadonlyArray<string>;
readonly modelSelection?: ModelSelection;
Expand All @@ -50,6 +53,7 @@ export interface ComposerDraft {

export interface ComposerDraftContent {
readonly text: string;
readonly skillInvocations?: ReadonlyArray<ExplicitSkillInvocation>;
readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
readonly sourceShareId?: string;
}
Expand All @@ -75,6 +79,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({

const ComposerDraftSchema = Schema.Struct({
text: Schema.String,
skillInvocations: Schema.optional(Schema.Array(ExplicitSkillInvocationSchema)),
attachments: Schema.Array(DraftComposerImageAttachmentSchema),
importedShareIds: Schema.optional(Schema.Array(Schema.String)),
modelSelection: Schema.optional(ModelSelectionSchema),
Expand Down Expand Up @@ -280,11 +285,18 @@ function updateComposerDrafts(
schedulePersistComposerDrafts(next);
}

export function setComposerDraftText(draftKey: string, value: string): void {
export function setComposerDraftText(
draftKey: string,
value: string,
skillInvocations?: ReadonlyArray<ExplicitSkillInvocation>,
): void {
updateComposerDrafts((current) => {
const existing = normalizeDraft(current[draftKey]);
const { skillInvocations: _skillInvocations, ...draftWithoutSkills } = existing;
const draft = {
...normalizeDraft(current[draftKey]),
...(skillInvocations === undefined ? existing : draftWithoutSkills),
text: value,
...(skillInvocations && skillInvocations.length > 0 ? { skillInvocations } : {}),
};
if (isEmptyDraft(draft)) {
const next = { ...current };
Expand Down Expand Up @@ -400,7 +412,12 @@ export function clearComposerDraftContentState(
if (!existing) {
return current;
}
const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing;
const {
importedShareIds: _importedShareIds,
skillInvocations: _skillInvocations,
workspaceSelection,
...retained
} = existing;
const draft = {
...retained,
...(options?.clearWorkspaceSelection || workspaceSelection === undefined
Expand Down Expand Up @@ -460,6 +477,7 @@ export function copyComposerDraftContentState(
[targetDraftKey]: {
...target,
text: source.text,
...(source.skillInvocations ? { skillInvocations: source.skillInvocations } : {}),
attachments: source.attachments,
...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}),
},
Expand Down Expand Up @@ -494,6 +512,13 @@ function mergeComposerDraftText(existing: string, incoming: string): string {
return `${existing}\n\n${incoming}`;
}

function mergedTextOffset(existing: string, incoming: string): number | null {
if (incoming.length === 0) return null;
if (existing.length === 0 || existing === incoming) return 0;
if (existing.endsWith(`\n\n${incoming}`)) return existing.length - incoming.length;
return existing.length + 2;
}

export function mergeComposerDraftContentState(
current: Record<string, ComposerDraft>,
draftKey: string,
Expand All @@ -516,13 +541,35 @@ export function mergeComposerDraftContentState(
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
);
const text = mergeComposerDraftText(existing.text, content.text);
const incomingTextOffset = mergedTextOffset(existing.text, content.text);
const skillInvocations = [...(existing.skillInvocations ?? [])];
if (incomingTextOffset !== null) {
for (const invocation of content.skillInvocations ?? []) {
const shifted = {
...invocation,
start: invocation.start + incomingTextOffset,
end: invocation.end + incomingTextOffset,
};
if (
!skillInvocations.some(
(existingInvocation) =>
existingInvocation.name === shifted.name &&
existingInvocation.start === shifted.start &&
existingInvocation.end === shifted.end,
)
) {
skillInvocations.push(shifted);
}
}
}
const importedShareIds = content.sourceShareId
? [...(existing.importedShareIds ?? []), content.sourceShareId]
: existing.importedShareIds;
if (
text === existing.text &&
attachments.length === existing.attachments.length &&
importedShareIds === existing.importedShareIds
importedShareIds === existing.importedShareIds &&
skillInvocations.length === (existing.skillInvocations?.length ?? 0)
) {
return current;
}
Expand All @@ -531,6 +578,7 @@ export function mergeComposerDraftContentState(
[draftKey]: {
...existing,
text,
...(skillInvocations.length > 0 ? { skillInvocations } : {}),
attachments,
...(importedShareIds ? { importedShareIds } : {}),
},
Expand Down
Loading
Loading