Skip to content

Commit 3396ffa

Browse files
committed
Merge remote-tracking branch 'github/main' into fix/preview-annotation-comment-clamp
2 parents 9a9e31a + 61c3794 commit 3396ffa

145 files changed

Lines changed: 7948 additions & 1781 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/mobile/src/features/cloud/cloud-drafts.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@ export const removeCloudEnvironments = createRuntimeCommand(connectionAtomRuntim
3030
);
3131
// Backup failure must retain local file owners so a later sign-in can retry.
3232
yield* Effect.tryPromise({
33-
try: () => archiveCloudComposerDrafts(accountId, environmentIds),
33+
// Resolve draft owners against every environment, not just the removed ones.
34+
try: () =>
35+
archiveCloudComposerDrafts(
36+
accountId,
37+
environmentIds,
38+
[...entries.values()].map((entry) => entry.target.environmentId),
39+
),
3440
catch: (cause) =>
3541
new CloudDraftArchiveError({
3642
environmentCount: environmentIds.size,

‎apps/mobile/src/features/threads/ThreadDetailScreen.tsx‎

Lines changed: 51 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ import {
197197
} from "./ThreadComposer";
198198
import { ComposerPopoverHost } from "./ComposerPopoverHost";
199199
import { ThreadFeed, type ThreadFeedHandle, type ThreadFeedHistoryControls } from "./ThreadFeed";
200+
import { WorkspaceMutationIdProvider } from "./WorkspaceMutationIdProvider";
200201
import { useThreadTurnSubagents } from "./ThreadAgentsSheet";
201202
import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit";
202203
import { useThreadQueuedCount } from "./ThreadQueueControl";
@@ -1437,54 +1438,56 @@ function ThreadDetailBody(
14371438
/>
14381439
)}
14391440
>
1440-
<ThreadFeed
1441-
environmentId={props.environmentId}
1442-
threadId={props.selectedThread.id}
1443-
detailRef={props.selectedThreadDetailRef}
1444-
workspaceRoot={props.threadCwd}
1445-
feed={props.selectedThreadFeed}
1446-
pendingHandoff={props.pendingHandoff}
1447-
worktreeSetup={props.worktreeSetup}
1448-
setupWorkingStartedAt={props.setupWorkingStartedAt}
1449-
queuedMessages={props.queuedMessages}
1450-
dispatchingMessageId={props.dispatchingMessageId}
1451-
// A native subagent has no composer to edit a pending message in;
1452-
// Cancel on the edit banner would discard it.
1453-
onEditPendingMessage={
1454-
isProviderSubagent || pendingMessageEditBlocked ? null : handleEditPendingMessage
1455-
}
1456-
contentPresentation={props.contentPresentation}
1457-
threadTitle={props.selectedThread.title}
1458-
latestRun={props.activityRun}
1459-
activeWorkStartedAt={props.activeWorkStartedAt}
1460-
runlessWorkActive={props.runlessWorkActive ?? false}
1461-
listRef={listRef}
1462-
freeze={freeze}
1463-
anchorMessageId={anchorMessageId}
1464-
jumpToMessageId={jumpMessageLoaded ? jumpMessageId : null}
1465-
onJumpedToMessage={() => finishThreadMessageJump(selectedThreadKey)}
1466-
ref={feedRef}
1467-
contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
1468-
keyboardOffset={composerKeyboardOffset}
1469-
sendingWithKeyboard={sendingWithKeyboard}
1470-
fitShortFeedToViewport={questionInFlow && !isKeyboardVisible}
1471-
contentTopInset={0}
1472-
contentBottomInset={
1473-
questionInFlow
1474-
? 0
1475-
: estimatedOverlayHeight +
1476-
(showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
1477-
}
1478-
contentMaxWidth={contentMaxWidth}
1479-
historyControls={props.historyControls}
1480-
contentSideInsets={nativeMetrics?.safeArea}
1481-
layoutVariant={layoutVariant}
1482-
usesAutomaticContentInsets={props.usesAutomaticContentInsets}
1483-
onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
1484-
onEndFollowEnabledChange={handleEndFollowEnabledChange}
1485-
skills={selectedProviderSkills}
1486-
onUseArtifactTemplate={handleUseArtifactTemplate}
1487-
/>
1441+
<WorkspaceMutationIdProvider threadRef={props.selectedThreadDetailRef}>
1442+
<ThreadFeed
1443+
environmentId={props.environmentId}
1444+
threadId={props.selectedThread.id}
1445+
detailRef={props.selectedThreadDetailRef}
1446+
workspaceRoot={props.threadCwd}
1447+
feed={props.selectedThreadFeed}
1448+
pendingHandoff={props.pendingHandoff}
1449+
worktreeSetup={props.worktreeSetup}
1450+
setupWorkingStartedAt={props.setupWorkingStartedAt}
1451+
queuedMessages={props.queuedMessages}
1452+
dispatchingMessageId={props.dispatchingMessageId}
1453+
// A native subagent has no composer to edit a pending message in;
1454+
// Cancel on the edit banner would discard it.
1455+
onEditPendingMessage={
1456+
isProviderSubagent || pendingMessageEditBlocked ? null : handleEditPendingMessage
1457+
}
1458+
contentPresentation={props.contentPresentation}
1459+
threadTitle={props.selectedThread.title}
1460+
latestRun={props.activityRun}
1461+
activeWorkStartedAt={props.activeWorkStartedAt}
1462+
runlessWorkActive={props.runlessWorkActive ?? false}
1463+
listRef={listRef}
1464+
freeze={freeze}
1465+
anchorMessageId={anchorMessageId}
1466+
jumpToMessageId={jumpMessageLoaded ? jumpMessageId : null}
1467+
onJumpedToMessage={() => finishThreadMessageJump(selectedThreadKey)}
1468+
ref={feedRef}
1469+
contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
1470+
keyboardOffset={composerKeyboardOffset}
1471+
sendingWithKeyboard={sendingWithKeyboard}
1472+
fitShortFeedToViewport={questionInFlow && !isKeyboardVisible}
1473+
contentTopInset={0}
1474+
contentBottomInset={
1475+
questionInFlow
1476+
? 0
1477+
: estimatedOverlayHeight +
1478+
(showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
1479+
}
1480+
contentMaxWidth={contentMaxWidth}
1481+
historyControls={props.historyControls}
1482+
contentSideInsets={nativeMetrics?.safeArea}
1483+
layoutVariant={layoutVariant}
1484+
usesAutomaticContentInsets={props.usesAutomaticContentInsets}
1485+
onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
1486+
onEndFollowEnabledChange={handleEndFollowEnabledChange}
1487+
skills={selectedProviderSkills}
1488+
onUseArtifactTemplate={handleUseArtifactTemplate}
1489+
/>
1490+
</WorkspaceMutationIdProvider>
14881491
</RenderErrorBoundary>
14891492
</View>
14901493
) : (

‎apps/mobile/src/features/threads/ThreadMarkdownImage.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { PresentationSource } from "../../components/ui/NativePresentation";
1919
import { useMediaActions } from "../../state/mediaActions";
2020
import type { MediaActionsSource } from "../../lib/mediaActionsSource";
2121
import { useAssetUrlState } from "../../state/assets";
22+
import { WorkspaceMutationIdContext } from "./workspaceMutationIdContext";
2223
import { assetImageCacheKey } from "../../lib/assetImageSource";
2324
import {
2425
MARKDOWN_IMAGE_MAX_WIDTH,
@@ -277,7 +278,12 @@ export function ThreadMarkdownImage(props: {
277278
readonly onPressPreview: (source: FilePreviewSource) => void;
278279
}) {
279280
const withGallery = useFilePreviewGallery();
280-
const assetUrl = useAssetUrlState(props.environmentId, props.resource);
281+
const workspaceMutationId = useContext(WorkspaceMutationIdContext);
282+
const assetUrl = useAssetUrlState(
283+
props.environmentId,
284+
props.resource,
285+
props.resource._tag === "media-file" ? workspaceMutationId : undefined,
286+
);
281287
const resourceKey =
282288
props.resource._tag === "attachment"
283289
? `attachment:${props.resource.attachmentId}`
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useAtomValue } from "@effect/atom-react";
2+
import type { ScopedThreadRef } from "@t3tools/contracts";
3+
import { Atom } from "effect/reactivity";
4+
import type { ReactNode } from "react";
5+
6+
import { environmentThreadDetails } from "../../state/threads";
7+
import { WorkspaceMutationIdContext } from "./workspaceMutationIdContext";
8+
9+
const NO_WORKSPACE_MUTATION_ID_ATOM = Atom.make<string | null | undefined>(undefined);
10+
11+
export function WorkspaceMutationIdProvider(props: {
12+
readonly threadRef: ScopedThreadRef | null;
13+
readonly children: ReactNode;
14+
}) {
15+
const workspaceMutationId = useAtomValue(
16+
props.threadRef === null
17+
? NO_WORKSPACE_MUTATION_ID_ATOM
18+
: environmentThreadDetails.workspaceMutationIdAtom(props.threadRef),
19+
);
20+
return (
21+
<WorkspaceMutationIdContext value={workspaceMutationId}>
22+
{props.children}
23+
</WorkspaceMutationIdContext>
24+
);
25+
}

‎apps/mobile/src/features/threads/thread-work-log.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -937,13 +937,14 @@ const ThreadWorkLogRow = function ThreadWorkLogRow(
937937
const warning = failureItem.failure.class === "usage_limit";
938938
const timestamp = new Date(row.createdAt);
939939
const resetAt = failureItem.failure.resetAt;
940+
// The reset time is the viewer's local time; naming the zone keeps it unambiguous.
940941
const resetTime = resetAt
941-
? new Date(resetAt).toLocaleString(undefined, {
942+
? `${new Date(resetAt).toLocaleString(undefined, {
942943
month: "short",
943944
day: "numeric",
944945
hour: "numeric",
945946
minute: "2-digit",
946-
})
947+
})} (${new Intl.DateTimeFormat().resolvedOptions().timeZone})`
947948
: null;
948949
const label = warning
949950
? `Usage limit reached.${resetTime ? ` Retry after ${resetTime}.` : ""}`
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { createContext } from "react";
2+
3+
/** The thread's workspace mutation id, so a host image the agent rewrites gets a fresh URL. */
4+
export const WorkspaceMutationIdContext = createContext<string | null | undefined>(undefined);

‎apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ describe("draft upload scope and offline submission", () => {
253253
const threadId = ThreadId.make("thread:1");
254254
const prefix = questionAttachmentDraftPrefix(environment, threadId);
255255
const key = questionAttachmentDraftKey(environment, threadId, requestId, "question:1");
256-
expect(composerDraftEnvironmentId(key, [])).toBe(environment);
256+
expect(composerDraftEnvironmentId(key, [environment], [])).toBe(environment);
257257
expect(key.startsWith(prefix)).toBe(true);
258258
for (const suffix of ["-extra", ":extra", "/extra", "%extra"]) {
259259
const otherKey = questionAttachmentDraftKey(
@@ -263,20 +263,34 @@ describe("draft upload scope and offline submission", () => {
263263
"question:1",
264264
);
265265
expect(otherKey.startsWith(prefix)).toBe(false);
266-
expect(composerDraftEnvironmentId(otherKey, [])).toBe(environment);
266+
expect(composerDraftEnvironmentId(otherKey, [environment], [])).toBe(environment);
267267
}
268268
}
269269
});
270270
it("resolves thread, new-task, and queued-task drafts without crossing environments", () => {
271-
expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId);
272-
expect(composerDraftEnvironmentId("new-task:environment-1:project", [])).toBe(environmentId);
271+
expect(composerDraftEnvironmentId("environment-1:thread", [environmentId], [])).toBe(
272+
environmentId,
273+
);
274+
expect(composerDraftEnvironmentId("new-task:environment-1:project", [], [])).toBe(
275+
environmentId,
276+
);
273277
expect(
274-
composerDraftEnvironmentId("pending-task:message", [{ messageId: "message", environmentId }]),
278+
composerDraftEnvironmentId(
279+
"pending-task:message",
280+
[],
281+
[{ messageId: "message", environmentId }],
282+
),
275283
).toBe(environmentId);
276-
expect(composerDraftEnvironmentId("pending-task:missing", [])).toBeNull();
284+
expect(composerDraftEnvironmentId("pending-task:missing", [], [])).toBeNull();
277285
const colonEnvironment = EnvironmentId.make("a:vcs-status:b");
278-
expect(composerDraftEnvironmentId(`${colonEnvironment}:thread`, [])).toBe(colonEnvironment);
279-
expect(composerDraftEnvironmentId(`new-task:${colonEnvironment}:project`, [])).toBe(
286+
expect(
287+
composerDraftEnvironmentId(
288+
`${colonEnvironment}:thread`,
289+
[EnvironmentId.make("a"), colonEnvironment],
290+
[],
291+
),
292+
).toBe(colonEnvironment);
293+
expect(composerDraftEnvironmentId(`new-task:${colonEnvironment}:project`, [], [])).toBe(
280294
colonEnvironment,
281295
);
282296
});
@@ -289,9 +303,11 @@ describe("draft upload scope and offline submission", () => {
289303
createdAt: "2026-09-05T00:00:00.000Z",
290304
},
291305
};
292-
expect(composerDraftEnvironmentId("new-task:abc123-def456", [], stamped)).toBe(environmentId);
293-
expect(composerDraftEnvironmentId("new-task:abc123-def456", [])).toBeNull();
294-
expect(composerDraftEnvironmentId("new-task:environment-2:project", [], stamped)).toBe(
306+
expect(composerDraftEnvironmentId("new-task:abc123-def456", [], [], stamped)).toBe(
307+
environmentId,
308+
);
309+
expect(composerDraftEnvironmentId("new-task:abc123-def456", [], [])).toBeNull();
310+
expect(composerDraftEnvironmentId("new-task:environment-2:project", [], [], stamped)).toBe(
295311
environmentId,
296312
);
297313
});

‎apps/mobile/src/lib/composerAttachmentUploadQueue.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ export function composerAttachmentUploadKey(
2121
return `${environmentId}:${attachmentId}`;
2222
}
2323

24+
/** Environment and thread ids may both contain colons, so a thread draft key resolves to the longest known environment it starts with. */
2425
export function composerDraftEnvironmentId(
2526
draftKey: string,
27+
environmentIds: Iterable<EnvironmentId>,
2628
queuedMessages: ReadonlyArray<{
2729
readonly messageId: string;
2830
readonly environmentId: EnvironmentId;
@@ -42,8 +44,16 @@ export function composerDraftEnvironmentId(
4244
const legacy = parseLegacyNewTaskDraftKey(draftKey);
4345
return legacy === null ? null : EnvironmentId.make(legacy.environmentId);
4446
}
45-
const separator = draftKey.lastIndexOf(":");
46-
return separator > 0 ? EnvironmentId.make(draftKey.slice(0, separator)) : null;
47+
let match: EnvironmentId | null = null;
48+
for (const environmentId of environmentIds) {
49+
if (
50+
draftKey.startsWith(`${environmentId}:`) &&
51+
(match === null || environmentId.length > match.length)
52+
) {
53+
match = environmentId;
54+
}
55+
}
56+
return match;
4757
}
4858

4959
type UploadServerConfig = {

‎apps/mobile/src/lib/composerContextClipboard.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,15 @@ vi.mock("expo-file-system", () => ({
2929
}));
3030
vi.mock("../state/atom-registry", () => ({
3131
appAtomRegistry: {
32-
get: () => ({ _tag: "Some", value: { httpBaseUrl: "https://source.example" } }),
32+
get: (atom: unknown) =>
33+
atom === "environments"
34+
? []
35+
: { _tag: "Some", value: { httpBaseUrl: "https://source.example" } },
3336
},
3437
}));
38+
vi.mock("../state/workspace", () => ({
39+
workspaceConnections: { environmentsAtom: "environments" },
40+
}));
3541
vi.mock("../state/session", () => ({
3642
environmentSession: { preparedConnectionValueAtom: vi.fn() },
3743
}));

‎apps/mobile/src/lib/composerContextClipboard.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import * as Option from "effect/Option";
2020
import { appAtomRegistry } from "../state/atom-registry";
2121
import { assetEnvironment } from "../state/assets";
2222
import { environmentSession } from "../state/session";
23+
import { workspaceConnections } from "../state/workspace";
2324
import { downloadAttachmentForPreview } from "./attachmentDownload";
2425
import {
2526
persistComposerAttachmentFile,
@@ -137,7 +138,13 @@ async function importAttachment(
137138
): Promise<DraftComposerAttachment> {
138139
await waitForComposerDraftsLoaded();
139140
checkAborted(signal);
140-
const local = findLocalComposerClipboardAttachment(environmentId, record.attachmentId);
141+
const local = findLocalComposerClipboardAttachment(
142+
appAtomRegistry
143+
.get(workspaceConnections.environmentsAtom)
144+
.map((environment) => environment.environmentId),
145+
environmentId,
146+
record.attachmentId,
147+
);
141148
if (local) {
142149
if (!local.fileUri && local.type === "image" && local.dataUrl)
143150
return {

0 commit comments

Comments
 (0)