Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6ea3414
refactor(client): composers share one workspace snapshot refresh per …
juliusmarminge Oct 26, 2026
6ffde5b
refactor(mobile): composer pastes and picks are cancelled by their dr…
juliusmarminge Oct 26, 2026
911091b
chore(mobile): e2e poses for current devices, Duo and landscape fixes
juliusmarminge Oct 26, 2026
3433ba1
fix(mobile): select the correct draft after adding a project (#24269)
juliusmarminge Oct 26, 2026
6a1c8d1
fix(mobile): mobile:install no longer requires --prepare
juliusmarminge Oct 26, 2026
3e47c1c
fix(mobile): e2e landscape poses rotate for real on tablets and folds
juliusmarminge Oct 26, 2026
cc0b86d
fix(server): goals stop the running turn, clear while working, no ech…
t3dotgg Oct 26, 2026
0712398
refactor(web): pull request list remembers each answer for the next s…
juliusmarminge Oct 26, 2026
3f3c1b9
refactor(web): HTML previews handle frame messages in one listener (#…
juliusmarminge Oct 26, 2026
2acc7b5
refactor(web): review guide runs are watched from their answers, not …
juliusmarminge Oct 26, 2026
9d7997a
refactor(web): resize drags, panel shells and the browser host own th…
juliusmarminge Oct 26, 2026
3cb16cf
fix(web): find shows its match after a thread switch, and the timelin…
juliusmarminge Oct 26, 2026
59a3f52
fix(mobile): a new thread's first send no longer sticks on Starting (…
juliusmarminge Oct 26, 2026
d5a791b
fix(client): drafts for answered questions are cleared even when thei…
juliusmarminge Oct 26, 2026
28df7a2
refactor(web): terminal output no longer re-renders the drawer, theme…
juliusmarminge Oct 26, 2026
f0dd86f
fix(web): stable callbacks refresh before layout effects read them (#…
juliusmarminge Oct 26, 2026
7d3208c
test(web): say why the useStableCallback test renders without a route…
juliusmarminge Oct 26, 2026
e5ea25d
fix(mobile): queued edits end when their run starts, wherever you are…
juliusmarminge Oct 26, 2026
148d2fd
refactor(web): root theme, toast and chrome sync start with the app (…
juliusmarminge Oct 26, 2026
a936f05
refactor(web): root theme, toast and chrome sync start with the app (…
juliusmarminge Oct 26, 2026
d6fb62a
fix(mobile): editing a queued message while a question is open no lon…
juliusmarminge Oct 26, 2026
f05c38f
fix(web): browser viewport resizes work without an open preview panel…
juliusmarminge Oct 26, 2026
d81757e
test(web): attachment preview omission tests no longer time out on CI…
juliusmarminge Oct 26, 2026
87ea18c
fix(mobile): describe when composer view commands can reject (#25516)
juliusmarminge Oct 26, 2026
7c6e85c
fix(web): browser history and titles show up again (#25491)
juliusmarminge Oct 26, 2026
3f94487
fix(web): switching back to a thread no longer restores its tab's old…
juliusmarminge Oct 26, 2026
54d888d
Merge tag 'v0.1.3-nightly.20261026.3726' into custom-nightly
chamaquita-d3[bot] Oct 26, 2026
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
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@
"react-native-pdf-jsi": "5.0.0",
"react-native-reanimated": "4.7.1",
"react-native-safe-area-context": "~5.10.1",
"react-native-screens": "file:deps/react-native-screens-5.0.0-t3.44.tgz",
"react-native-screens": "file:deps/react-native-screens-5.0.0-t3.45.tgz",
"react-native-svg": "15.16.0",
"react-native-webview": "14.0.1",
"react-native-worklets": "0.13.0",
Expand Down
27 changes: 12 additions & 15 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ import {
NewTaskEnvironmentPickerRouteScreen,
} from "./features/threads/NewTaskContextPickerScreens";
import {
ExistingThreadSettingsRouteProvider,
ExistingThreadSettingsRouteScreen,
NewTaskThreadSettingsRouteScreen,
} from "./features/threads/ThreadSettingsSheet";
Expand Down Expand Up @@ -558,20 +557,18 @@ function RootStackLayout(props: {
<ShowcaseCaptureCoordinator pathname={pathname} />
<LauncherMoodCoordinator navigationState={props.state} />
<SettingsEnvironmentFilterProvider open={settingsOpen}>
<ExistingThreadSettingsRouteProvider>
<WorkspaceConnectionStatusProvider>
<AndroidWorkspaceTabsProvider state={props.state}>
<AdaptiveWorkspaceLayout
pathname={workspaceLocation.pathname}
workspaceRouteKey={workspaceLocation.routeKey}
selectedPullRequestKey={selectedPullRequestKey(props.state)}
>
{props.children}
<HardwareKeyboardCommandOverlay />
</AdaptiveWorkspaceLayout>
</AndroidWorkspaceTabsProvider>
</WorkspaceConnectionStatusProvider>
</ExistingThreadSettingsRouteProvider>
<WorkspaceConnectionStatusProvider>
<AndroidWorkspaceTabsProvider state={props.state}>
<AdaptiveWorkspaceLayout
pathname={workspaceLocation.pathname}
workspaceRouteKey={workspaceLocation.routeKey}
selectedPullRequestKey={selectedPullRequestKey(props.state)}
>
{props.children}
<HardwareKeyboardCommandOverlay />
</AdaptiveWorkspaceLayout>
</AndroidWorkspaceTabsProvider>
</WorkspaceConnectionStatusProvider>
</SettingsEnvironmentFilterProvider>
</HardwareKeyboardCommandProvider>
</NewTaskFlowProvider>
Expand Down
90 changes: 28 additions & 62 deletions apps/mobile/src/components/ComposerAttachmentStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import { composerImageGallery } from "../lib/composerImageGallery";
import { SymbolView } from "./ui/AppSymbol";
import { imageMimeType } from "@t3tools/shared/image";
import { videoMimeType } from "@t3tools/shared/video";
import { useEffect, useMemo, useState } from "react";
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult, Atom } from "effect/reactivity";
import { Paths } from "expo-file-system";
import { Image, Pressable, ScrollView, View } from "react-native";

import { AppText as Text } from "./ui/AppText";
import { MaterializedPreviewKey, materializedPreviewAtom } from "../lib/composerImagePreview";
import { PierreEntryIcon } from "./PierreEntryIcon";
import {
isFileBackedComposerAttachment,
Expand Down Expand Up @@ -93,65 +96,31 @@ export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailPr
);
}

const PREVIEW_CACHE_DIRECTORY = "t3-composer-previews";

/** Fabric re-parses URLs each layout; large data URLs can stall and abort commits. */
const INLINE_PREVIEW_FALLBACK_MAX_CHARS = 256_000;

async function materializeDataUrlPreview(id: string, dataUrl: string): Promise<string | null> {
const comma = dataUrl.indexOf(",");
if (comma < 0) return null;
const { Directory, File, Paths } = await import("expo-file-system");
const mimeType = /^data:([^;,]+)/.exec(dataUrl)?.[1] ?? "image/jpeg";
const extension = (mimeType.split("/")[1] ?? "jpg").replace("jpeg", "jpg");
const directory = new Directory(Paths.cache, PREVIEW_CACHE_DIRECTORY);
directory.create({ idempotent: true, intermediates: true });
const file = new File(directory, `${id}.${extension}`);
if (!file.exists) {
file.create();
await file.write(dataUrl.slice(comma + 1), { encoding: "base64" });
}
return file.uri;
}
const noMaterializedPreview = Atom.make(AsyncResult.initial<string | null>()).pipe(
Atom.withLabel("composer-image-preview:none"),
);

function useComposerImagePreviewUri(attachment: DraftComposerImageAttachment): string | null {
const { id, fileUri, previewUri } = attachment;
const [rebased, setRebased] = useState<{ fileUri: string; uri: string } | null>(null);
const [materialized, setMaterialized] = useState<{ id: string; uri: string | null } | null>(null);
const { fileUri, previewUri } = attachment;
const inlinePreview = fileUri === undefined && previewUri.startsWith("data:");
useEffect(() => {
if (fileUri === undefined) return;
let cancelled = false;
void (async () => {
const { Paths } = await import("expo-file-system");
const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri);
if (!cancelled && owned !== null && owned !== previewUri) setRebased({ fileUri, uri: owned });
})();
return () => {
cancelled = true;
};
}, [fileUri, previewUri]);
useEffect(() => {
if (!inlinePreview) return;
let cancelled = false;
void materializeDataUrlPreview(id, previewUri)
.then((uri) => {
if (!cancelled && uri !== null) setMaterialized({ id, uri });
})
.catch((error: unknown) => {
console.warn("[composer-attachments] could not cache an image preview", error);
if (!cancelled) setMaterialized({ id, uri: null });
});
return () => {
cancelled = true;
};
}, [id, inlinePreview, previewUri]);
if (fileUri !== undefined && rebased?.fileUri === fileUri) return rebased.uri;
if (fileUri !== undefined) return previewUri.startsWith("data:") ? fileUri : previewUri;
const materialized = useAtomValue(
inlinePreview
? materializedPreviewAtom(new MaterializedPreviewKey(attachment))
: noMaterializedPreview,
);
if (fileUri !== undefined) {
// Reinstalls move the app container, so a stored file URI may point at the old one.
const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri);
if (owned !== null) return owned;
return previewUri.startsWith("data:") ? fileUri : previewUri;
}
if (inlinePreview) {
if (materialized?.id !== id) return null;
if (!AsyncResult.isSuccess(materialized)) return null;
return (
materialized.uri ??
materialized.value ??
(previewUri.length <= INLINE_PREVIEW_FALLBACK_MAX_CHARS ? previewUri : null)
);
}
Expand Down Expand Up @@ -280,15 +249,12 @@ function ComposerVideoAttachment(props: {
const { attachment } = props;
const sourceIdentifier = `draft:${attachment.id}`;
const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
const actionsSource = useMemo<MediaActionsSource>(
() => ({
name: attachment.name,
mimeType: videoMimeType(attachment) ?? attachment.mimeType,
sourceIdentifier,
attachment,
}),
[attachment, sourceIdentifier],
);
const actionsSource: MediaActionsSource = {
name: attachment.name,
mimeType: videoMimeType(attachment) ?? attachment.mimeType,
sourceIdentifier,
attachment,
};

return (
<VideoAttachmentTile
Expand All @@ -304,7 +270,7 @@ function ComposerVideoAttachment(props: {
}

export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
const gallery = useMemo(() => composerImageGallery(props.attachments), [props.attachments]);
const gallery = composerImageGallery(props.attachments);
const size = props.imageSize ?? 72;
const radius = props.imageBorderRadius ?? 16;
const removeButtonPlacement = props.removeButtonPlacement ?? "overlay";
Expand Down
37 changes: 19 additions & 18 deletions apps/mobile/src/components/ComposerEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import { ComposerContextId } from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/reactivity";
import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import type { EnvironmentId } from "@t3tools/contracts";
import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences";
import { ComposerEditor as NativeComposerEditor } from "../native/T3ComposerEditor";
import type { ComposerEditorProps as NativeComposerEditorProps } from "../native/T3ComposerEditor";
import {
appendComposerDraftAttachments,
composerContextImportsAtom,
createComposerDraftContextHistory,
getComposerDraftAfterSelection,
getComposerDraftSnapshot,
Expand All @@ -22,6 +23,8 @@ import {
useComposerDraft,
} from "../state/use-composer-drafts";
import { importComposerContextClipboard } from "../lib/composerContextClipboard";
import { startComposerDraftOperation } from "../state/composerDraftOperations";
import { appAtomRegistry } from "../state/atom-registry";
import { mobilePreferencesAtom } from "../state/preferences";
import { useOpenLinkedThread } from "../features/threads/useOpenLinkedThread";
import { ComposerContextSheet } from "./ComposerContextSheet";
Expand Down Expand Up @@ -73,33 +76,32 @@ export function ComposerEditor({
const [selected, setSelected] = useState<{ source: string; start: number; end: number } | null>(
null,
);
const importRef = useRef<AbortController | null>(null);
const [importing, setImporting] = useState(false);
useEffect(
() => () => {
importRef.current?.abort();
},
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- Switching drafts aborts an import owned by the previous draft.
[draftKey],
);
const contextImports = useAtomValue(composerContextImportsAtom);
const importing = draftKey ? contextImports[draftKey] === true : false;
const pasteContext = async (
clipboard: Parameters<NonNullable<NativeComposerEditorProps["onPasteContext"]>>[0],
) => {
if (!draftKey || importRef.current || props.readOnly || props.editable === false) return;
if (
!draftKey ||
appAtomRegistry.get(composerContextImportsAtom)[draftKey] ||
props.readOnly ||
props.editable === false
)
return;
const insertion = { text: clipboard.value, ...clipboard.selection };
const controller = new AbortController();
importRef.current = controller;
setImporting(true);
const operation = startComposerDraftOperation(draftKey);
const { signal } = operation;
setComposerContextImporting(draftKey, true);
const settled = await settlePromise(async () => {
const retained = getComposerDraftAfterSelection(draftKey, insertion);
const result = await importComposerContextClipboard(
clipboard,
retained.attachments.length,
controller.signal,
signal,
retained.context?.records.length ?? 0,
environmentId,
);
if (signal.aborted) return;
if (!result) {
insertComposerDraftText(draftKey, clipboard.text, insertion);
return;
Expand All @@ -126,10 +128,9 @@ export function ComposerEditor({
[...warnings, "Those references are marked unavailable."].join("\n\n"),
);
});
operation.finish();
setComposerContextImporting(draftKey, false);
importRef.current = null;
setImporting(false);
if (AsyncResult.isFailure(settled) && !controller.signal.aborted) {
if (AsyncResult.isFailure(settled) && !signal.aborted) {
const error = squashAtomCommandFailure(settled);
Dialog.alert(
"Could not paste context",
Expand Down
Loading
Loading