Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import * as MobileStorage from "../persistence/mobile-storage";
import { appAtomRegistry } from "../state/atom-registry";
import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal";
import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";
import { clearThreadComposerErrorsForEnvironment } from "../state/thread-composer-error";
import { mobileApplicationActiveWakeup } from "./app-state-wakeups";
import { connectionStorageLayer } from "./storage";

Expand Down Expand Up @@ -264,6 +265,7 @@ const environmentOwnedDataCleanupLayer = Layer.succeed(
[
Effect.promise(() => clearThreadOutboxEnvironment(environmentId)),
Effect.promise(() => clearComposerDraftsEnvironment(environmentId)),
Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)),
],
{ concurrency: "unbounded", discard: true },
).pipe(
Expand Down
55 changes: 55 additions & 0 deletions apps/mobile/src/features/threads/ComposerErrorNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useEffect } from "react";
import { AccessibilityInfo, Platform, Pressable, View } from "react-native";

import { AppText as Text } from "../../components/AppText";
import { SymbolView } from "../../components/AppSymbol";

/** Why the thread's last message did not send, above the composer until dismissed. */
export function ComposerErrorNotice({
message,
onDismiss,
}: {
readonly message: string;
readonly onDismiss: () => void;
}) {
// accessibilityLiveRegion below only reaches TalkBack; VoiceOver needs an
// explicit announcement.
useEffect(() => {
if (Platform.OS === "ios") {
AccessibilityInfo.announceForAccessibility(message);
}
}, [message]);
return (
<View className="px-4 pb-3">
<View className="flex-row items-start gap-3 rounded-[20px] border-continuous bg-card p-4">
<SymbolView
name="exclamationmark.circle"
size={16}
tintColorClassName="accent-danger-foreground"
type="monochrome"
/>
<Text
selectable
accessibilityLiveRegion="polite"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
className="min-w-0 flex-1 text-sm text-foreground"
>
{message}
</Text>
<Pressable
accessibilityLabel="Dismiss error"
accessibilityRole="button"
hitSlop={12}
onPress={onDismiss}
className="p-1 active:opacity-60"
>
<SymbolView
name="xmark"
size={14}
tintColorClassName="accent-icon-muted"
type="monochrome"
/>
</Pressable>
</View>
</View>
);
}
19 changes: 19 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useAtomValue } from "@effect/atom-react";
import { useThreadReportedModelSelection } from "../../state/entities";
import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
import { useNavigation } from "@react-navigation/native";
Expand Down Expand Up @@ -93,6 +94,10 @@ import { useEnvironmentQuery } from "../../state/query";
import { threadDevicePreviews } from "../devices/threadDevicePreviews";
import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
import { scopedThreadKey } from "../../lib/scopedEntities";
import {
clearThreadComposerError,
threadComposerErrorsAtom,
} from "../../state/thread-composer-error";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import { useDelayedStatus } from "../../lib/useDelayedStatus";
Expand All @@ -104,6 +109,7 @@ import type {
ThreadFeedLatestRun,
} from "../../lib/threadActivity";
import { PendingApprovalCard } from "./PendingApprovalCard";
import { ComposerErrorNotice } from "./ComposerErrorNotice";
import { ComposerFeedback } from "./ComposerFeedback";
import { ComposerUsageLimits } from "./ComposerUsageLimits";
import { PendingUserInputCard } from "./PendingUserInputCard";
Expand Down Expand Up @@ -362,6 +368,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44;
const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null;
const queuedCount = useThreadQueuedCount({
environmentId: props.environmentId,
threadId: props.selectedThread.id,
Expand Down Expand Up @@ -1190,6 +1197,18 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onDismiss={() => props.onDismissFeedback(submission.id)}
/>
))}
{composerError !== null ? (
<Animated.View
className="shrink-0"
entering={FadeInDown.duration(180)}
exiting={FadeOut.duration(120)}
>
<ComposerErrorNotice
message={composerError}
onDismiss={() => clearThreadComposerError(selectedThreadKey)}
/>
</Animated.View>
) : null}
{usageLimitsReport && activeUserInputRequestId === null ? (
<Animated.View
className="shrink-0 px-4 pb-3"
Expand Down
39 changes: 39 additions & 0 deletions apps/mobile/src/state/thread-composer-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it } from "vite-plus/test";

import { appAtomRegistry } from "./atom-registry";
import {
clearThreadComposerError,
clearThreadComposerErrorsForEnvironment,
setThreadComposerError,
threadComposerErrorsAtom,
} from "./thread-composer-error";

afterEach(() => {
appAtomRegistry.set(threadComposerErrorsAtom, {});
});

describe("thread composer errors", () => {
it("clears a message-scoped error only for that message", () => {
setThreadComposerError("environment-1:thread-1", "rejected", "message-1");

clearThreadComposerError("environment-1:thread-1", "message-2");
expect(appAtomRegistry.get(threadComposerErrorsAtom)["environment-1:thread-1"]?.message).toBe(
"rejected",
);

clearThreadComposerError("environment-1:thread-1");
expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({});
});

it("clears every thread's error for a removed environment and no others", () => {
setThreadComposerError("environment-1:thread-1", "one");
setThreadComposerError("environment-1:thread-2", "two");
setThreadComposerError("environment-10:thread-1", "other environment");

clearThreadComposerErrorsForEnvironment("environment-1");

expect(Object.keys(appAtomRegistry.get(threadComposerErrorsAtom))).toEqual([
"environment-10:thread-1",
]);
});
});
56 changes: 56 additions & 0 deletions apps/mobile/src/state/thread-composer-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Atom } from "effect/unstable/reactivity";

import { appAtomRegistry } from "./atom-registry";

interface ThreadComposerError {
readonly message: string;
/** The queued message this error is about, when it is about one. */
readonly messageId: string | null;
}

/**
* Why a thread's last message did not go out, shown above that thread's
* composer. The outbox drain can reject a message after the user has left the
* thread, so the reason is kept per thread until they dismiss it, send again,
* or the message it describes is delivered after all. Keyed by `scopedThreadKey`.
*/
export const threadComposerErrorsAtom = Atom.make<Readonly<Record<string, ThreadComposerError>>>(
{},
).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer-errors"));

export function setThreadComposerError(
threadKey: string,
message: string,
messageId: string | null = null,
): void {
appAtomRegistry.set(threadComposerErrorsAtom, {
...appAtomRegistry.get(threadComposerErrorsAtom),
[threadKey]: { message, messageId },
});
}

/** With `messageId`, clears only an error that describes that message. */
export function clearThreadComposerError(threadKey: string, messageId?: string): void {
const current = appAtomRegistry.get(threadComposerErrorsAtom);
const error = current[threadKey];
if (!error || (messageId !== undefined && error.messageId !== messageId)) {
return;
}
const next = { ...current };
delete next[threadKey];
appAtomRegistry.set(threadComposerErrorsAtom, next);
}

export function clearThreadComposerErrorsForEnvironment(environmentId: string): void {
const current = appAtomRegistry.get(threadComposerErrorsAtom);
const prefix = `${environmentId}:`;
const keys = Object.keys(current).filter((key) => key.startsWith(prefix));
if (keys.length === 0) {
return;
}
const next = { ...current };
for (const key of keys) {
delete next[key];
}
appAtomRegistry.set(threadComposerErrorsAtom, next);
}
9 changes: 7 additions & 2 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import {
useQueuedRunEdit,
} from "./queued-run-edit";
import { setPendingConnectionError } from "../state/use-remote-environment-registry";
import { clearThreadComposerError, setThreadComposerError } from "./thread-composer-error";
import {
useSelectedThreadProjection,
useSelectedThreadVisibleTurnItems,
Expand Down Expand Up @@ -452,7 +453,8 @@ export function useThreadComposerState() {
});
}
endQueuedRunEdit(selectedThreadKey, { deferAttachmentCleanup: keepable });
setPendingConnectionError(
setThreadComposerError(
selectedThreadKey,
keepable
? "That message already started. Your edit is back in the composer."
: "That message already started, so the edit was discarded.",
Expand Down Expand Up @@ -670,6 +672,8 @@ export function useThreadComposerState() {

const metadata = makeQueuedMessageMetadata();
const messageId = MessageId.make(metadata.messageId);
// A new send supersedes the reason the previous one bounced back.
clearThreadComposerError(threadKey);
// Enqueue publishes the queued atom synchronously (the durable write
// happens behind it), so clearing the draft here gives send feedback on
// the tap frame instead of after file I/O. If the write fails the message
Expand Down Expand Up @@ -706,7 +710,8 @@ export function useThreadComposerState() {
attachments: [],
});
appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true });
setPendingConnectionError(
setThreadComposerError(
threadKey,
error instanceof Error ? error.message : "Failed to save the queued message.",
);
},
Expand Down
Loading
Loading