Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";
import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface";
import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads";

const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);

Expand Down Expand Up @@ -355,6 +356,7 @@ function workspacePathFromState(state: NavigationState): string {
// each enqueue, shell change, or reconnect.
function ThreadOutboxDrainWorker() {
useThreadOutboxDrain();
useComposerAttachmentUploadWorker();
return null;
}

Expand Down
52 changes: 50 additions & 2 deletions apps/mobile/src/components/ComposerAttachmentStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@ import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
import { PresentationSource } from "./NativePresentation";
import type { FilePreviewSource } from "./FilePreviewModal";
import { isPdfFile } from "../lib/filePreview";
import type { EnvironmentId } from "@t3tools/contracts";
import {
retryComposerAttachmentUpload,
useComposerAttachmentUploadState,
} from "../state/composer-attachment-uploads";

export interface ComposerAttachmentStripProps {
readonly environmentId?: EnvironmentId;
/** Attachments to display. */
readonly attachments: ReadonlyArray<DraftComposerAttachment>;
/** Called when the user removes an attachment. */
Expand All @@ -30,7 +36,8 @@ export interface ComposerAttachmentStripProps {
readonly removeButtonPlacement?: "overlay" | "gutter";
}

export function ComposerAttachmentThumbnail(props: {
type ComposerAttachmentThumbnailProps = {
readonly environmentId?: EnvironmentId;
readonly attachment: DraftComposerAttachment;
readonly size: number;
readonly borderRadius: number;
Expand All @@ -40,7 +47,47 @@ export function ComposerAttachmentThumbnail(props: {
attachment: DraftComposerFileAttachment,
sourceIdentifier: string,
) => void;
}) {
};

export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailProps) {
const upload = useComposerAttachmentUploadState(props.environmentId, props.attachment.id);
return (
<View style={{ width: props.size, height: props.size }}>
<ComposerAttachmentContent {...props} />
{upload && upload.status !== "ready" ? (
<Pressable
accessibilityRole={upload.status === "failed" ? "button" : "text"}
accessibilityLabel={
upload.status === "failed"
? `Retry uploading ${props.attachment.name}`
: `Uploading ${props.attachment.name}, ${Math.floor(upload.progress * 100)}%`
}
accessibilityHint={upload.status === "failed" ? upload.reason : undefined}
disabled={upload.status !== "failed"}
onPress={() =>
props.environmentId &&
retryComposerAttachmentUpload(props.environmentId, props.attachment.id)
}
className="absolute bottom-0.5 left-0.5 flex-row items-center gap-0.5 rounded-full bg-black/70 px-1 py-0.5"
>
<SymbolView
name={upload.status === "failed" ? "arrow.clockwise" : "arrow.up"}
size={props.compact ? 8 : 10}
tintColor="#ffffff"
type="monochrome"
/>
{!props.compact ? (
<Text className="text-2xs text-white">
{upload.status === "failed" ? "Retry" : `${Math.floor(upload.progress * 100)}%`}
</Text>
) : null}
</Pressable>
) : null}
</View>
);
}

function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) {
const { attachment } = props;
const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
if (attachment.type === "image") {
Expand Down Expand Up @@ -213,6 +260,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
}}
>
<ComposerAttachmentThumbnail
environmentId={props.environmentId}
attachment={attachment}
size={size}
borderRadius={radius}
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/features/cloud/CloudAuthProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ vi.mock("../../connection/catalog", () => ({
},
}));

vi.mock("./cloud-drafts", () => ({ removeCloudEnvironments: {} }));
vi.mock("../../state/use-composer-drafts", () => ({
getComposerCloudAccountId: vi.fn(async () => null),
restoreCloudComposerDrafts: vi.fn(async () => undefined),
}));

vi.mock("./publicConfig", () => ({
resolveCloudPublicConfig: vi.fn(() => ({
clerk: { publishableKey: null },
Expand Down
82 changes: 52 additions & 30 deletions apps/mobile/src/features/cloud/CloudAuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,26 @@ import {
reportAtomCommandResult,
settleAsyncResult,
settlePromise,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import * as Effect from "effect/Effect";
import { type ReactNode, useEffect, useRef } from "react";

import { environmentCatalog } from "../../connection/catalog";
import { runtime } from "../../lib/runtime";
import { appAtomRegistry } from "../../state/atom-registry";
import { useAtomCommand } from "../../state/use-atom-command";
import {
getComposerCloudAccountId,
restoreCloudComposerDrafts,
} from "../../state/use-composer-drafts";
import {
releaseAgentAwarenessRelayTokenProvider,
setAgentAwarenessRelayTokenProvider,
unregisterAgentAwarenessDeviceForCurrentUser,
} from "../agent-awareness/remoteRegistration";
import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding";
import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
import { removeCloudEnvironments } from "./cloud-drafts";

function resetManagedRelayTokenCache() {
return settleAsyncResult(() =>
Expand Down Expand Up @@ -47,7 +52,7 @@ export function activateCloudRelayAccount(

function CloudAuthBridge(props: { readonly children: ReactNode }) {
const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
const removeRelayEnvironments = useAtomCommand(environmentCatalog.removeRelayEnvironments, {
const removeRelayEnvironments = useAtomCommand(removeCloudEnvironments, {
reportFailure: false,
reportDefect: false,
});
Expand Down Expand Up @@ -81,32 +86,37 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
clearConnectOnboardingRequest();
}

const queueAccountCleanup = (
const cleanUpAccount = async (
previous: {
readonly userId: string;
readonly provider: () => Promise<string | null>;
} | null,
accountId: string | null,
) => {
const previousTransition = accountTransitionRef.current ?? Promise.resolve();
accountTransitionRef.current = previousTransition.then(async () => {
const cleanup = [
resetManagedRelayTokenCache(),
removeRelayEnvironments(),
...(previous
? [
settleAsyncResult(() =>
runtime.runPromiseExit(
unregisterAgentAwarenessDeviceForCurrentUser(previous.provider),
),
const removal = await removeRelayEnvironments(accountId);
if (removal._tag !== "Success") throw squashAtomCommandFailure(removal);
const cleanup = [
resetManagedRelayTokenCache(),
...(previous
? [
settleAsyncResult(() =>
runtime.runPromiseExit(
unregisterAgentAwarenessDeviceForCurrentUser(previous.provider),
),
]
: []),
];
const results = await Promise.all(cleanup);
for (const result of results) {
reportAtomCommandResult(result, { label: "cloud account cleanup" });
}
});
),
]
: []),
];
const results = await Promise.all(cleanup);
for (const result of results) {
reportAtomCommandResult(result, { label: "cloud account cleanup" });
}
};
const queueAccountCleanup = (previous: typeof previousTokenProviderRef.current) => {
const previousTransition = accountTransitionRef.current ?? Promise.resolve();
accountTransitionRef.current = previousTransition
.catch(() => {})
.then(() => cleanUpAccount(previous, previousObservedAccount ?? null));
return accountTransitionRef.current;
};

Expand All @@ -115,7 +125,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
previousTokenProviderRef.current = null;
deactivateCloudRelayAccount();
if (previousObservedAccount !== null) {
void queueAccountCleanup(previous);
void settlePromise(() => queueAccountCleanup(previous)).then((result) => {
reportAtomCommandResult(result, { label: "cloud account cleanup" });
});
}
return;
}
Expand All @@ -133,13 +145,21 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
}
};
const activateAfterTransition = (transition: Promise<void>) => {
void (async () => {
const result = await settlePromise(async () => {
await transition;
activateSession();
});
reportAtomCommandResult(result, { label: "cloud account activation" });
const activation = (async () => {
await transition;
if (cancelled) return;
const storedAccount = await getComposerCloudAccountId();
if (storedAccount !== null && storedAccount !== userId) {
await cleanUpAccount(null, storedAccount);
}
if (cancelled) return;
await restoreCloudComposerDrafts(userId);
activateSession();
})();
accountTransitionRef.current = activation;
void settlePromise(() => activation).then((result) => {
reportAtomCommandResult(result, { label: "cloud account activation" });
});
};
if (
previousObservedAccount !== undefined &&
Expand All @@ -150,7 +170,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
deactivateCloudRelayAccount();
activateAfterTransition(queueAccountCleanup(previous));
} else {
activateAfterTransition(accountTransitionRef.current ?? Promise.resolve());
// A failed disk write can be retried. The persisted account check above
// still requires cleanup before activating a different account.
activateAfterTransition((accountTransitionRef.current ?? Promise.resolve()).catch(() => {}));
}

return () => {
Expand Down
24 changes: 24 additions & 0 deletions apps/mobile/src/features/cloud/cloud-drafts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime";
import * as Effect from "effect/Effect";
import * as SubscriptionRef from "effect/SubscriptionRef";

import { connectionAtomRuntime } from "../../connection/runtime";
import { archiveCloudComposerDrafts } from "../../state/use-composer-drafts";

export const removeCloudEnvironments = createRuntimeCommand(connectionAtomRuntime, {
label: "cloud:preserve-drafts-and-remove-environments",
execute: Effect.fn(function* (accountId: string | null) {
const registry = yield* EnvironmentRegistry;
const entries = yield* SubscriptionRef.get(registry.entries);
const environmentIds = new Set(
[...entries.values()]
.filter((entry) => entry.target._tag === "RelayConnectionTarget")
.map((entry) => entry.target.environmentId),
);
// Credentials are already revoked. A failed backup must leave the local
// owners intact so a later sign-in can retry without losing their files.
yield* Effect.tryPromise(() => archiveCloudComposerDrafts(accountId, environmentIds));
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
yield* registry.removeRelayEnvironments();
}),
});
23 changes: 21 additions & 2 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useAtomValue } from "@effect/atom-react";
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import {
CommonActions,
Expand Down Expand Up @@ -35,6 +36,10 @@ import {
import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
import {
composerAttachmentUploadBlockReason,
composerAttachmentUploadsAtom,
} from "../../state/composer-attachment-uploads";
import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
import { ProviderIcon } from "../../components/ProviderIcon";
Expand Down Expand Up @@ -164,6 +169,16 @@ export function NewTaskDraftScreen(props: {
connectedEnvironments.find(
(environment) => environment.environmentId === selectedProject.environmentId,
)?.connectionState === "connected";
const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
const attachmentBlockReason = selectedProject
? composerAttachmentUploadBlockReason({
environmentId: selectedProject.environmentId,
attachments: flow.attachments,
connected: environmentConnected,
serverConfig: selectedEnvironmentServerConfig,
states: uploadStates,
})
: null;
const promptInputRef = useRef<ComposerEditorHandle>(null);
const loadedBranchesProjectKeyRef = useRef<string | null>(null);
const [isComposerFocused, setIsComposerFocused] = useState(false);
Expand Down Expand Up @@ -863,6 +878,7 @@ export function NewTaskDraftScreen(props: {
const initialMessageText = draft.text.trim();

if (
attachmentBlockReason !== null ||
!modelSelection ||
initialMessageText.length === 0 ||
flow.submitting ||
Expand Down Expand Up @@ -1017,6 +1033,7 @@ export function NewTaskDraftScreen(props: {

const isAndroid = Platform.OS === "android";
const canStart =
attachmentBlockReason === null &&
Boolean(flow.selectedProject) &&
Boolean(flow.selectedModel) &&
flow.prompt.trim().length > 0 &&
Expand Down Expand Up @@ -1216,6 +1233,7 @@ export function NewTaskDraftScreen(props: {
{flow.attachments.length > 0 ? (
<View className="px-[14px] pb-2.5">
<ComposerAttachmentStrip
environmentId={selectedProject.environmentId}
attachments={flow.attachments}
imageBorderRadius={16}
imageSize={72}
Expand Down Expand Up @@ -1317,11 +1335,12 @@ export function NewTaskDraftScreen(props: {
{voicePresentation.showsSend ? (
<ComposerActionButton
accessibilityLabel={
flow.submitting
attachmentBlockReason ??
(flow.submitting
? "Starting task"
: environmentConnected
? "Start task"
: "Queue task"
: "Queue task")
}
disabled={!canStart}
icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
Expand Down
Loading
Loading