Skip to content
Closed
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
16 changes: 16 additions & 0 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
import { GlassSurface } from "../../components/GlassSurface";
import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
import { useComposerDraftRebind } from "../../native/useComposerDraftRebind";
import { fileRoutePathSegments } from "../files/filePath";
import {
ComposerActionButton,
Expand Down Expand Up @@ -155,6 +156,12 @@ export interface ThreadComposerProps {
readonly onExpandedChange?: (expanded: boolean) => void;
/** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */
readonly onEditorFocusChange?: (focused: boolean) => void;
/**
* True while an ask-question card (or a failed-creation card) owns the
* composer slot. Flipping this back to false remounts the editor so the
* stored draft is shown again.
*/
readonly composerConcealed: boolean;
}

/**
Expand Down Expand Up @@ -267,7 +274,12 @@ export function ComposerSurface(props: {
);
}

/**
* Composer for an existing thread. When `composerConcealed` flips from true
* back to false, the editor remounts and paints the stored draft.
*/
export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
const draftRebindGeneration = useComposerDraftRebind(props.composerConcealed);
const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId));
const { themeVariables: materialTheme } = useAppearancePreferences();
const composerPanel = materialTheme["--color-composer-panel"];
Expand Down Expand Up @@ -737,7 +749,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
className={isExpanded ? "px-[14px]" : "min-w-0 flex-1 px-[4px]"}
layout={COMPOSER_LAYOUT_TRANSITION}
>
{/* Remount after the ask-question card closes. The hidden editor's
revision history would treat the stored draft as a native echo
and leave the text view empty. */}
<ComposerEditor
key={draftRebindGeneration}
draftKey={composerOwnerKey}
environmentId={props.environmentId}
onOpenMention={(path) => {
Expand Down
25 changes: 15 additions & 10 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,11 @@ const USER_INPUT_TOGGLE_TIMING = {
easing: Easing.out(Easing.cubic),
};

/**
* Thread transcript and composer. An ask-question card hides the composer
* without clearing its draft, and the composer rebinds that draft when the
* card closes.
*/
export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
const navigation = useNavigation();
const deviceState = useEnvironmentQuery(
Expand Down Expand Up @@ -441,6 +446,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] =
useState<ApprovalRequestId | null>(null);
const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null;
// Same condition that hides the composer. Closing the card (or the failure
// card) flips this off so the editor rebinds the stored draft in place.
const composerConcealed =
activeUserInputRequestId !== null || props.creationState?.kind === "failed";
// The open /usage-limits panel for this thread, model and turn. Only the open
// moment is stored: the rows read live provider data, so a redeemed reset
// credit or refreshed probe shows through. Anything that spends quota closes
Expand Down Expand Up @@ -1061,17 +1070,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
</View>

{/* Hidden (not unmounted) while a user-input request owns the
composer slot, so composer drafts and editor state survive.
A rejected creation has no thread to send to; the failure card
owns the slot instead. */}
<View
style={
activeUserInputRequestId !== null || props.creationState?.kind === "failed"
? { display: "none" }
: undefined
}
>
composer slot, so the stored draft survives. Closing the card
rebinds the editor so that draft is shown again. A rejected
creation has no thread to send to; the failure card owns the
slot instead. */}
<View style={composerConcealed ? { display: "none" } : undefined}>
<ThreadComposer
composerConcealed={composerConcealed}
editorRef={composerEditorRef}
draftMessage={props.draftMessage}
draftAttachments={props.draftAttachments}
Expand Down
33 changes: 33 additions & 0 deletions apps/mobile/src/native/composerEditorRevision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
acknowledgeComposerNativeEvent,
assumeComposerControlledState,
isComposerNativeEcho,
nextComposerDraftRebind,
pruneAcknowledgedComposerNativeEvents,
resolveComposerControlledEventCount,
} from "./composerEditorRevision";
Expand Down Expand Up @@ -193,6 +194,38 @@ describe("assumeComposerControlledState", () => {
});
});

describe("nextComposerDraftRebind", () => {
const draft = "long follow-up typed before the question";
const selection = { start: draft.length, end: draft.length };
const typedSnapshots = [{ eventCount: 6, value: draft, selection }];

it("keeps the editor mounted while the question card opens and stays open", () => {
const opened = nextComposerDraftRebind({ generation: 2, concealed: false }, true);
expect(opened).toEqual({ generation: 2, concealed: true });
const stillOpen = nextComposerDraftRebind(opened, true);
expect(stillOpen).toBe(opened);
// The stored draft is still an echo of the hidden editor, and this did not clear it.
expect(isComposerNativeEcho(draft, selection, 6, typedSnapshots)).toBe(true);
});

it("rebinds when the question card closes so a fresh editor paints the stored draft", () => {
const closed = nextComposerDraftRebind({ generation: 2, concealed: true }, false);
expect(closed).toEqual({ generation: 3, concealed: false });

// The bumped generation remounts the editor. A new mount has no snapshot
// history, so the stored draft is a controlled document rather than an echo
// the text view would skip.
const freshSnapshots: typeof typedSnapshots = [];
expect(isComposerNativeEcho(draft, selection, 0, freshSnapshots)).toBe(false);
expect(resolveComposerControlledEventCount(draft, selection, 0, freshSnapshots)).toBe(0);
});

it("does not rebind when the composer was already visible", () => {
const visible = { generation: 4, concealed: false };
expect(nextComposerDraftRebind(visible, false)).toBe(visible);
});
});

describe("typing immediately before an intercepted paste", () => {
it("keeps the pre-paste React value behind the native paste revision", () => {
const snapshots = [
Expand Down
33 changes: 33 additions & 0 deletions apps/mobile/src/native/composerEditorRevision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,39 @@ export function assumeComposerControlledState(
];
}

export interface ComposerDraftRebind {
/** Increments when a concealed composer is shown again. */
readonly generation: number;
/** True while an ask-question card owns the composer slot. */
readonly concealed: boolean;
}

/**
* Decide whether the composer editor should rebind its stored draft.
*
* An ask-question card hides the composer without unmounting it, so the draft
* stays stored and the last keystroke stays classified as a native echo. On
* iOS that echo is dropped when the text view no longer matches, and the
* composer comes back empty. A thread switch mounts a fresh editor, which
* paints the same draft. `generation` advances only when the card closes so
* the editor can do that fresh mount in place.
*
* Opening the card, or leaving it open, keeps the current generation. The
* draft text is not an input, so this cannot clear it.
*/
export function nextComposerDraftRebind(
rebind: ComposerDraftRebind,
concealed: boolean,
): ComposerDraftRebind {
if (rebind.concealed === concealed) {
return rebind;
}
if (rebind.concealed && !concealed) {
return { generation: rebind.generation + 1, concealed };
}
return { generation: rebind.generation, concealed };
}

export function pruneAcknowledgedComposerNativeEvents(
snapshots: ReadonlyArray<ComposerNativeEventSnapshot>,
acknowledgedEventCount: number,
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/native/useComposerDraftRebind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useState } from "react";

import { nextComposerDraftRebind, type ComposerDraftRebind } from "./composerEditorRevision";

/**
* Bind generation for a composer hidden by an ask-question card.
*
* The generation stays put while the card opens and while it is open, so the
* mounted editor and its stored draft are left alone. It advances when the
* card closes. The composer uses that value as the editor key, which mounts a
* fresh text view that paints the stored draft instead of skipping it as a
* native echo.
*/
export function useComposerDraftRebind(concealed: boolean): number {
const [rebind, setRebind] = useState<ComposerDraftRebind>(() => ({
generation: 0,
concealed,
}));
if (rebind.concealed === concealed) {
return rebind.generation;
}
const next = nextComposerDraftRebind(rebind, concealed);
setRebind(next);
return next.generation;
}
Loading