From 64fafbdfcf91d3c44b533f5b8febbe8d6b19d482 Mon Sep 17 00:00:00 2001
From: Akshar Patel <123344143+AksharP5@users.noreply.github.com>
Date: Sun, 6 Sep 2026 13:09:26 -0400
Subject: [PATCH 01/71] perf(web): speed up folder menu sorting (#10190)
---
apps/web/src/components/files/filePath.test.ts | 11 +++++++++--
apps/web/src/components/files/filePath.ts | 4 +++-
2 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts
index 3018aa91a776..b501562fee00 100644
--- a/apps/web/src/components/files/filePath.test.ts
+++ b/apps/web/src/components/files/filePath.test.ts
@@ -67,8 +67,15 @@ describe("fileBreadcrumbChildren", () => {
]);
});
- it("uses natural file-name ordering", () => {
- expect(fileBreadcrumbChildren(entries, "src/lib").map((entry) => entry.label)).toEqual([
+ it("uses natural file-name ordering and preserves input order for equivalent names", () => {
+ const files = ["file10.ts", "File2.ts", "file02.ts", "file2.ts"].map((name) => ({
+ path: `src/lib/${name}`,
+ kind: "file" as const,
+ }));
+
+ expect(fileBreadcrumbChildren(files, "src/lib").map((entry) => entry.label)).toEqual([
+ "File2.ts",
+ "file02.ts",
"file2.ts",
"file10.ts",
]);
diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts
index aea8266ec8b8..e819315310b8 100644
--- a/apps/web/src/components/files/filePath.ts
+++ b/apps/web/src/components/files/filePath.ts
@@ -36,6 +36,7 @@ export function fileBreadcrumbChildren(
entries: readonly ProjectEntry[],
directoryPath: string,
): FileBreadcrumbChild[] {
+ let collator: Intl.Collator | undefined;
const prefix = directoryPath ? `${directoryPath}/` : "";
return entries
.flatMap((entry) => {
@@ -46,10 +47,11 @@ export function fileBreadcrumbChildren(
})
.toSorted((left, right) => {
if (left.kind !== right.kind) return left.kind === "directory" ? -1 : 1;
- return left.label.localeCompare(right.label, undefined, {
+ collator ??= new Intl.Collator(undefined, {
numeric: true,
sensitivity: "base",
});
+ return collator.compare(left.label, right.label);
});
}
From 29d03ec556e94d5f847644730daf75ba4aa20678 Mon Sep 17 00:00:00 2001
From: Exotic <118054752+extoci@users.noreply.github.com>
Date: Sun, 6 Sep 2026 20:19:01 +0300
Subject: [PATCH 02/71] style(web): fix inconsistencies in new settings layouts
(#10177)
Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>
---
apps/web/src/components/settings/ConnectionsSettings.tsx | 7 +++++--
apps/web/src/components/settings/SourceControlSettings.tsx | 4 ++--
apps/web/src/components/settings/itemRows.ts | 4 ++--
3 files changed, 9 insertions(+), 6 deletions(-)
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
index b5de1e124801..ad7665651171 100644
--- a/apps/web/src/components/settings/ConnectionsSettings.tsx
+++ b/apps/web/src/components/settings/ConnectionsSettings.tsx
@@ -395,7 +395,7 @@ function formatDesktopSshConnectionError(error: unknown): string {
return withoutTaggedErrorPrefix.trim() || fallback;
}
-const ENDPOINT_ROW_CLASSNAME = "rounded-xl px-3 py-2.5 sm:px-4";
+const ENDPOINT_ROW_CLASSNAME = "first:rounded-t-xl last:rounded-b-xl px-3 py-2.5 sm:px-4";
type AccessSectionPresentation = "current" | "endpoint-rail";
@@ -405,7 +405,10 @@ function accessRowClassName(_presentation: AccessSectionPresentation) {
function endpointRowClassName(presentation: AccessSectionPresentation, isAvailable: boolean) {
if (presentation === "endpoint-rail") {
- return cn("relative rounded-xl px-3 py-3 sm:px-4", !isAvailable && "bg-muted/15");
+ return cn(
+ "relative first:rounded-t-xl last:rounded-b-xl px-3 py-3 sm:px-4",
+ !isAvailable && "bg-muted/15",
+ );
}
return cn(ENDPOINT_ROW_CLASSNAME, !isAvailable && "bg-muted/24");
diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx
index 736b7f1b4b99..6d9d20105224 100644
--- a/apps/web/src/components/settings/SourceControlSettings.tsx
+++ b/apps/web/src/components/settings/SourceControlSettings.tsx
@@ -282,7 +282,7 @@ function DiscoveryItemRow({
return (
@@ -432,7 +432,7 @@ function SourceControlSectionSkeleton({
return (
{SOURCE_CONTROL_SKELETON_ROWS.map((row) => (
-
+
diff --git a/apps/web/src/components/settings/itemRows.ts b/apps/web/src/components/settings/itemRows.ts
index e207c9ff7a78..0bad52bcb033 100644
--- a/apps/web/src/components/settings/itemRows.ts
+++ b/apps/web/src/components/settings/itemRows.ts
@@ -1,5 +1,5 @@
-/** Direct row in a settings section. Whitespace, rather than rules, separates peers. */
-export const ITEM_ROW_CLASSNAME = "rounded-xl px-3 py-3 sm:px-4";
+/** Direct row in a grouped settings section. Round only outer corners; the parent owns borders and separators. */
+export const ITEM_ROW_CLASSNAME = "first:rounded-t-xl last:rounded-b-xl px-3 py-3 sm:px-4";
export const ITEM_ROW_INNER_CLASSNAME =
"flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between";
From 2d645df474f0af5e261edb56a79f7a2a9a2d442e Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 6 Sep 2026 11:10:13 -0700
Subject: [PATCH 03/71] feat(threads): persist manual active thread order
(#9729)
---
.../src/environment/ServerEnvironment.test.ts | 1 +
.../src/environment/ServerEnvironment.ts | 1 +
.../Layers/ProjectionPipeline.test.ts | 58 ++++-
.../Layers/ProjectionPipeline.ts | 5 +
.../Layers/ProjectionSnapshotQuery.test.ts | 6 +
.../Layers/ProjectionSnapshotQuery.ts | 10 +
.../decider.active-order.test.ts | 200 ++++++++++++++++++
apps/server/src/orchestration/decider.ts | 36 ++++
.../src/orchestration/projector.test.ts | 1 +
apps/server/src/orchestration/projector.ts | 5 +
.../persistence/Layers/ProjectionThreads.ts | 5 +
apps/server/src/persistence/Migrations.ts | 2 +
...49_ProjectionThreadsActiveOrderKey.test.ts | 44 ++++
.../049_ProjectionThreadsActiveOrderKey.ts | 15 ++
.../persistence/Services/ProjectionThreads.ts | 1 +
.../src/operations/commands.test.ts | 21 ++
.../client-runtime/src/operations/commands.ts | 11 +
.../client-runtime/src/state/entities.test.ts | 6 +
.../src/state/threadCommands.ts | 9 +
.../client-runtime/src/state/threadDetail.ts | 2 +
.../src/state/threadReducer.test.ts | 113 ++++++----
.../client-runtime/src/state/threadReducer.ts | 5 +
.../src/state/threadSort.test.ts | 176 +++++++++++++++
.../client-runtime/src/state/threadSort.ts | 75 +++++--
packages/contracts/src/environment.ts | 2 +
packages/contracts/src/orchestration.test.ts | 55 +++++
packages/contracts/src/orchestration.ts | 16 ++
27 files changed, 824 insertions(+), 57 deletions(-)
create mode 100644 apps/server/src/orchestration/decider.active-order.test.ts
create mode 100644 apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts
create mode 100644 apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.ts
diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts
index 141aa405af21..a674a25c1ec8 100644
--- a/apps/server/src/environment/ServerEnvironment.test.ts
+++ b/apps/server/src/environment/ServerEnvironment.test.ts
@@ -167,6 +167,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
expect(second.capabilities.pullRequests).toBe(true);
expect(second.capabilities.usagePriceOverrides).toBe(true);
+ expect(second.capabilities.threadActiveReorder).toBe(true);
expect(second.capabilities.threadTitleRegeneration).toBe(true);
expect(second.capabilities.threadPullRequestLinking).toBe(true);
expect(second.capabilities.agentActivityPublishing).toBe(false);
diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts
index eab723d7909a..0b7f1761cf76 100644
--- a/apps/server/src/environment/ServerEnvironment.ts
+++ b/apps/server/src/environment/ServerEnvironment.ts
@@ -226,6 +226,7 @@ export const make = Effect.gen(function* () {
usagePriceOverrides: true,
threadPinning: true,
threadPinReorder: true,
+ threadActiveReorder: true,
threadTitleRegeneration: true,
threadPullRequestLinking: true,
environmentIcon: true,
diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
index f619f6a93916..1a989958f50c 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
@@ -488,6 +488,48 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
yield* sql`DROP TRIGGER count_thread_shell_updates`;
yield* sql`DROP TABLE thread_shell_updates`;
+ // Replayed order events must survive later lifecycle upserts, whose
+ // complete SQL row writes otherwise risk dropping the placement.
+ const orderUpdatedAt = "2026-01-01T00:00:00.200Z";
+ const orderEvents = [
+ { type: "thread.meta-updated", payload: { activeOrderKey: "gm" } },
+ { type: "thread.pinned", payload: { pinnedAt: now, pinOrderKey: "m" } },
+ {
+ type: "thread.snoozed",
+ payload: { snoozedAt: now, snoozedUntil: "2026-01-02T00:00:00.000Z" },
+ },
+ { type: "thread.unsnoozed", payload: { reason: "user" } },
+ { type: "thread.unpinned", payload: {} },
+ { type: "thread.meta-updated", payload: { title: "Renamed" } },
+ ] as const;
+ for (const [index, event] of orderEvents.entries()) {
+ yield* eventStore.append({
+ type: event.type,
+ eventId: EventId.make(`evt-active-order-${index}`),
+ aggregateKind: "thread",
+ aggregateId: ThreadId.make("thread-1"),
+ occurredAt: "2026-01-01T00:00:00.500Z",
+ commandId: CommandId.make(`cmd-active-order-${index}`),
+ causationEventId: null,
+ correlationId: null,
+ metadata: {},
+ payload: {
+ ...event.payload,
+ threadId: ThreadId.make("thread-1"),
+ updatedAt: orderUpdatedAt,
+ },
+ });
+ yield* projectionPipeline.bootstrap;
+ const rows = yield* sql<{
+ readonly activeOrderKey: string | null;
+ readonly updatedAt: string;
+ }>`
+ SELECT active_order_key AS "activeOrderKey", updated_at AS "updatedAt"
+ FROM projection_threads WHERE thread_id = 'thread-1'
+ `;
+ assert.deepEqual(rows, [{ activeOrderKey: "gm", updatedAt: orderUpdatedAt }]);
+ }
+
// Settled lifecycle through the DB pipeline: thread.settled writes the
// override + timestamp, thread.unsettled(user) flips to the active pin.
yield* eventStore.append({
@@ -512,16 +554,23 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
readonly settledOverride: string | null;
readonly settledAt: string | null;
readonly unsettledAt: string | null;
+ readonly activeOrderKey: string | null;
}>`
SELECT
settled_override AS "settledOverride",
settled_at AS "settledAt",
- unsettled_at AS "unsettledAt"
+ unsettled_at AS "unsettledAt",
+ active_order_key AS "activeOrderKey"
FROM projection_threads
WHERE thread_id = 'thread-1'
`;
assert.deepEqual(settledRows, [
- { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z", unsettledAt: null },
+ {
+ settledOverride: "settled",
+ settledAt: "2026-01-01T00:00:01.000Z",
+ unsettledAt: null,
+ activeOrderKey: null,
+ },
]);
yield* eventStore.append({
@@ -546,11 +595,13 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
readonly settledOverride: string | null;
readonly settledAt: string | null;
readonly unsettledAt: string | null;
+ readonly activeOrderKey: string | null;
}>`
SELECT
settled_override AS "settledOverride",
settled_at AS "settledAt",
- unsettled_at AS "unsettledAt"
+ unsettled_at AS "unsettledAt",
+ active_order_key AS "activeOrderKey"
FROM projection_threads
WHERE thread_id = 'thread-1'
`;
@@ -561,6 +612,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
settledOverride: "active",
settledAt: null,
unsettledAt: "2026-01-01T00:00:02.000Z",
+ activeOrderKey: null,
},
]);
}),
diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
index bcef68170a49..050ad1a902ae 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
@@ -617,6 +617,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
snoozedAt: null,
pinnedAt: null,
pinOrderKey: null,
+ activeOrderKey: null,
titleRegenerationRequestId: null,
titleRegenerationStartedAt: null,
latestUserMessageAt: null,
@@ -671,6 +672,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
settledOverride: "settled",
settledAt: event.payload.settledAt,
unsettledAt: null,
+ activeOrderKey: null,
updatedAt: event.payload.updatedAt,
});
return;
@@ -790,6 +792,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
yield* projectionThreadRepository.upsert({
...existingRow.value,
...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
+ ...(event.payload.activeOrderKey !== undefined
+ ? { activeOrderKey: event.payload.activeOrderKey }
+ : {}),
...(event.payload.titleRegeneration !== undefined
? {
titleRegenerationRequestId: event.payload.titleRegeneration?.requestId ?? null,
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
index 08a07c0be75b..e262bce34aaf 100644
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
@@ -110,6 +110,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
has_actionable_proposed_plan,
pinned_at,
pin_order_key,
+ active_order_key,
created_at,
updated_at,
deleted_at
@@ -132,6 +133,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
0,
'2026-02-24T00:00:01.000Z',
'gm',
+ 'hq',
'2026-02-24T00:00:02.000Z',
'2026-02-24T00:00:03.000Z',
NULL
@@ -358,6 +360,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
snoozedAt: null,
pinnedAt: "2026-02-24T00:00:01.000Z",
pinOrderKey: "gm",
+ activeOrderKey: "hq",
titleRegeneration: null,
deletedAt: null,
messages: [
@@ -487,6 +490,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
snoozedAt: null,
pinnedAt: "2026-02-24T00:00:01.000Z",
pinOrderKey: "gm",
+ activeOrderKey: "hq",
titleRegeneration: null,
session: {
threadId: ThreadId.make("thread-1"),
@@ -513,6 +517,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
}
const commandSnapshot = yield* snapshotQuery.getCommandReadModel();
+ assert.equal(commandSnapshot.threads[0]?.activeOrderKey, "hq");
assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest);
const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1"));
assert.equal(threadShell._tag, "Some");
@@ -560,6 +565,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
);
assert.equal(detailWithoutActivities._tag, "Some");
if (detailWithoutActivities._tag === "Some") {
+ assert.equal(detailWithoutActivities.value.activeOrderKey, "hq");
assert.deepEqual(detailWithoutActivities.value.activities, []);
assert.deepEqual(detailWithoutActivities.value.messages, snapshot.threads[0]?.messages);
assert.deepEqual(
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
index b15b72ee5673..5f82a26e2a36 100644
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
@@ -513,6 +513,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
pinned_at AS "pinnedAt",
pin_order_key AS "pinOrderKey",
+ active_order_key AS "activeOrderKey",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
latest_user_message_at AS "latestUserMessageAt",
@@ -552,6 +553,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
pinned_at AS "pinnedAt",
pin_order_key AS "pinOrderKey",
+ active_order_key AS "activeOrderKey",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
latest_user_message_at AS "latestUserMessageAt",
@@ -593,6 +595,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
pinned_at AS "pinnedAt",
pin_order_key AS "pinOrderKey",
+ active_order_key AS "activeOrderKey",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
latest_user_message_at AS "latestUserMessageAt",
@@ -1083,6 +1086,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
pinned_at AS "pinnedAt",
pin_order_key AS "pinOrderKey",
+ active_order_key AS "activeOrderKey",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
latest_user_message_at AS "latestUserMessageAt",
@@ -2082,6 +2086,7 @@ pending_approval_requests AS (
snoozedAt: row.snoozedAt,
pinnedAt: row.pinnedAt,
pinOrderKey: row.pinOrderKey ?? null,
+ activeOrderKey: row.activeOrderKey ?? null,
titleRegeneration: mapTitleRegeneration(row),
deletedAt: row.deletedAt,
messages: messagesByThread.get(row.threadId) ?? [],
@@ -2296,6 +2301,7 @@ pending_approval_requests AS (
snoozedAt: row.snoozedAt,
pinnedAt: row.pinnedAt,
pinOrderKey: row.pinOrderKey ?? null,
+ activeOrderKey: row.activeOrderKey ?? null,
titleRegeneration: mapTitleRegeneration(row),
deletedAt: row.deletedAt,
messages: [],
@@ -2437,6 +2443,7 @@ pending_approval_requests AS (
snoozedAt: row.snoozedAt,
pinnedAt: row.pinnedAt,
pinOrderKey: row.pinOrderKey ?? null,
+ activeOrderKey: row.activeOrderKey ?? null,
titleRegeneration: mapTitleRegeneration(row),
session: sessionByThread.get(row.threadId) ?? null,
latestUserMessageAt: row.latestUserMessageAt,
@@ -2586,6 +2593,7 @@ pending_approval_requests AS (
snoozedAt: row.snoozedAt,
pinnedAt: row.pinnedAt,
pinOrderKey: row.pinOrderKey ?? null,
+ activeOrderKey: row.activeOrderKey ?? null,
titleRegeneration: mapTitleRegeneration(row),
session: sessionByThread.get(row.threadId) ?? null,
latestUserMessageAt: row.latestUserMessageAt,
@@ -2908,6 +2916,7 @@ pending_approval_requests AS (
snoozedAt: threadRow.value.snoozedAt,
pinnedAt: threadRow.value.pinnedAt,
pinOrderKey: threadRow.value.pinOrderKey ?? null,
+ activeOrderKey: threadRow.value.activeOrderKey ?? null,
titleRegeneration: mapTitleRegeneration(threadRow.value),
session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
latestUserMessageAt: threadRow.value.latestUserMessageAt,
@@ -3190,6 +3199,7 @@ pending_approval_requests AS (
snoozedAt: threadRow.value.snoozedAt,
pinnedAt: threadRow.value.pinnedAt,
pinOrderKey: threadRow.value.pinOrderKey ?? null,
+ activeOrderKey: threadRow.value.activeOrderKey ?? null,
titleRegeneration: mapTitleRegeneration(threadRow.value),
deletedAt: null,
messages: messageRows.map((row) => {
diff --git a/apps/server/src/orchestration/decider.active-order.test.ts b/apps/server/src/orchestration/decider.active-order.test.ts
new file mode 100644
index 000000000000..58a7f5c054ec
--- /dev/null
+++ b/apps/server/src/orchestration/decider.active-order.test.ts
@@ -0,0 +1,200 @@
+import {
+ CommandId,
+ ProjectId,
+ ProviderInstanceId,
+ ThreadId,
+ type OrchestrationCommand,
+ type OrchestrationReadModel,
+ type OrchestrationThread,
+} from "@t3tools/contracts";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+
+import { decideOrchestrationCommand } from "./decider.ts";
+import { projectEvent } from "./projector.ts";
+
+const NOW = "2026-01-01T00:00:00.000Z";
+// The Effect test clock starts at the epoch.
+const BEFORE_NOW = "1969-12-30T00:00:00.000Z";
+const SNOOZED_AT = "1969-12-31T00:00:00.000Z";
+const FUTURE_WAKE = "1970-01-02T00:00:00.000Z";
+const THREAD_ID = ThreadId.make("thread-1");
+
+function makeReadModel(overrides: Partial = {}): OrchestrationReadModel {
+ return {
+ snapshotSequence: 0,
+ projects: [],
+ threads: [
+ {
+ id: THREAD_ID,
+ projectId: ProjectId.make("project-1"),
+ title: "Thread",
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ latestTurn: null,
+ createdAt: NOW,
+ updatedAt: NOW,
+ archivedAt: null,
+ settledOverride: null,
+ settledAt: null,
+ unsettledAt: null,
+ activeOrderKey: null,
+ snoozedUntil: null,
+ snoozedAt: null,
+ pinnedAt: null,
+ pinOrderKey: null,
+ deletedAt: null,
+ messages: [],
+ proposedPlans: [],
+ activities: [],
+ checkpoints: [],
+ session: null,
+ ...overrides,
+ },
+ ],
+ updatedAt: NOW,
+ };
+}
+
+const reorderCommand = {
+ type: "thread.active.reorder",
+ commandId: CommandId.make("cmd-active-reorder"),
+ threadId: THREAD_ID,
+ orderKey: "m",
+} as const;
+
+it.layer(NodeServices.layer)("active thread ordering", (it) => {
+ it.effect("persists changed and repeated slots without changing thread activity timestamps", () =>
+ Effect.gen(function* () {
+ let readModel = makeReadModel({ unsettledAt: BEFORE_NOW });
+ for (const orderKey of ["m", "m", "g"]) {
+ const decided = yield* decideOrchestrationCommand({
+ command: { ...reorderCommand, orderKey },
+ readModel,
+ });
+ const events = Array.isArray(decided) ? decided : [decided];
+ expect(events).toHaveLength(1);
+ expect(events[0]).toMatchObject({
+ type: "thread.meta-updated",
+ payload: { threadId: THREAD_ID, activeOrderKey: orderKey, updatedAt: NOW },
+ });
+ for (const event of events) {
+ readModel = yield* projectEvent(readModel, {
+ ...event,
+ sequence: readModel.snapshotSequence + 1,
+ });
+ }
+ expect(readModel.threads[0]).toMatchObject({
+ activeOrderKey: orderKey,
+ updatedAt: NOW,
+ createdAt: NOW,
+ unsettledAt: BEFORE_NOW,
+ });
+ }
+ }),
+ );
+
+ for (const [label, overrides] of [
+ ["archived", { archivedAt: NOW }],
+ ["deleted", { deletedAt: NOW }],
+ ["pinned", { pinnedAt: NOW }],
+ ["settled", { settledOverride: "settled", settledAt: NOW }],
+ ] satisfies ReadonlyArray]>) {
+ it.effect(`rejects reordering a ${label} thread`, () =>
+ Effect.gen(function* () {
+ const error = yield* decideOrchestrationCommand({
+ command: reorderCommand,
+ readModel: makeReadModel(overrides),
+ }).pipe(Effect.flip);
+ expect(error._tag).toBe("OrchestrationCommandInvariantError");
+ }),
+ );
+ }
+
+ it.effect("reorders a running thread without affecting its session", () =>
+ Effect.gen(function* () {
+ const readModel = makeReadModel({
+ session: {
+ threadId: THREAD_ID,
+ status: "running",
+ providerName: "codex",
+ runtimeMode: "full-access",
+ activeTurnId: null,
+ lastError: null,
+ updatedAt: NOW,
+ },
+ });
+ const decided = yield* decideOrchestrationCommand({ command: reorderCommand, readModel });
+ const events = Array.isArray(decided) ? decided : [decided];
+ expect(events).toHaveLength(1);
+ for (const event of events) {
+ const projected = yield* projectEvent(readModel, { ...event, sequence: 1 });
+ expect(projected.threads[0]).toEqual({ ...readModel.threads[0], activeOrderKey: "m" });
+ }
+ }),
+ );
+
+ it.effect(
+ "changes a snoozed thread's retained slot without waking it or changing timestamps",
+ () =>
+ Effect.gen(function* () {
+ const readModel = makeReadModel({
+ activeOrderKey: "g",
+ snoozedAt: SNOOZED_AT,
+ snoozedUntil: FUTURE_WAKE,
+ unsettledAt: BEFORE_NOW,
+ });
+ const decided = yield* decideOrchestrationCommand({ command: reorderCommand, readModel });
+ const events = Array.isArray(decided) ? decided : [decided];
+ expect(events).toHaveLength(1);
+ for (const event of events) {
+ const projected = yield* projectEvent(readModel, { ...event, sequence: 1 });
+ expect(projected.threads[0]).toEqual({ ...readModel.threads[0], activeOrderKey: "m" });
+ }
+ }),
+ );
+
+ it.effect("keeps placement through metadata, pin and snooze, then resets it on settlement", () =>
+ Effect.gen(function* () {
+ let readModel = makeReadModel();
+ const steps = [
+ [reorderCommand, "m"],
+ [{ type: "thread.meta.update", title: "Renamed" }, "m"],
+ [{ type: "thread.pin", orderKey: "g" }, "m"],
+ [{ type: "thread.snooze", snoozedUntil: FUTURE_WAKE }, "m"],
+ [{ type: "thread.unsnooze", reason: "user" }, "m"],
+ [{ type: "thread.unpin" }, "m"],
+ [{ type: "thread.settle" }, null],
+ [{ type: "thread.unsettle", reason: "user" }, null],
+ [{ type: "thread.active.reorder", orderKey: "s" }, "s"],
+ ] as const;
+ for (const [index, [step, expectedKey]] of steps.entries()) {
+ const command: OrchestrationCommand = {
+ ...step,
+ commandId: CommandId.make(`lifecycle-${index}`),
+ threadId: THREAD_ID,
+ };
+ const decided = yield* decideOrchestrationCommand({ command, readModel });
+ const events = Array.isArray(decided) ? decided : [decided];
+ for (const event of events) {
+ readModel = yield* projectEvent(readModel, {
+ ...event,
+ sequence: readModel.snapshotSequence + 1,
+ });
+ }
+ expect(readModel.threads[0]?.activeOrderKey, command.type).toBe(expectedKey);
+ }
+ expect(readModel.threads[0]).toMatchObject({
+ title: "Renamed",
+ settledOverride: "active",
+ settledAt: null,
+ snoozedUntil: null,
+ pinnedAt: null,
+ });
+ }),
+ );
+});
diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts
index a20dba468c65..9dc55e1194cc 100644
--- a/apps/server/src/orchestration/decider.ts
+++ b/apps/server/src/orchestration/decider.ts
@@ -788,6 +788,42 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
};
}
+ case "thread.active.reorder": {
+ const thread = yield* requireThreadNotArchived({
+ readModel,
+ command,
+ threadId: command.threadId,
+ });
+ const occurredAt = yield* nowIso;
+ // Snooze retains this slot. Changing it cannot wake the thread, and
+ // accepting it handles races with snooze and retained wake timestamps.
+ if (
+ thread.deletedAt !== null ||
+ thread.pinnedAt != null ||
+ thread.settledOverride === "settled"
+ ) {
+ return yield* new OrchestrationCommandInvariantError({
+ commandType: command.type,
+ detail: `thread ${command.threadId} is not active and cannot be reordered`,
+ });
+ }
+ return {
+ ...(yield* withEventBase({
+ aggregateKind: "thread",
+ aggregateId: command.threadId,
+ occurredAt,
+ commandId: command.commandId,
+ })),
+ type: "thread.meta-updated",
+ payload: {
+ threadId: command.threadId,
+ activeOrderKey: command.orderKey,
+ // Arranging the list is not thread activity or a lifecycle transition.
+ updatedAt: thread.updatedAt,
+ },
+ };
+ }
+
case "thread.meta.update": {
const thread = yield* requireThread({
readModel,
diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts
index d4e1213b2abb..e973b523275f 100644
--- a/apps/server/src/orchestration/projector.test.ts
+++ b/apps/server/src/orchestration/projector.test.ts
@@ -91,6 +91,7 @@ describe("orchestration projector", () => {
createdAt: now,
updatedAt: now,
archivedAt: null,
+ activeOrderKey: null,
settledOverride: null,
settledAt: null,
unsettledAt: null,
diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts
index 58c4876905ec..c048247f4128 100644
--- a/apps/server/src/orchestration/projector.ts
+++ b/apps/server/src/orchestration/projector.ts
@@ -344,6 +344,7 @@ export function projectEvent(
settledOverride: null,
settledAt: null,
unsettledAt: null,
+ activeOrderKey: null,
snoozedUntil: null,
snoozedAt: null,
deletedAt: null,
@@ -406,6 +407,7 @@ export function projectEvent(
settledOverride: "settled",
settledAt: payload.settledAt,
unsettledAt: null,
+ activeOrderKey: null,
updatedAt: payload.updatedAt,
}),
})),
@@ -500,6 +502,9 @@ export function projectEvent(
...nextBase,
threads: updateThread(nextBase.threads, payload.threadId, {
...(payload.title !== undefined ? { title: payload.title } : {}),
+ ...(payload.activeOrderKey !== undefined
+ ? { activeOrderKey: payload.activeOrderKey }
+ : {}),
...(payload.titleRegeneration !== undefined
? { titleRegeneration: payload.titleRegeneration }
: {}),
diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts
index 799386845419..6406e8237bc9 100644
--- a/apps/server/src/persistence/Layers/ProjectionThreads.ts
+++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts
@@ -54,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
snoozed_at,
pinned_at,
pin_order_key,
+ active_order_key,
title_regeneration_request_id,
title_regeneration_started_at,
latest_user_message_at,
@@ -84,6 +85,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
${row.snoozedAt},
${row.pinnedAt},
${row.pinOrderKey ?? null},
+ ${row.activeOrderKey ?? null},
${row.titleRegenerationRequestId ?? null},
${row.titleRegenerationStartedAt ?? null},
${row.latestUserMessageAt},
@@ -114,6 +116,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
snoozed_at = excluded.snoozed_at,
pinned_at = excluded.pinned_at,
pin_order_key = excluded.pin_order_key,
+ active_order_key = excluded.active_order_key,
title_regeneration_request_id = excluded.title_regeneration_request_id,
title_regeneration_started_at = excluded.title_regeneration_started_at,
latest_user_message_at = excluded.latest_user_message_at,
@@ -151,6 +154,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
pinned_at AS "pinnedAt",
pin_order_key AS "pinOrderKey",
+ active_order_key AS "activeOrderKey",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
latest_user_message_at AS "latestUserMessageAt",
@@ -190,6 +194,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
pinned_at AS "pinnedAt",
pin_order_key AS "pinOrderKey",
+ active_order_key AS "activeOrderKey",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
latest_user_message_at AS "latestUserMessageAt",
diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts
index 7f89170d29f5..c95f746d3648 100644
--- a/apps/server/src/persistence/Migrations.ts
+++ b/apps/server/src/persistence/Migrations.ts
@@ -60,6 +60,7 @@ import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts";
import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts";
import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts";
import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts";
+import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts";
/**
* Migration loader with all migrations defined inline.
@@ -120,6 +121,7 @@ export const migrationEntries = [
[46, "RepairAutomaticSettlementTimestamps", Migration0046],
[47, "ProjectionProjectIcon", Migration0047],
[48, "ProjectionThreadBranchPullRequest", Migration0048],
+ [49, "ProjectionThreadsActiveOrderKey", Migration0049],
] as const;
export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
diff --git a/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts
new file mode 100644
index 000000000000..138d25754d7e
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts
@@ -0,0 +1,44 @@
+import { assert, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+
+import { runMigrations } from "../Migrations.ts";
+import migrateActiveOrderKey from "./049_ProjectionThreadsActiveOrderKey.ts";
+
+it.layer(NodeSqliteClient.layerMemory())("049_ProjectionThreadsActiveOrderKey", (it) => {
+ it.effect("migrates old threads without changing their timestamps or assigning an order", () =>
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ yield* runMigrations({ toMigrationInclusive: 48 });
+ const now = "2026-01-01T00:00:00.000Z";
+ yield* sql`
+ INSERT INTO projection_threads (
+ thread_id, project_id, title, model_selection_json, runtime_mode,
+ created_at, updated_at
+ ) VALUES (
+ 'thread-1', 'project-1', 'Existing thread',
+ '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', ${now}, ${now}
+ )
+ `;
+ yield* runMigrations({ toMigrationInclusive: 49 });
+ const migrated = yield* sql<{ readonly activeOrderKey: string | null }>`
+ SELECT active_order_key AS "activeOrderKey" FROM projection_threads WHERE thread_id = 'thread-1'
+ `;
+ assert.deepEqual(migrated, [{ activeOrderKey: null }]);
+ // Recovery may run the same migration against a database that already
+ // has the column, including a placement written after the upgrade.
+ yield* sql`UPDATE projection_threads SET active_order_key = 'gm' WHERE thread_id = 'thread-1'`;
+ yield* migrateActiveOrderKey;
+ const rows = yield* sql<{
+ readonly activeOrderKey: string | null;
+ readonly createdAt: string;
+ readonly updatedAt: string;
+ }>`
+ SELECT active_order_key AS "activeOrderKey", created_at AS "createdAt", updated_at AS "updatedAt"
+ FROM projection_threads WHERE thread_id = 'thread-1'
+ `;
+ assert.deepEqual(rows, [{ activeOrderKey: "gm", createdAt: now, updatedAt: now }]);
+ }),
+ );
+});
diff --git a/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.ts b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.ts
new file mode 100644
index 000000000000..6f40ec38d081
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.ts
@@ -0,0 +1,15 @@
+import * as Effect from "effect/Effect";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+export default Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ const columns = yield* sql<{ readonly name: string }>`
+ PRAGMA table_info(projection_threads)
+ `;
+ if (!columns.some((column) => column.name === "active_order_key")) {
+ yield* sql`
+ ALTER TABLE projection_threads
+ ADD COLUMN active_order_key TEXT
+ `;
+ }
+});
diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts
index ee442624be51..0a8b2e31c5ab 100644
--- a/apps/server/src/persistence/Services/ProjectionThreads.ts
+++ b/apps/server/src/persistence/Services/ProjectionThreads.ts
@@ -47,6 +47,7 @@ export const ProjectionThread = Schema.Struct({
snoozedAt: Schema.NullOr(IsoDateTime),
pinnedAt: Schema.NullOr(IsoDateTime),
pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)),
+ activeOrderKey: Schema.optional(Schema.NullOr(Schema.String)),
titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)),
titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
latestUserMessageAt: Schema.NullOr(IsoDateTime),
diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts
index 36bc6a7b296f..5cc17586471d 100644
--- a/packages/client-runtime/src/operations/commands.test.ts
+++ b/packages/client-runtime/src/operations/commands.test.ts
@@ -24,6 +24,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
import {
archiveThread,
createProject,
+ reorderActiveThread,
settleThread,
stopThreadSession,
unsettleThread,
@@ -172,4 +173,24 @@ describe("environment commands", () => {
]);
}).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
);
+
+ it.effect("sends an active order key without changing activity timestamps", () =>
+ Effect.gen(function* () {
+ const dispatched: ClientOrchestrationCommand[] = [];
+ const supervisor = yield* makeSupervisor(dispatched);
+ yield* reorderActiveThread({
+ commandId: CommandId.make("reorder-command"),
+ threadId: ThreadId.make("thread-1"),
+ orderKey: "mf",
+ }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
+ expect(dispatched).toEqual([
+ {
+ type: "thread.active.reorder",
+ commandId: "reorder-command",
+ threadId: "thread-1",
+ orderKey: "mf",
+ },
+ ]);
+ }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
+ );
});
diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts
index cb74f117b772..9bf75c838a99 100644
--- a/packages/client-runtime/src/operations/commands.ts
+++ b/packages/client-runtime/src/operations/commands.ts
@@ -42,6 +42,7 @@ export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;
export type PinThreadInput = CommandInput<"thread.pin">;
export type UnpinThreadInput = CommandInput<"thread.unpin">;
export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">;
+export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">;
export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;
@@ -230,6 +231,16 @@ export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEf
});
});
+export const reorderActiveThread: (input: ReorderActiveThreadInput) => CommandEffect = Effect.fn(
+ "EnvironmentCommands.reorderActiveThread",
+)(function* (input) {
+ return yield* dispatch({
+ ...input,
+ type: "thread.active.reorder",
+ commandId: yield* commandId(input),
+ });
+});
+
export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn(
"EnvironmentCommands.updateThreadMetadata",
)(function* (input) {
diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts
index d02f63c0b69a..b8d2aef40697 100644
--- a/packages/client-runtime/src/state/entities.test.ts
+++ b/packages/client-runtime/src/state/entities.test.ts
@@ -208,6 +208,8 @@ describe("environment entity projections", () => {
title: "Cached thread",
branch: "stale-branch",
worktreePath: "/repo/stale-worktree",
+ activeOrderKey: "t",
+ unsettledAt: "2026-03-09T10:00:00.000Z",
deletedAt: null,
messages,
proposedPlans: [],
@@ -220,6 +222,8 @@ describe("environment entity projections", () => {
title: "Current thread",
branch: "current-branch",
worktreePath: "/repo/current-worktree",
+ activeOrderKey: "f",
+ unsettledAt: "2026-03-09T12:00:00.000Z",
};
const merged = mergeEnvironmentThread(detail, shell);
@@ -228,6 +232,8 @@ describe("environment entity projections", () => {
title: "Current thread",
branch: "current-branch",
worktreePath: "/repo/current-worktree",
+ activeOrderKey: "f",
+ unsettledAt: "2026-03-09T12:00:00.000Z",
});
expect(merged?.messages).toBe(messages);
});
diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts
index c540644289df..83881f7ec15f 100644
--- a/packages/client-runtime/src/state/threadCommands.ts
+++ b/packages/client-runtime/src/state/threadCommands.ts
@@ -19,6 +19,7 @@ import {
type SetThreadRuntimeModeInput,
type PinThreadInput,
type ReorderPinnedThreadInput,
+ type ReorderActiveThreadInput,
type SettleThreadInput,
type SnoozeThreadInput,
type StartThreadTurnInput,
@@ -39,6 +40,7 @@ import {
setThreadRuntimeMode,
pinThread,
reorderPinnedThread,
+ reorderActiveThread,
settleThread,
snoozeThread,
startThreadTurn,
@@ -63,6 +65,7 @@ export type {
SetThreadRuntimeModeInput,
PinThreadInput,
ReorderPinnedThreadInput,
+ ReorderActiveThreadInput,
SettleThreadInput,
SnoozeThreadInput,
StartThreadTurnInput,
@@ -150,6 +153,12 @@ export function createThreadEnvironmentAtoms(
scheduler,
concurrency,
}),
+ reorderActive: createEnvironmentCommand(runtime, {
+ label: "environment-data:commands:thread:reorder-active",
+ execute: (input: ReorderActiveThreadInput) => reorderActiveThread(input),
+ scheduler,
+ concurrency,
+ }),
updateMetadata: createEnvironmentCommand(runtime, {
label: "environment-data:commands:thread:update-metadata",
execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input),
diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts
index 0233cee0e22e..379985b71243 100644
--- a/packages/client-runtime/src/state/threadDetail.ts
+++ b/packages/client-runtime/src/state/threadDetail.ts
@@ -58,6 +58,8 @@ export function mergeEnvironmentThread(
archivedAt: shell.archivedAt,
settledOverride: shell.settledOverride,
settledAt: shell.settledAt,
+ unsettledAt: shell.unsettledAt,
+ activeOrderKey: shell.activeOrderKey,
snoozedUntil: shell.snoozedUntil,
snoozedAt: shell.snoozedAt,
pinnedAt: shell.pinnedAt,
diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts
index 59b3cb0551e4..38afce2d5cb7 100644
--- a/packages/client-runtime/src/state/threadReducer.test.ts
+++ b/packages/client-runtime/src/state/threadReducer.test.ts
@@ -178,24 +178,28 @@ describe("applyThreadDetailEvent", () => {
describe("thread.settled / thread.unsettled", () => {
it("sets the settled override and timestamp", () => {
const settledAt = "2026-04-01T05:00:00.000Z";
- const result = applyThreadDetailEvent(baseThread, {
- ...baseEventFields,
- sequence: 5,
- occurredAt: settledAt,
- aggregateKind: "thread",
- aggregateId: ThreadId.make("thread-1"),
- type: "thread.settled",
- payload: {
- threadId: ThreadId.make("thread-1"),
- settledAt,
- updatedAt: settledAt,
+ const result = applyThreadDetailEvent(
+ { ...baseThread, activeOrderKey: "m" },
+ {
+ ...baseEventFields,
+ sequence: 5,
+ occurredAt: settledAt,
+ aggregateKind: "thread",
+ aggregateId: ThreadId.make("thread-1"),
+ type: "thread.settled",
+ payload: {
+ threadId: ThreadId.make("thread-1"),
+ settledAt,
+ updatedAt: settledAt,
+ },
},
- });
+ );
expect(result.kind).toBe("updated");
if (result.kind === "updated") {
expect(result.thread.settledOverride).toBe("settled");
expect(result.thread.settledAt).toBe(settledAt);
+ expect(result.thread.activeOrderKey).toBeNull();
}
});
@@ -234,23 +238,27 @@ describe("applyThreadDetailEvent", () => {
describe("thread.pinned / thread.unpinned", () => {
it("sets pinnedAt", () => {
const pinnedAt = "2026-04-01T05:00:00.000Z";
- const result = applyThreadDetailEvent(baseThread, {
- ...baseEventFields,
- sequence: 5,
- occurredAt: pinnedAt,
- aggregateKind: "thread",
- aggregateId: ThreadId.make("thread-1"),
- type: "thread.pinned",
- payload: {
- threadId: ThreadId.make("thread-1"),
- pinnedAt,
- updatedAt: pinnedAt,
+ const result = applyThreadDetailEvent(
+ { ...baseThread, activeOrderKey: "m" },
+ {
+ ...baseEventFields,
+ sequence: 5,
+ occurredAt: pinnedAt,
+ aggregateKind: "thread",
+ aggregateId: ThreadId.make("thread-1"),
+ type: "thread.pinned",
+ payload: {
+ threadId: ThreadId.make("thread-1"),
+ pinnedAt,
+ updatedAt: pinnedAt,
+ },
},
- });
+ );
expect(result.kind).toBe("updated");
if (result.kind === "updated") {
expect(result.thread.pinnedAt).toBe(pinnedAt);
+ expect(result.thread.activeOrderKey).toBe("m");
}
});
@@ -281,26 +289,57 @@ describe("applyThreadDetailEvent", () => {
});
describe("thread.meta-updated", () => {
+ it.each(["f", null] as const)(
+ "updates the active key to %s without activity",
+ (activeOrderKey) => {
+ const result = applyThreadDetailEvent(
+ { ...baseThread, activeOrderKey: "m" },
+ {
+ ...baseEventFields,
+ sequence: 5,
+ occurredAt: "2026-04-01T05:00:00.000Z",
+ aggregateKind: "thread",
+ aggregateId: baseThread.id,
+ type: "thread.meta-updated",
+ payload: {
+ threadId: baseThread.id,
+ activeOrderKey,
+ updatedAt: baseThread.updatedAt,
+ },
+ },
+ );
+ expect(result.kind).toBe("updated");
+ if (result.kind === "updated") {
+ expect(result.thread.activeOrderKey).toBe(activeOrderKey);
+ expect(result.thread.updatedAt).toBe(baseThread.updatedAt);
+ }
+ },
+ );
+
it("patches title and branch", () => {
- const result = applyThreadDetailEvent(baseThread, {
- ...baseEventFields,
- sequence: 5,
- occurredAt: "2026-04-01T05:00:00.000Z",
- aggregateKind: "thread",
- aggregateId: ThreadId.make("thread-1"),
- type: "thread.meta-updated",
- payload: {
- threadId: ThreadId.make("thread-1"),
- title: "Updated Title",
- branch: "feature/demo",
- updatedAt: "2026-04-01T05:00:00.000Z",
+ const result = applyThreadDetailEvent(
+ { ...baseThread, activeOrderKey: "m" },
+ {
+ ...baseEventFields,
+ sequence: 5,
+ occurredAt: "2026-04-01T05:00:00.000Z",
+ aggregateKind: "thread",
+ aggregateId: ThreadId.make("thread-1"),
+ type: "thread.meta-updated",
+ payload: {
+ threadId: ThreadId.make("thread-1"),
+ title: "Updated Title",
+ branch: "feature/demo",
+ updatedAt: "2026-04-01T05:00:00.000Z",
+ },
},
- });
+ );
expect(result.kind).toBe("updated");
if (result.kind === "updated") {
expect(result.thread.title).toBe("Updated Title");
expect(result.thread.branch).toBe("feature/demo");
+ expect(result.thread.activeOrderKey).toBe("m");
// Model selection should be unchanged since it wasn't in the payload
expect(result.thread.modelSelection).toEqual(baseThread.modelSelection);
}
diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts
index 09272cd065cf..a3481fdc729c 100644
--- a/packages/client-runtime/src/state/threadReducer.ts
+++ b/packages/client-runtime/src/state/threadReducer.ts
@@ -105,6 +105,7 @@ export function applyThreadDetailEvent(
settledOverride: null,
settledAt: null,
unsettledAt: null,
+ activeOrderKey: null,
snoozedUntil: null,
snoozedAt: null,
deletedAt: null,
@@ -144,6 +145,7 @@ export function applyThreadDetailEvent(
settledOverride: "settled",
settledAt: event.payload.settledAt,
unsettledAt: null,
+ activeOrderKey: null,
updatedAt: event.payload.updatedAt,
},
};
@@ -244,6 +246,9 @@ export function applyThreadDetailEvent(
...(event.payload.branchPullRequest !== undefined
? { branchPullRequest: event.payload.branchPullRequest }
: {}),
+ ...(event.payload.activeOrderKey !== undefined
+ ? { activeOrderKey: event.payload.activeOrderKey }
+ : {}),
updatedAt: event.payload.updatedAt,
},
};
diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts
index a51d4cfed093..d9a2c124ee7d 100644
--- a/packages/client-runtime/src/state/threadSort.test.ts
+++ b/packages/client-runtime/src/state/threadSort.test.ts
@@ -1,8 +1,12 @@
import { describe, expect, it } from "vite-plus/test";
import {
+ generateSpreadPinOrderKeys,
+ pinOrderKeyBetween,
planPinnedMove,
+ planPinnedReorder,
resolveSettledThreadTimestamp,
+ sortActiveThreadsByOrderKey,
sortPinnedThreadsByOrderKey,
sortThreads,
type ThreadSortInput,
@@ -108,6 +112,44 @@ describe("sortThreads", () => {
});
});
+describe("planPinnedReorder with hidden rows", () => {
+ it("keeps hidden slots available when inserting between visible neighbors", () => {
+ const midpoint = pinOrderKeyBetween("f", "t")!;
+ const keysById = new Map([
+ ["a", "f"],
+ ["b", "t"],
+ ["moved", "z"],
+ ["snoozed", midpoint],
+ ]);
+ const assignments = planPinnedReorder({
+ orderedIds: ["a", "moved", "b"],
+ keysById,
+ movedId: "moved",
+ });
+ expect(assignments).toHaveLength(1);
+ const key = assignments[0]!.orderKey;
+ expect(key > "f" && key < "t").toBe(true);
+ expect(key).not.toBe(midpoint);
+ expect(assignments[0]!.id).toBe("moved");
+ });
+
+ it("materializes keyless rows without overwriting hidden slots", () => {
+ const reserved = generateSpreadPinOrderKeys(6);
+ const keysById = new Map([
+ ["a", null],
+ ["b", null],
+ ["c", null],
+ ...reserved.map((key, i) => [`hidden-${i}`, key] as const),
+ ]);
+ const assignments = planPinnedReorder({ orderedIds: ["c", "a", "b"], keysById, movedId: "c" });
+ expect(assignments.map(({ id }) => id)).toEqual(["c", "a", "b"]);
+ const keys = assignments.map(({ orderKey }) => orderKey);
+ expect(keys).toEqual([...keys].sort());
+ expect(new Set(keys).size).toBe(3);
+ expect(keys.every((key) => !reserved.includes(key))).toBe(true);
+ });
+});
+
describe("planPinnedMove", () => {
it("moves a thread up with a single key write", () => {
const assignments = planPinnedMove({
@@ -173,3 +215,137 @@ describe("sortPinnedThreadsByOrderKey", () => {
expect(sorted.map((thread) => thread.environmentId)).toEqual(["env-a", "env-b"]);
});
});
+
+describe("generateSpreadPinOrderKeys", () => {
+ it.each([0, 1, 650, 675, 676, 1_001, 2_000])(
+ "leaves unique, insertable keys for %i threads",
+ (count) => {
+ const keys = generateSpreadPinOrderKeys(count);
+ expect(keys).toHaveLength(count);
+ expect(new Set(keys).size).toBe(count);
+ expect([...keys].sort()).toEqual(keys);
+ for (let index = 0; index < keys.length; index += 1) {
+ const before = keys[index - 1] ?? null;
+ const after = keys[index]!;
+ expect(after).toMatch(/^[a-z]*[b-z]$/);
+ const between = pinOrderKeyBetween(before, after);
+ expect(between).not.toBeNull();
+ expect(between! < after).toBe(true);
+ if (before !== null) expect(between! > before).toBe(true);
+ }
+ },
+ );
+});
+
+describe("sortActiveThreadsByOrderKey", () => {
+ it("keeps new and reopened threads ahead of the saved order", () => {
+ const sorted = sortActiveThreadsByOrderKey([
+ {
+ id: "arranged-first",
+ createdAt: "2026-03-09T09:00:00.000Z",
+ activeOrderKey: "f",
+ },
+ {
+ id: "new",
+ createdAt: "2026-03-09T11:00:00.000Z",
+ activeOrderKey: null,
+ },
+ {
+ id: "arranged-last",
+ createdAt: "2026-03-09T12:00:00.000Z",
+ unsettledAt: "2026-03-09T13:00:00.000Z",
+ activeOrderKey: "t",
+ },
+ {
+ id: "reopened",
+ createdAt: "2026-03-01T09:00:00.000Z",
+ unsettledAt: "2026-03-09T12:00:00.000Z",
+ },
+ ]);
+ expect(sorted.map((thread) => thread.id)).toEqual([
+ "reopened",
+ "new",
+ "arranged-first",
+ "arranged-last",
+ ]);
+ });
+
+ it("breaks equal order keys and timestamps by thread then environment", () => {
+ for (const activeOrderKey of [null, "m"]) {
+ const threads = [
+ { id: "thread-b", environmentId: "env-a" },
+ { id: "thread-a", environmentId: "env-b" },
+ { id: "thread-a", environmentId: "env-a" },
+ ].map((thread) => ({
+ ...thread,
+ createdAt: "2026-03-09T10:00:00.000Z",
+ activeOrderKey,
+ }));
+ expect(
+ sortActiveThreadsByOrderKey(threads).map(
+ (thread) => `${thread.id}:${thread.environmentId}`,
+ ),
+ ).toEqual(["thread-a:env-a", "thread-a:env-b", "thread-b:env-a"]);
+ }
+ });
+
+ it("applies every move across a mixed keyless and keyed section", () => {
+ const threads = Array.from({ length: 6 }, (_, index) => ({
+ id: String(index),
+ createdAt: `2026-03-09T0${6 - index}:00:00.000Z`,
+ activeOrderKey: index < 3 ? null : ["f", "m", "t"][index - 3]!,
+ }));
+ const ids = threads.map((thread) => thread.id);
+ const keysById = new Map(threads.map((thread) => [thread.id, thread.activeOrderKey]));
+ for (const movedId of ids) {
+ for (let targetIndex = 0; targetIndex < ids.length; targetIndex += 1) {
+ const desired = ids.filter((id) => id !== movedId);
+ desired.splice(targetIndex, 0, movedId);
+ const assignments = planPinnedReorder({ orderedIds: desired, keysById, movedId });
+ const nextKeys = new Map(
+ assignments.map((assignment) => [assignment.id, assignment.orderKey]),
+ );
+ const updated = threads.map((thread) => ({
+ ...thread,
+ activeOrderKey: nextKeys.get(thread.id) ?? thread.activeOrderKey,
+ }));
+ expect(sortActiveThreadsByOrderKey(updated).map((thread) => thread.id)).toEqual(desired);
+ }
+ }
+ });
+
+ it("moves a keyless thread into the arranged run with one write", () => {
+ const assignments = planPinnedMove({
+ orderedIds: ["new", "reopened", "first", "last"],
+ keysById: new Map([
+ ["new", null],
+ ["reopened", null],
+ ["first", "f"],
+ ["last", "t"],
+ ]),
+ movedId: "reopened",
+ direction: "down",
+ });
+ expect(assignments).toHaveLength(1);
+ expect(assignments![0]!.id).toBe("reopened");
+ expect(assignments![0]!.orderKey > "f").toBe(true);
+ expect(assignments![0]!.orderKey < "t").toBe(true);
+ });
+
+ it("materializes a large active list without changing the requested order", () => {
+ const threads = Array.from({ length: 1_200 }, (_, index) => ({
+ id: String(index),
+ createdAt: "2026-03-09T10:00:00.000Z",
+ activeOrderKey: null as string | null,
+ }));
+ const orderedIds = threads.map((thread) => thread.id).toReversed();
+ const assignments = planPinnedReorder({
+ orderedIds,
+ movedId: orderedIds[0]!,
+ keysById: new Map(threads.map((thread) => [thread.id, thread.activeOrderKey])),
+ });
+ const keys = new Map(assignments.map((assignment) => [assignment.id, assignment.orderKey]));
+ const updated = threads.map((thread) => ({ ...thread, activeOrderKey: keys.get(thread.id) }));
+ expect(sortActiveThreadsByOrderKey(updated).map((thread) => thread.id)).toEqual(orderedIds);
+ });
+});
diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts
index cf89d4a21ac9..f06c95919554 100644
--- a/packages/client-runtime/src/state/threadSort.ts
+++ b/packages/client-runtime/src/state/threadSort.ts
@@ -205,25 +205,27 @@ export function pinOrderKeyBetween(before: string | null, after: string | null):
return pinOrderMidpoint(a, b);
}
-/** Evenly spaced keys for rewriting a whole pinned section (used when a
- drop lands next to keyless threads, so single-key insertion has nothing
- to anchor on). Two base-26 digits give 675 slots — far beyond any real
- pinned section — with monotonicity enforced as a belt-and-braces. */
-function generateSpreadPinOrderKeys(count: number): string[] {
- const space = PIN_ORDER_DIGITS.length * PIN_ORDER_DIGITS.length;
+/** Evenly spaced keys for materializing an order. Wider keys keep a large
+ active list from exhausting the space between two-digit keys. */
+export function generateSpreadPinOrderKeys(count: number): string[] {
+ let width = 2;
+ let space = PIN_ORDER_DIGITS.length ** width;
+ while (space <= (count + 1) * 2) {
+ width += 1;
+ space *= PIN_ORDER_DIGITS.length;
+ }
const step = space / (count + 1);
const keys: string[] = [];
- let previous = 0;
for (let i = 0; i < count; i += 1) {
- let value = Math.max(Math.round(step * (i + 1)), previous + 1);
+ let value = Math.round(step * (i + 1));
// Skip values whose low digit is the minimum (a trailing "a" key).
if (value % PIN_ORDER_DIGITS.length === 0) value += 1;
- value = Math.min(value, space - 1);
- previous = value;
- keys.push(
- PIN_ORDER_DIGITS.charAt(Math.floor(value / PIN_ORDER_DIGITS.length)) +
- PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length),
- );
+ let key = "";
+ for (let digit = 0; digit < width; digit += 1) {
+ key = PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length) + key;
+ value = Math.floor(value / PIN_ORDER_DIGITS.length);
+ }
+ keys.push(key);
}
return keys;
}
@@ -233,15 +235,21 @@ function generateSpreadPinOrderKeys(count: number): string[] {
* sits between two keyed (or absent) neighbors, this is a single write to
* the moved thread. When a neighbor is keyless (threads pinned before
* reordering shipped), the whole section gets fresh spread keys — a
- * one-time materialization; every move after that is single-write.
+ * one-time materialization; every move after that is single-write. Active
+ * reordering uses the same planner with activeOrderKey values.
*/
export function planPinnedReorder(input: {
/** Thread ids in the desired visual order (after the move). */
readonly orderedIds: readonly string[];
+ /** Include retained keys from hidden rows; only orderedIds receive writes. */
readonly keysById: ReadonlyMap;
readonly movedId: string;
}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> {
const { orderedIds, keysById, movedId } = input;
+ const visibleIds = new Set(orderedIds);
+ const reservedKeys = new Set(
+ [...keysById].flatMap(([id, key]) => (!visibleIds.has(id) && key != null ? [key] : [])),
+ );
const movedIndex = orderedIds.indexOf(movedId);
if (movedIndex === -1) return [];
const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null;
@@ -251,11 +259,14 @@ export function planPinnedReorder(input: {
const beforeUsable = beforeId === null || beforeKey != null;
const afterUsable = afterId === null || afterKey != null;
if (beforeUsable && afterUsable) {
- const key = pinOrderKeyBetween(beforeKey, afterKey);
+ let key = pinOrderKeyBetween(beforeKey, afterKey);
+ while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey);
if (key !== null) return [{ id: movedId, orderKey: key }];
}
// Keyless neighbor (or corrupt keys): rewrite the section in the new order.
- const keys = generateSpreadPinOrderKeys(orderedIds.length);
+ const keys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size)
+ .filter((key) => !reservedKeys.has(key))
+ .slice(0, orderedIds.length);
return orderedIds.flatMap((id, index) => {
const key = keys[index]!;
return keysById.get(id) === key ? [] : [{ id, orderKey: key }];
@@ -303,6 +314,36 @@ export function sortPinnedThreadsByOrderKey<
return [...keyed, ...keyless];
}
+/** New and reopened threads lead the active list. Arranged threads follow
+ their saved keys; activity leaves both groups in place. */
+export function sortActiveThreadsByOrderKey<
+ T extends {
+ readonly id: string;
+ readonly createdAt: string;
+ readonly unsettledAt?: string | null | undefined;
+ readonly activeOrderKey?: string | null | undefined;
+ readonly environmentId?: string | undefined;
+ },
+>(threads: readonly T[]): T[] {
+ return [...threads].sort((left, right) => {
+ const leftKey = left.activeOrderKey;
+ const rightKey = right.activeOrderKey;
+ if (leftKey == null && rightKey != null) return -1;
+ if (leftKey != null && rightKey == null) return 1;
+ let order = 0;
+ if (leftKey != null && rightKey != null) {
+ order = leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
+ } else {
+ order = activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left);
+ }
+ return (
+ order ||
+ left.id.localeCompare(right.id) ||
+ (left.environmentId ?? "").localeCompare(right.environmentId ?? "")
+ );
+ });
+}
+
/**
* planPinnedReorder specialized for mobile's Move up / Move down menu
* actions: swap the moved thread with its displayed neighbor. Null when the
diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts
index 6b815591ff2b..94d7c6081250 100644
--- a/packages/contracts/src/environment.ts
+++ b/packages/contracts/src/environment.ts
@@ -116,6 +116,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
/** Server understands thread.pin.reorder (and orderKey on thread.pin).
Same version-skew contract as threadSettlement. */
threadPinReorder: Schema.optionalKey(Schema.Boolean),
+ /** Server persists manual Active order through thread.active.reorder. */
+ threadActiveReorder: Schema.optionalKey(Schema.Boolean),
/** Server understands regenerateTitle on thread.meta.update. Absent on
older servers, so clients hide the action instead of sending it. */
threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),
diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts
index 61dbdb9a5512..b3dbc4188c23 100644
--- a/packages/contracts/src/orchestration.test.ts
+++ b/packages/contracts/src/orchestration.test.ts
@@ -784,6 +784,61 @@ it.effect("accepts a title seed in thread.turn.start", () =>
}),
);
+it.effect("decodes active reorder commands through client and orchestration boundaries", () =>
+ Effect.gen(function* () {
+ const input = {
+ type: "thread.active.reorder",
+ commandId: "cmd-active-reorder",
+ threadId: "thread-1",
+ orderKey: "gm",
+ };
+ const clientCommand = yield* decodeClientOrchestrationCommand(input);
+ const command = yield* decodeOrchestrationCommand(input);
+ for (const decoded of [clientCommand, command]) {
+ assert.strictEqual(decoded.type, "thread.active.reorder");
+ if (decoded.type === "thread.active.reorder") {
+ assert.strictEqual(decoded.threadId, "thread-1");
+ assert.strictEqual(decoded.orderKey, "gm");
+ }
+ }
+ const emptyKey = yield* Effect.exit(
+ decodeClientOrchestrationCommand({ ...input, orderKey: " " }),
+ );
+ assert.isTrue(Exit.isFailure(emptyKey));
+ }),
+);
+
+it.effect("decodes active placement on existing metadata events while accepting old payloads", () =>
+ Effect.gen(function* () {
+ const payload = { threadId: "thread-1", updatedAt: "2026-01-01T00:00:00.000Z" };
+ const oldPayload = yield* decodeThreadMetaUpdatedPayload(payload);
+ assert.strictEqual(oldPayload.activeOrderKey, undefined);
+ const resetPayload = yield* decodeThreadMetaUpdatedPayload({
+ ...payload,
+ activeOrderKey: null,
+ });
+ assert.strictEqual(resetPayload.activeOrderKey, null);
+ const event = yield* decodeOrchestrationEvent({
+ type: "thread.meta-updated",
+ sequence: 1,
+ eventId: "event-active-reorder",
+ aggregateKind: "thread",
+ aggregateId: "thread-1",
+ occurredAt: "2026-01-02T00:00:00.000Z",
+ commandId: "cmd-active-reorder",
+ causationEventId: null,
+ correlationId: null,
+ metadata: {},
+ payload: { ...payload, activeOrderKey: "gm" },
+ });
+ assert.strictEqual(event.type, "thread.meta-updated");
+ if (event.type === "thread.meta-updated") {
+ assert.strictEqual(event.payload.activeOrderKey, "gm");
+ assert.strictEqual(event.payload.updatedAt, payload.updatedAt);
+ }
+ }),
+);
+
it.effect("accepts a title regeneration intent in thread.meta.update", () =>
Effect.gen(function* () {
const parsed = yield* decodeOrchestrationCommand({
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index 47501e323441..37f5476fecc8 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -523,6 +523,9 @@ export const OrchestrationThread = Schema.Struct({
// servers never need each other's threads to agree on the merged list.
// Optional so payloads from pre-reorder servers still decode.
pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ // Manual Active placement. Keyless threads retain their creation/re-entry
+ // order above the arranged run. Settling clears this slot.
+ activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
// Pending-only state. Optional so older servers remain compatible.
titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
deletedAt: Schema.NullOr(IsoDateTime),
@@ -588,6 +591,7 @@ export const OrchestrationThreadShell = Schema.Struct({
snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
session: Schema.NullOr(OrchestrationSession),
latestUserMessageAt: Schema.NullOr(IsoDateTime),
@@ -895,6 +899,13 @@ const ThreadPinReorderCommand = Schema.Struct({
orderKey: TrimmedNonEmptyString,
});
+const ThreadActiveReorderCommand = Schema.Struct({
+ type: Schema.Literal("thread.active.reorder"),
+ commandId: CommandId,
+ threadId: ThreadId,
+ orderKey: TrimmedNonEmptyString,
+});
+
const ThreadMetaUpdateCommand = Schema.Struct({
type: Schema.Literal("thread.meta.update"),
commandId: CommandId,
@@ -1058,6 +1069,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
ThreadPinCommand,
ThreadUnpinCommand,
ThreadPinReorderCommand,
+ ThreadActiveReorderCommand,
ThreadMetaUpdateCommand,
ThreadRuntimeModeSetCommand,
ThreadInteractionModeSetCommand,
@@ -1086,6 +1098,7 @@ export const ClientOrchestrationCommand = Schema.Union([
ThreadPinCommand,
ThreadUnpinCommand,
ThreadPinReorderCommand,
+ ThreadActiveReorderCommand,
ThreadMetaUpdateCommand,
ThreadRuntimeModeSetCommand,
ThreadInteractionModeSetCommand,
@@ -1375,6 +1388,9 @@ export const ThreadPinReorderedPayload = Schema.Struct({
export const ThreadMetaUpdatedPayload = Schema.Struct({
threadId: ThreadId,
+ // Order updates use this existing event so older clients can ignore the
+ // new field while continuing to decode the event stream.
+ activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
title: Schema.optional(TrimmedNonEmptyString),
/** Intent marker consumed by the title-generation reactor. Keeping this on
the existing event lets older clients safely ignore the new field. */
From 6766e682ac1a0aa33020358ba98707f4f8fee467 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 6 Sep 2026 11:10:14 -0700
Subject: [PATCH 04/71] feat(mobile): arrange active threads from both thread
lists (#9730)
---
.../src/features/home/HomeRouteScreen.tsx | 4 +-
apps/mobile/src/features/home/HomeScreen.tsx | 94 ++++--
.../src/features/home/useThreadListActions.ts | 108 ++++---
.../threads/ThreadNavigationSidebar.tsx | 89 ++++--
.../features/threads/thread-list-v2-items.tsx | 103 +++---
.../src/features/threads/threadListV2.test.ts | 293 ++++++++++++++++++
.../src/features/threads/threadListV2.ts | 86 +++--
.../src/features/threads/threadOrder.ts | 120 +++++++
apps/mobile/src/state/thread-order.test.ts | 117 +++++++
apps/mobile/src/state/thread-order.ts | 89 ++++++
apps/mobile/src/state/use-thread-selection.ts | 4 +
11 files changed, 934 insertions(+), 173 deletions(-)
create mode 100644 apps/mobile/src/features/threads/threadOrder.ts
create mode 100644 apps/mobile/src/state/thread-order.test.ts
create mode 100644 apps/mobile/src/state/thread-order.ts
diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx
index 943303202216..a9833d2d619f 100644
--- a/apps/mobile/src/features/home/HomeRouteScreen.tsx
+++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx
@@ -49,7 +49,7 @@ export function HomeRouteScreen() {
unsnoozeThread,
pinThread,
unpinThread,
- movePinnedThread,
+ moveThread,
regenerateThreadTitle,
unsettleThread,
} = useThreadListActions();
@@ -199,7 +199,7 @@ export function HomeRouteScreen() {
onUnsettleThread={unsettleThread}
onPinThread={pinThread}
onUnpinThread={unpinThread}
- onMovePinnedThread={movePinnedThread}
+ onMoveThread={moveThread}
onRegenerateThreadTitle={regenerateThreadTitle}
onEnvironmentChange={setSelectedEnvironmentId}
onProjectChange={setSelectedProjectKey}
diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index 798a6a840c94..4a0095165a2c 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -1,3 +1,4 @@
+import { createThreadMovePlanner } from "../threads/threadOrder";
import {
LegendList,
type LegendListRef,
@@ -11,7 +12,6 @@ import {
threadSearchMatchKey,
type EnvironmentThreadSearchMatch,
} from "@t3tools/client-runtime/state/thread-search";
-import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import {
type EnvironmentId,
resolveEnvironmentMachineKind,
@@ -35,6 +35,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { useThreadSearch } from "../../state/queries";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
+import { usePendingThreadOrder } from "../../state/thread-order";
import { environmentServerConfigsAtom } from "../../state/server";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
@@ -51,6 +52,7 @@ import {
} from "../threads/thread-list-v2-items";
import {
buildThreadListV2Items,
+ getThreadListV2OrderedSection,
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
@@ -114,7 +116,7 @@ interface HomeScreenProps {
readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
readonly onPinThread: (thread: EnvironmentThreadShell) => Promise;
readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise;
- readonly onMovePinnedThread: (
+ readonly onMoveThread: (
thread: EnvironmentThreadShell,
direction: "up" | "down",
) => Promise;
@@ -512,11 +514,11 @@ export function HomeScreen(props: HomeScreenProps) {
},
[props.onPinThread],
);
- const handleMovePinnedThread = useCallback(
+ const handleMoveThread = useCallback(
(thread: EnvironmentThreadShell, direction: "up" | "down") => {
- void props.onMovePinnedThread(thread, direction);
+ void props.onMoveThread(thread, direction);
},
- [props.onMovePinnedThread],
+ [props.onMoveThread],
);
const handleUnpinThread = useCallback(
(thread: EnvironmentThreadShell) => {
@@ -608,6 +610,15 @@ export function HomeScreen(props: HomeScreenProps) {
}
return supported;
}, [serverConfigs]);
+ const activeReorderEnvironmentIds = useMemo(() => {
+ const supported = new Set();
+ for (const [environmentId, config] of serverConfigs) {
+ if (config.environment.capabilities.threadActiveReorder === true) {
+ supported.add(environmentId);
+ }
+ }
+ return supported;
+ }, [serverConfigs]);
const titleRegenerationEnvironmentIds = useMemo(() => {
const supported = new Set();
for (const [environmentId, config] of serverConfigs) {
@@ -627,20 +638,40 @@ export function HomeScreen(props: HomeScreenProps) {
),
[serverConfigs],
);
- // Canonical arranged pinned order (reorder-capable threads only) for the
- // Move up/down position flags. Computed from all shells, not the rendered
- // list, so search/scope filtering never disables or misdirects a move.
- const arrangedPinnedKeys = useMemo(() => {
- const pinned = sortPinnedThreadsByOrderKey(
- props.threads.filter(
- (thread) =>
- thread.pinnedAt != null &&
- thread.archivedAt === null &&
- pinReorderEnvironmentIds.has(thread.environmentId),
- ),
- );
- return pinned.map((thread) => `${thread.environmentId}:${thread.id}`);
- }, [pinReorderEnvironmentIds, props.threads]);
+ const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
+ const threadMovePlanners = useMemo(() => {
+ const sectionPlanner = (section: "pinned" | "active") =>
+ createThreadMovePlanner({
+ allThreads: props.threads,
+ section,
+ reorderableEnvironmentIds: new Set(
+ [...serverConfigs].flatMap(([id, config]) =>
+ (section === "pinned"
+ ? config.environment.capabilities.threadPinReorder
+ : config.environment.capabilities.threadActiveReorder) === true
+ ? [id]
+ : [],
+ ),
+ ),
+ ordered: getThreadListV2OrderedSection({
+ threads: props.threads,
+ section,
+ pendingOrder,
+ now: new Date().toISOString(),
+ settlementEnvironmentIds,
+ snoozeEnvironmentIds,
+ }),
+ });
+ return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") };
+ }, [
+ serverConfigs,
+ props.threads,
+ pendingOrder,
+ settlementEnvironmentIds,
+ snoozeEnvironmentIds,
+ nowMinute,
+ snoozeWakeTick,
+ ]);
const threadListV2Layout = useMemo(() => {
if (!threadListV2Enabled)
return {
@@ -655,6 +686,7 @@ export function HomeScreen(props: HomeScreenProps) {
// Settled threads are live shells; archived threads keep their original
// "hidden from lists" meaning.
return buildThreadListV2Items({
+ pendingOrder,
threads: props.threads.filter((thread) => thread.archivedAt === null),
environmentId: props.selectedEnvironmentId,
projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
@@ -669,6 +701,7 @@ export function HomeScreen(props: HomeScreenProps) {
selectedThreadKey: null,
});
}, [
+ pendingOrder,
nowMinute,
snoozeWakeTick,
snoozedShelfExpanded,
@@ -784,6 +817,8 @@ export function HomeScreen(props: HomeScreenProps) {
);
}
const thread = item.item.thread;
+ const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active;
+ const movedId = `${thread.environmentId}:${thread.id}`;
return (
0}
- canMovePinnedDown={(() => {
- const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`);
- return index !== -1 && index < arrangedPinnedKeys.length - 1;
- })()}
+ reorderSupported={
+ item.item.pinned
+ ? pinReorderEnvironmentIds.has(thread.environmentId)
+ : activeReorderEnvironmentIds.has(thread.environmentId)
+ }
+ canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
+ canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
onSnoozeThread={handleSnoozeThread}
onUnsnoozeThread={handleUnsnoozeThread}
onUnsettleThread={handleUnsettleThread}
onPinThread={handlePinThread}
onUnpinThread={handleUnpinThread}
- onMovePinnedThread={handleMovePinnedThread}
+ onMoveThread={handleMoveThread}
onSwipeableClose={handleSwipeableClose}
onSwipeableWillOpen={handleSwipeableWillOpen}
/>
@@ -849,8 +885,10 @@ export function HomeScreen(props: HomeScreenProps) {
},
[
handleDeleteThread,
- arrangedPinnedKeys,
- handleMovePinnedThread,
+ activeReorderEnvironmentIds,
+ threadMovePlanners,
+ pendingOrder,
+ handleMoveThread,
handlePinThread,
handleRegenerateThreadTitle,
handleSettleThread,
diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts
index dae6c46a89dd..7b0b7b701106 100644
--- a/apps/mobile/src/features/home/useThreadListActions.ts
+++ b/apps/mobile/src/features/home/useThreadListActions.ts
@@ -8,15 +8,14 @@ import { Alert } from "react-native";
import { showConfirmDialog } from "../../components/ConfirmDialogHost";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
-import {
- pinOrderKeyBetween,
- planPinnedMove,
- sortPinnedThreadsByOrderKey,
-} from "@t3tools/client-runtime/state/thread-sort";
+import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { environmentThreadShells, threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
+import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order";
+import { createPendingThreadOrder, createThreadMovePlanner } from "../threads/threadOrder";
+import { getThreadListV2OrderedSection } from "../threads/threadListV2";
/** Version skew: never send settle/unsettle to a server that predates them
(capability defaults false on decode for older servers). */
@@ -222,7 +221,7 @@ export function useThreadListActions(): {
readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise;
readonly pinThread: (thread: EnvironmentThreadShell) => Promise;
readonly unpinThread: (thread: EnvironmentThreadShell) => Promise;
- readonly movePinnedThread: (
+ readonly moveThread: (
thread: EnvironmentThreadShell,
direction: "up" | "down",
) => Promise;
@@ -451,60 +450,75 @@ export function useThreadListActions(): {
[updateThreadMetadata],
);
- // Move up / Move down for the pinned block. Computed against the CANONICAL
- // keyed pinned order (not the rendered list), so the move is valid even
- // while search or a project scope filters rows: the same fractional-key
- // scheme web dragging uses, one write to one thread per move (plus a
- // one-time section materialization when legacy keyless pins are involved).
+ // Plan against the complete section so filtering does not change a move.
const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, {
reportFailure: false,
});
- // One move at a time: a second tap before the first write's event lands
- // would plan from the same stale snapshot and silently collapse two moves
- // into one — same double-dispatch guard as snoozeThread.
- const movePinnedInFlightRef = useRef(false);
- const movePinnedThread = useCallback(
+ const reorderActiveMutation = useAtomCommand(threadEnvironment.reorderActive, {
+ reportFailure: false,
+ });
+ const moveThread = useCallback(
async (thread: EnvironmentThreadShell, direction: "up" | "down") => {
- if (movePinnedInFlightRef.current) return false;
- if (!environmentSupportsPinReorder(thread.environmentId)) {
+ if (getPendingThreadOrder() !== null) return false;
+ const section = thread.pinnedAt != null ? "pinned" : "active";
+ const configs = appAtomRegistry.get(environmentServerConfigsAtom);
+ const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => {
+ const capabilities = configs.get(environmentId)?.environment.capabilities;
+ return section === "pinned"
+ ? capabilities?.threadPinReorder === true
+ : capabilities?.threadActiveReorder === true;
+ };
+ if (!supportsReorder(thread.environmentId)) {
Alert.alert(
"Could not move thread",
- "This environment's server does not support pinned reordering yet. Update the server to reorder pins.",
+ "This environment's server does not support reordering these threads. Update the server to arrange them.",
);
return false;
}
const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
- const pinned = sortPinnedThreadsByOrderKey(
- shells.filter(
- (shell) =>
- shell.pinnedAt != null &&
- shell.archivedAt === null &&
- environmentSupportsPinReorder(shell.environmentId),
+ const ordered = getThreadListV2OrderedSection({
+ threads: shells,
+ section,
+ now: new Date().toISOString(),
+ settlementEnvironmentIds: new Set(
+ [...configs].flatMap(([id, config]) =>
+ config.environment.capabilities.threadSettlement === true ? [id] : [],
+ ),
),
- );
- const orderedIds = pinned.map((shell) => scopedThreadKey(shell.environmentId, shell.id));
- const assignments = planPinnedMove({
- orderedIds,
- keysById: new Map(
- pinned.map((shell) => [
- scopedThreadKey(shell.environmentId, shell.id),
- shell.pinOrderKey ?? null,
- ]),
+ snoozeEnvironmentIds: new Set(
+ [...configs].flatMap(([id, config]) =>
+ config.environment.capabilities.threadSnooze === true ? [id] : [],
+ ),
),
- movedId: scopedThreadKey(thread.environmentId, thread.id),
- direction,
});
- if (assignments === null || assignments.length === 0) return false;
+ const assignments = createThreadMovePlanner({
+ allThreads: shells,
+ ordered,
+ section,
+ reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)),
+ })(scopedThreadKey(thread.environmentId, thread.id), direction);
+ if (assignments === null) return false;
const shellByKey = new Map(
- pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),
+ ordered.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),
);
selectionHaptic();
- movePinnedInFlightRef.current = true;
+ const pending = beginPendingThreadOrder(
+ createPendingThreadOrder({
+ section,
+ ordered,
+ movedId: scopedThreadKey(thread.environmentId, thread.id),
+ direction,
+ assignments,
+ }),
+ );
+ let succeeded = false;
+ const reorder = section === "pinned" ? reorderPinnedMutation : reorderActiveMutation;
try {
for (const assignment of assignments) {
+ if (!pending.isPending()) return false;
const target = shellByKey.get(assignment.id);
if (target === undefined) continue;
- const result = await reorderPinnedMutation({
+ const result = await reorder({
environmentId: target.environmentId,
input: { threadId: target.id, orderKey: assignment.orderKey },
});
@@ -514,20 +528,20 @@ export function useThreadListActions(): {
"Could not move thread",
error instanceof Error && error.message.trim().length > 0
? error.message
- : "The pinned thread could not be moved.",
+ : "The thread could not be moved.",
);
- // No rollback: keys already written are valid orderings on their
- // own (each write is a complete, consistent placement), so a
- // partial materialization leaves the list sensible, not corrupt.
+ // Keep confirmed keys when a later environment rejects its write.
return false;
}
}
+ succeeded = true;
+ pending.complete();
return true;
} finally {
- movePinnedInFlightRef.current = false;
+ if (!succeeded) pending.cancel();
}
},
- [reorderPinnedMutation],
+ [reorderActiveMutation, reorderPinnedMutation],
);
const confirmDeleteThread = useConfirmDeleteThread(executeAction);
@@ -541,7 +555,7 @@ export function useThreadListActions(): {
unsettleThread,
pinThread,
unpinThread,
- movePinnedThread,
+ moveThread,
regenerateThreadTitle,
};
}
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index f529432070bf..12f5ac8ce4f3 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -1,3 +1,4 @@
+import { createThreadMovePlanner } from "./threadOrder";
import type {
EnvironmentProject,
EnvironmentThreadShell,
@@ -10,7 +11,6 @@ import { LegendList } from "@legendapp/list/react-native";
import type { MenuAction } from "@react-native-menu/menu";
import { useAtomValue } from "@effect/atom-react";
import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
-import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { LayoutChangeEvent } from "react-native";
import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
@@ -30,6 +30,7 @@ import { useProjects, useThreadShells } from "../../state/entities";
import { useThreadSearch } from "../../state/queries";
import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
+import { usePendingThreadOrder } from "../../state/thread-order";
import { environmentServerConfigsAtom } from "../../state/server";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useWorkspaceState } from "../../state/workspace";
@@ -78,6 +79,7 @@ import {
} from "./thread-list-v2-items";
import {
buildThreadListV2Items,
+ getThreadListV2OrderedSection,
buildThreadListV2ListItems,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
@@ -158,7 +160,7 @@ function ThreadNavigationSidebarPane(
unsettleThread,
pinThread,
unpinThread,
- movePinnedThread,
+ moveThread,
regenerateThreadTitle,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
@@ -437,6 +439,15 @@ function ThreadNavigationSidebarPane(
}
return supported;
}, [serverConfigs]);
+ const activeReorderEnvironmentIds = useMemo(() => {
+ const supported = new Set();
+ for (const [environmentId, config] of serverConfigs) {
+ if (config.environment.capabilities.threadActiveReorder === true) {
+ supported.add(environmentId);
+ }
+ }
+ return supported;
+ }, [serverConfigs]);
const titleRegenerationEnvironmentIds = useMemo(() => {
const supported = new Set();
for (const [environmentId, config] of serverConfigs) {
@@ -456,19 +467,40 @@ function ThreadNavigationSidebarPane(
),
[serverConfigs],
);
- // Canonical arranged pinned order for Move up/down flags — computed from
- // all shells so search/scope filtering never disables a valid move.
- const arrangedPinnedKeys = useMemo(() => {
- const pinned = sortPinnedThreadsByOrderKey(
- threads.filter(
- (thread) =>
- thread.pinnedAt != null &&
- thread.archivedAt === null &&
- pinReorderEnvironmentIds.has(thread.environmentId),
- ),
- );
- return pinned.map((thread) => `${thread.environmentId}:${thread.id}`);
- }, [pinReorderEnvironmentIds, threads]);
+ const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
+ const threadMovePlanners = useMemo(() => {
+ const sectionPlanner = (section: "pinned" | "active") =>
+ createThreadMovePlanner({
+ allThreads: threads,
+ section,
+ reorderableEnvironmentIds: new Set(
+ [...serverConfigs].flatMap(([id, config]) =>
+ (section === "pinned"
+ ? config.environment.capabilities.threadPinReorder
+ : config.environment.capabilities.threadActiveReorder) === true
+ ? [id]
+ : [],
+ ),
+ ),
+ ordered: getThreadListV2OrderedSection({
+ threads,
+ section,
+ pendingOrder,
+ now: new Date().toISOString(),
+ settlementEnvironmentIds,
+ snoozeEnvironmentIds,
+ }),
+ });
+ return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") };
+ }, [
+ serverConfigs,
+ threads,
+ pendingOrder,
+ settlementEnvironmentIds,
+ snoozeEnvironmentIds,
+ nowMinute,
+ snoozeWakeTick,
+ ]);
const threadListV2Layout = useMemo(() => {
if (!threadListV2Enabled)
return {
@@ -481,6 +513,7 @@ function ThreadNavigationSidebarPane(
nextSnoozeWakeAt: null,
};
return buildThreadListV2Items({
+ pendingOrder,
threads: threads.filter((thread) => thread.archivedAt === null),
environmentId: options.selectedEnvironmentId,
projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
@@ -495,6 +528,7 @@ function ThreadNavigationSidebarPane(
selectedThreadKey: props.selectedThreadKey ?? null,
});
}, [
+ pendingOrder,
nowMinute,
snoozeWakeTick,
snoozedShelfExpanded,
@@ -844,6 +878,10 @@ function ThreadNavigationSidebarPane(
}
case "v2-thread": {
const thread = item.item.thread;
+ const movePlanner = item.item.pinned
+ ? threadMovePlanners.pinned
+ : threadMovePlanners.active;
+ const movedId = `${thread.environmentId}:${thread.id}`;
const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId);
return (
0
+ reorderSupported={
+ item.item.pinned
+ ? pinReorderEnvironmentIds.has(thread.environmentId)
+ : activeReorderEnvironmentIds.has(thread.environmentId)
}
- canMovePinnedDown={(() => {
- const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`);
- return index !== -1 && index < arrangedPinnedKeys.length - 1;
- })()}
+ canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
+ canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
onSnoozeThread={snoozeThread}
onUnsnoozeThread={unsnoozeThread}
onUnsettleThread={unsettleThread}
onPinThread={pinThread}
onUnpinThread={unpinThread}
- onMovePinnedThread={movePinnedThread}
+ onMoveThread={moveThread}
onSwipeableClose={handleSwipeableClose}
onSwipeableWillOpen={handleSwipeableWillOpen}
simultaneousSwipeGesture={sidebarScrollGesture}
@@ -1027,14 +1064,16 @@ function ThreadNavigationSidebarPane(
},
[
archiveThread,
- arrangedPinnedKeys,
+ activeReorderEnvironmentIds,
+ threadMovePlanners,
+ pendingOrder,
confirmDeletePendingTask,
confirmDeleteThread,
handleSelectThread,
handleSwipeableClose,
handleSwipeableWillOpen,
machineByEnvironmentId,
- movePinnedThread,
+ moveThread,
openPendingTask,
pinReorderEnvironmentIds,
pinThread,
diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
index e66fa778476b..37b211e835f9 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -367,14 +367,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly pinningSupported: boolean;
/** False on servers that predate thread title regeneration. */
readonly titleRegenerationSupported: boolean;
- /** False on servers that predate thread.pin.reorder. Gates the pinned
- Move up / Move down menu items. */
- readonly pinReorderSupported?: boolean;
- readonly onMovePinnedThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void;
- /** Position flags for the pinned block so the menu disables the move that
+ /** Server supports reordering this card's section. */
+ readonly reorderSupported?: boolean;
+ readonly onMoveThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void;
+ /** Position flags for the card's section so the menu disables the move that
would fall off the end of the list. */
- readonly canMovePinnedUp?: boolean;
- readonly canMovePinnedDown?: boolean;
+ readonly canMoveUp?: boolean;
+ readonly canMoveDown?: boolean;
readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
readonly onSwipeableClose: (methods: SwipeableMethods) => void;
readonly searchMatch?: EnvironmentThreadSearchMatch;
@@ -397,7 +396,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
onArchiveThread,
onPinThread,
onUnpinThread,
- onMovePinnedThread,
+ onMoveThread,
} = props;
const snoozedRow = props.snoozed === true;
const pinnedRow = props.pinned === true;
@@ -436,14 +435,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);
const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]);
const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]);
- const handleMovePinnedUp = useCallback(
- () => onMovePinnedThread?.(thread, "up"),
- [onMovePinnedThread, thread],
- );
- const handleMovePinnedDown = useCallback(
- () => onMovePinnedThread?.(thread, "down"),
- [onMovePinnedThread, thread],
- );
+ const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]);
+ const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]);
const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
// Swipe: the v2 primary action is the lifecycle transition. Un-settling a
@@ -482,38 +475,39 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
// Pinned cards keep the full lifecycle menu; only the pin item flips to
// Unpin. (Settling a pinned thread clears the pin server-side; snoozing
// hides the card until wake with the pin intact.)
- const pinMenuItem = useMemo(
- () =>
- props.pinningSupported
+ const arrangementMenuItems = useMemo(
+ () => [
+ ...(variant === "card" && props.reorderSupported === true
+ ? [
+ {
+ id: "move-up",
+ title: "Move up",
+ image: "arrow.up",
+ attributes: { disabled: props.canMoveUp !== true },
+ } satisfies MenuAction,
+ {
+ id: "move-down",
+ title: "Move down",
+ image: "arrow.down",
+ attributes: { disabled: props.canMoveDown !== true },
+ } satisfies MenuAction,
+ ]
+ : []),
+ ...(props.pinningSupported
? [
- ...(pinnedRow && props.pinReorderSupported === true
- ? [
- {
- id: "move-pin-up",
- title: "Move up",
- image: "arrow.up",
- attributes: { disabled: props.canMovePinnedUp !== true },
- } satisfies MenuAction,
- {
- id: "move-pin-down",
- title: "Move down",
- image: "arrow.down",
- attributes: { disabled: props.canMovePinnedDown !== true },
- } satisfies MenuAction,
- ]
- : []),
thread.pinnedAt != null
? { id: "unpin", title: "Unpin", image: "pin.slash" }
: { id: "pin", title: "Pin", image: "pin" },
]
- : [],
+ : []),
+ ],
[
- pinnedRow,
- props.canMovePinnedDown,
- props.canMovePinnedUp,
- props.pinReorderSupported,
+ props.canMoveDown,
+ props.canMoveUp,
+ props.reorderSupported,
props.pinningSupported,
thread.pinnedAt,
+ variant,
],
);
const titleRegenerationMenuItems = useMemo(
@@ -533,37 +527,42 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
image: "clock",
subactions: snoozePresetActions,
},
- ...pinMenuItem,
+ ...arrangementMenuItems,
...titleRegenerationMenuItems,
{ id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
],
- [pinMenuItem, snoozePresetActions, titleRegenerationMenuItems],
+ [arrangementMenuItems, snoozePresetActions, titleRegenerationMenuItems],
);
const cardMenuActions = useMemo(
() => [
CARD_MENU_ACTIONS[0]!,
- ...pinMenuItem,
+ ...arrangementMenuItems,
...titleRegenerationMenuItems,
...CARD_MENU_ACTIONS.slice(1),
],
- [pinMenuItem, titleRegenerationMenuItems],
+ [arrangementMenuItems, titleRegenerationMenuItems],
);
const slimMenuActions = useMemo(
() => [
SLIM_MENU_ACTIONS[0]!,
- ...(thread.pinnedAt != null ? pinMenuItem : []),
+ ...(thread.pinnedAt != null ? arrangementMenuItems : []),
...titleRegenerationMenuItems,
SLIM_MENU_ACTIONS[1]!,
],
- [pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems],
+ [arrangementMenuItems, thread.pinnedAt, titleRegenerationMenuItems],
);
const snoozedMenuActions = useMemo(
() => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!],
[titleRegenerationMenuItems],
);
const legacyMenuActions = useMemo(
- () => [LEGACY_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, LEGACY_MENU_ACTIONS[1]!],
- [titleRegenerationMenuItems],
+ () => [
+ LEGACY_MENU_ACTIONS[0]!,
+ ...arrangementMenuItems,
+ ...titleRegenerationMenuItems,
+ LEGACY_MENU_ACTIONS[1]!,
+ ],
+ [arrangementMenuItems, titleRegenerationMenuItems],
);
const handleMenuAction = useCallback(
({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
@@ -572,8 +571,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
if (nativeEvent.event === "unsnooze") handleUnsnooze();
if (nativeEvent.event === "pin") handlePin();
if (nativeEvent.event === "unpin") handleUnpin();
- if (nativeEvent.event === "move-pin-up") handleMovePinnedUp();
- if (nativeEvent.event === "move-pin-down") handleMovePinnedDown();
+ if (nativeEvent.event === "move-up") handleMoveUp();
+ if (nativeEvent.event === "move-down") handleMoveDown();
if (nativeEvent.event === "archive") handleArchive();
if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
if (nativeEvent.event === "delete") handleDelete();
@@ -592,8 +591,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
handleArchive,
handleDelete,
handleRegenerateTitle,
- handleMovePinnedDown,
- handleMovePinnedUp,
+ handleMoveDown,
+ handleMoveUp,
handlePin,
handleSettle,
handleSnooze,
diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts
index 33ae27cc0638..4b1abb78c272 100644
--- a/apps/mobile/src/features/threads/threadListV2.test.ts
+++ b/apps/mobile/src/features/threads/threadListV2.test.ts
@@ -1,3 +1,10 @@
+import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort";
+import {
+ createPendingThreadOrder,
+ createThreadMovePlanner,
+ reconcilePendingThreadOrder,
+ type PendingThreadOrder,
+} from "./threadOrder";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
@@ -16,6 +23,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
+ getThreadListV2OrderedSection,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
@@ -258,6 +266,15 @@ describe("resolveThreadListV2SnoozeGateExpiryMs", () => {
});
describe("sortThreadsForListV2", () => {
+ it("honors a saved active order and leaves new threads above it", () => {
+ const sorted = sortThreadsForListV2([
+ { id: "newer-arranged", createdAt: "2026-06-01T12:00:00.000Z", activeOrderKey: "t" },
+ { id: "older-arranged", createdAt: "2026-06-01T08:00:00.000Z", activeOrderKey: "f" },
+ { id: "new", createdAt: "2026-06-01T13:00:00.000Z" },
+ ]);
+ expect(sorted.map((thread) => thread.id)).toEqual(["new", "older-arranged", "newer-arranged"]);
+ });
+
it("orders by creation time, newest first, ignoring activity", () => {
const sorted = sortThreadsForListV2([
{ id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" },
@@ -281,6 +298,55 @@ describe("sortThreadsForListV2", () => {
});
});
+describe("getThreadListV2OrderedSection", () => {
+ it("uses each saved order and excludes settled, snoozed, and archived rows", () => {
+ const threads = [
+ makeThread({ id: ThreadId.make("active-later"), title: "Later", activeOrderKey: "t" }),
+ makeThread({ id: ThreadId.make("active-first"), title: "First", activeOrderKey: "f" }),
+ makeThread({ id: ThreadId.make("active-new"), title: "New" }),
+ makeThread({
+ id: ThreadId.make("pinned-later"),
+ title: "Pinned later",
+ pinnedAt: NOW,
+ pinOrderKey: "t",
+ activeOrderKey: "f",
+ }),
+ makeThread({
+ id: ThreadId.make("pinned-first"),
+ title: "Pinned first",
+ pinnedAt: NOW,
+ pinOrderKey: "f",
+ activeOrderKey: "t",
+ }),
+ makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }),
+ makeThread({ id: ThreadId.make("archived"), title: "Archived", archivedAt: NOW }),
+ makeThread({
+ id: ThreadId.make("snoozed"),
+ title: "Snoozed",
+ snoozedUntil: "2026-06-03T10:00:00.000Z",
+ snoozedAt: NOW,
+ }),
+ makeThread({
+ id: ThreadId.make("pinned-snoozed"),
+ title: "Pinned snoozed",
+ pinnedAt: NOW,
+ snoozedUntil: "2026-06-03T10:00:00.000Z",
+ snoozedAt: NOW,
+ }),
+ ];
+ expect(
+ getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map(
+ (thread) => thread.id,
+ ),
+ ).toEqual(["active-new", "active-first", "active-later"]);
+ expect(
+ getThreadListV2OrderedSection({ threads, section: "pinned", now: NOW }).map(
+ (thread) => thread.id,
+ ),
+ ).toEqual(["pinned-first", "pinned-later"]);
+ });
+});
+
describe("buildThreadListV2Items", () => {
it("places a persisted settled thread in the settled shelf", () => {
const thread = makeThread({
@@ -937,3 +1003,230 @@ describe("buildThreadListV2ListItems", () => {
]);
});
});
+
+describe("pending mobile thread moves", () => {
+ function fixture(section: "active" | "pinned" = "active") {
+ const rows = ["a", "b", "c"].map((id, index) =>
+ makeThread({
+ id: ThreadId.make(id),
+ title: id === "a" ? "hidden" : "match",
+ createdAt: `2026-06-01T0${3 - index}:00:00.000Z`,
+ pinnedAt: section === "pinned" ? `2026-06-01T0${3 - index}:00:00.000Z` : null,
+ }),
+ );
+ const ordered = getThreadListV2OrderedSection({ threads: rows, section, now: NOW });
+ const orderedIds = ordered.map((row) => `${row.environmentId}:${row.id}`);
+ const movedId = orderedIds[2]!;
+ const assignments = planPinnedMove({
+ orderedIds,
+ keysById: new Map(orderedIds.map((id) => [id, null])),
+ movedId,
+ direction: "up",
+ })!;
+ const pending = createPendingThreadOrder({
+ section,
+ ordered,
+ movedId,
+ direction: "up",
+ assignments,
+ });
+ const update = (current: EnvironmentThreadShell[], assignment: (typeof assignments)[number]) =>
+ current.map((row) =>
+ `${row.environmentId}:${row.id}` === assignment.id
+ ? {
+ ...row,
+ [section === "pinned" ? "pinOrderKey" : "activeOrderKey"]: assignment.orderKey,
+ }
+ : row,
+ );
+ return { rows, assignments, pending, update };
+ }
+
+ function layout(
+ rows: EnvironmentThreadShell[],
+ pendingOrder: PendingThreadOrder | null,
+ searchQuery = "",
+ ) {
+ return buildThreadListV2Items({
+ threads: rows,
+ pendingOrder,
+ environmentId: null,
+ searchQuery,
+ now: NOW,
+ }).items.map((item) => item.thread.id);
+ }
+
+ it.each(["active", "pinned"] as const)(
+ "holds %s order through every intermediate key upsert",
+ (section) => {
+ const { rows, assignments, pending, update } = fixture(section);
+ let current = rows;
+ let hold: PendingThreadOrder | null = pending;
+ const desired = pending.orderedIds.map((id) => id.split(":")[1]);
+ expect(layout(current, hold)).toEqual(desired);
+ for (const assignment of assignments) {
+ current = update(current, assignment);
+ hold = reconcilePendingThreadOrder(
+ hold!,
+ getThreadListV2OrderedSection({ threads: current, section, now: NOW }),
+ );
+ expect(hold).not.toBeNull();
+ expect(layout(current, hold)).toEqual(desired);
+ }
+ expect(reconcilePendingThreadOrder({ ...hold!, commandsComplete: true }, current)).toBeNull();
+ expect(layout(current, null)).toEqual(desired);
+ },
+ );
+
+ it("keeps the action guard pending when receipts precede canonical shells", () => {
+ const { rows, assignments, pending, update } = fixture();
+ let hold: PendingThreadOrder | null = { ...pending, commandsComplete: true };
+ let current = rows;
+ expect(reconcilePendingThreadOrder(hold, current)).toBe(hold);
+ for (const [index, assignment] of assignments.entries()) {
+ current = update(current, assignment);
+ hold = reconcilePendingThreadOrder(hold!, current);
+ expect(hold === null).toBe(index === assignments.length - 1);
+ expect(layout(current, hold)).toEqual(["a", "c", "b"]);
+ }
+ });
+
+ it("keeps search results in the full pending section order", () => {
+ const { rows, assignments, pending, update } = fixture();
+ const current = update(update(rows, assignments[0]!), assignments[1]!);
+ expect(layout(current, pending, "match")).toEqual(["c", "b"]);
+ });
+
+ it("releases for real section membership and foreign key changes", () => {
+ const { rows, pending } = fixture();
+ expect(reconcilePendingThreadOrder(pending, rows.slice(1))).toBeNull();
+ const newRow = makeThread({ id: ThreadId.make("new"), title: "new" });
+ expect(reconcilePendingThreadOrder(pending, [...rows, newRow])).toBeNull();
+ expect(
+ reconcilePendingThreadOrder(
+ pending,
+ rows.map((row, index) => (index === 0 ? { ...row, activeOrderKey: "zz" } : row)),
+ ),
+ ).toBeNull();
+ const settled = rows.map((row, index) =>
+ index === 0 ? { ...row, settledOverride: "settled" as const } : row,
+ );
+ expect(layout(settled, pending)).toEqual(layout(settled, null));
+ });
+
+ it("does not hide a concurrent return to a previously confirmed key", () => {
+ const { rows, assignments, pending, update } = fixture();
+ const confirmed = reconcilePendingThreadOrder(pending, update(rows, assignments[0]!))!;
+ expect(reconcilePendingThreadOrder(confirmed, rows)).toBeNull();
+ });
+
+ it("preserves the hold for activity but releases for a reopened sort anchor", () => {
+ const { rows, pending } = fixture();
+ expect(
+ reconcilePendingThreadOrder(
+ pending,
+ rows.map((row) => ({ ...row, updatedAt: NOW })),
+ ),
+ ).toBe(pending);
+ expect(
+ reconcilePendingThreadOrder(
+ pending,
+ rows.map((row, index) => (index === 0 ? { ...row, unsettledAt: NOW } : row)),
+ ),
+ ).toBeNull();
+ });
+});
+
+describe("mobile move availability", () => {
+ const oldEnvironment = EnvironmentId.make("older-server");
+ function rows(section: "active" | "pinned", keys: readonly (string | null)[]) {
+ return keys.map((key, index) =>
+ makeThread({
+ id: ThreadId.make(`move-${index}`),
+ title: `Move ${index}`,
+ environmentId: index === 1 ? oldEnvironment : environmentId,
+ activeOrderKey: section === "active" ? key : null,
+ pinOrderKey: section === "pinned" ? key : null,
+ pinnedAt: section === "pinned" ? NOW : null,
+ }),
+ );
+ }
+
+ it.each(["active", "pinned"] as const)(
+ "keeps unsupported keyed %s neighbors as usable anchors",
+ (section) => {
+ const ordered = rows(section, ["bb", "dd", "ff"]);
+ const plan = createThreadMovePlanner({
+ ordered,
+ section,
+ reorderableEnvironmentIds: new Set([environmentId]),
+ });
+ const assignments = plan(`${environmentId}:move-0`, "down");
+ expect(assignments).toHaveLength(1);
+ expect(assignments![0]!.id).toBe(`${environmentId}:move-0`);
+ expect(assignments![0]!.orderKey > "dd").toBe(true);
+ expect(assignments![0]!.orderKey < "ff").toBe(true);
+ expect(plan(`${oldEnvironment}:move-1`, "up")).toBeNull();
+ expect(plan(`${environmentId}:move-0`, "up")).toBeNull();
+ },
+ );
+
+ it.each(["active", "pinned"] as const)(
+ "disables %s moves requiring unsupported keyless materialization",
+ (section) => {
+ const ordered = rows(section, [null, null, null]);
+ const plan = createThreadMovePlanner({
+ ordered,
+ section,
+ reorderableEnvironmentIds: new Set([environmentId]),
+ });
+ expect(plan(`${environmentId}:move-0`, "down")).toBeNull();
+ expect(plan(`${environmentId}:move-2`, "up")).toBeNull();
+ const supported = createThreadMovePlanner({
+ ordered,
+ section,
+ reorderableEnvironmentIds: new Set([environmentId, oldEnvironment]),
+ });
+ expect(supported(`${environmentId}:move-0`, "down")).toHaveLength(3);
+ },
+ );
+
+ it.each(["active", "pinned"] as const)(
+ "reserves snoozed %s keys when moving visible rows",
+ (section) => {
+ const ordered = rows(section, ["bb", "dd", "ff"]);
+ const input = { ordered, section, reorderableEnvironmentIds: new Set([environmentId]) };
+ const collision = createThreadMovePlanner(input)(`${environmentId}:move-0`, "down")![0]!
+ .orderKey;
+ const hidden = {
+ ...ordered[0]!,
+ id: ThreadId.make("snoozed"),
+ snoozedAt: NOW,
+ snoozedUntil: "2099-01-01T00:00:00.000Z",
+ pinOrderKey: section === "pinned" ? collision : null,
+ activeOrderKey: section === "active" ? collision : null,
+ };
+ const assignments = createThreadMovePlanner({ ...input, allThreads: [...ordered, hidden] })(
+ `${environmentId}:move-0`,
+ "down",
+ );
+ expect(assignments).toHaveLength(1);
+ expect(assignments![0]!.orderKey).not.toBe(collision);
+ expect(assignments![0]!.orderKey > "dd" && assignments![0]!.orderKey < "ff").toBe(true);
+ },
+ );
+
+ it("allows an independent keyed move despite an unsupported keyless row elsewhere", () => {
+ const ordered = rows("active", [null, null, "bb", "dd", "ff"]);
+ const plan = createThreadMovePlanner({
+ ordered,
+ section: "active",
+ reorderableEnvironmentIds: new Set([environmentId]),
+ });
+ const assignments = plan(`${environmentId}:move-4`, "up");
+ expect(assignments).toHaveLength(1);
+ expect(assignments![0]!.id).toBe(`${environmentId}:move-4`);
+ expect(assignments![0]!.orderKey > "bb").toBe(true);
+ expect(assignments![0]!.orderKey < "dd").toBe(true);
+ });
+});
diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts
index 2b44851f9309..7b6b44c00bdf 100644
--- a/apps/mobile/src/features/threads/threadListV2.ts
+++ b/apps/mobile/src/features/threads/threadListV2.ts
@@ -9,7 +9,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import {
- activeThreadAnchorTimestampMs,
+ sortActiveThreadsByOrderKey,
resolveSettledThreadTimestamp,
sortPinnedThreadsByOrderKey,
} from "@t3tools/client-runtime/state/thread-sort";
@@ -17,6 +17,12 @@ import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
+import {
+ applyPendingThreadOrder,
+ reconcilePendingThreadOrder,
+ type PendingThreadOrder,
+} from "./threadOrder";
+
export { snoozeWakeLabel };
/**
@@ -150,28 +156,54 @@ function parseTimestampMs(isoDate: string): number {
return Number.isNaN(parsed) ? 0 : parsed;
}
-/**
- * v2 sort: static order, newest anchor on top. Activity NEVER reorders the
- * list — a row holds its position between lifecycle transitions. The anchor
- * is creation time until an un-settle re-anchors it (see
- * activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the
- * top instead of sinking back to its creation-order slot. Mirrors web's
- * sortThreadsForSidebar.
- */
+/** The active order shared by web and native: new/reopened rows, then the
+ saved arrangement. Activity does not move a thread. */
export function sortThreadsForListV2<
T extends {
readonly id: string;
readonly createdAt: string;
readonly unsettledAt?: string | null | undefined;
+ readonly activeOrderKey?: string | null | undefined;
+ readonly environmentId?: string | undefined;
},
>(threads: readonly T[]): T[] {
- // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023
- // change-by-copy array methods.
- return [...threads].sort(
- (left, right) =>
- activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) ||
- left.id.localeCompare(right.id),
- );
+ return sortActiveThreadsByOrderKey(threads);
+}
+
+/** Canonical card section for Move up/down, independent of search or scope. */
+export function getThreadListV2OrderedSection(input: {
+ readonly threads: readonly EnvironmentThreadShell[];
+ readonly section: "pinned" | "active";
+ readonly pendingOrder?: PendingThreadOrder | null;
+ readonly now: string;
+ readonly settlementEnvironmentIds?: ReadonlySet;
+ readonly snoozeEnvironmentIds?: ReadonlySet;
+}): EnvironmentThreadShell[] {
+ const threads = input.threads.filter((thread) => {
+ if (thread.archivedAt !== null) return false;
+ if (
+ (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) &&
+ thread.settledOverride === "settled"
+ ) {
+ return false;
+ }
+ if (
+ (input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true) &&
+ effectiveSnoozed(thread, { now: input.now })
+ ) {
+ return false;
+ }
+ return (thread.pinnedAt != null) === (input.section === "pinned");
+ });
+ const ordered =
+ input.section === "pinned"
+ ? sortPinnedThreadsByOrderKey(threads)
+ : sortActiveThreadsByOrderKey(threads);
+ const pending =
+ input.pendingOrder?.section === input.section
+ ? reconcilePendingThreadOrder(input.pendingOrder, ordered)
+ : null;
+ return applyPendingThreadOrder(ordered, input.section, pending);
}
export interface ThreadListV2Item {
@@ -299,10 +331,11 @@ export function buildThreadListV2ListItems(input: {
}
/**
- * Partitions visible threads into the active card block (creation order) and
+ * Partitions visible threads into the active card block (saved order) and
* the settled recency tail, matching the web v2 list.
*/
export function buildThreadListV2Items(input: {
+ readonly pendingOrder?: PendingThreadOrder | null;
readonly threads: ReadonlyArray;
readonly environmentId: EnvironmentId | null;
readonly projectRefs?: ReadonlyArray<{
@@ -331,6 +364,17 @@ export function buildThreadListV2Items(input: {
readonly selectedThreadKey?: string | null;
}): ThreadListV2Layout {
const now = input.now;
+ const pending =
+ input.pendingOrder == null
+ ? null
+ : reconcilePendingThreadOrder(
+ input.pendingOrder,
+ getThreadListV2OrderedSection({
+ ...input,
+ section: input.pendingOrder.section,
+ pendingOrder: null,
+ }),
+ );
const query = input.searchQuery.trim().toLocaleLowerCase();
const projectKeys = input.projectRefs
? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
@@ -382,7 +426,7 @@ export function buildThreadListV2Items(input: {
}
}
- const orderedActive = sortThreadsForListV2(active);
+ const orderedActive = applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending);
const orderedSnoozed = [...snoozed].sort(
(left, right) =>
parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""),
@@ -414,7 +458,11 @@ export function buildThreadListV2Items(input: {
);
const items: ThreadListV2Item[] = [];
- for (const thread of sortPinnedThreadsByOrderKey(pinned)) {
+ for (const thread of applyPendingThreadOrder(
+ sortPinnedThreadsByOrderKey(pinned),
+ "pinned",
+ pending,
+ )) {
items.push({
thread,
variant: "card",
diff --git a/apps/mobile/src/features/threads/threadOrder.ts b/apps/mobile/src/features/threads/threadOrder.ts
new file mode 100644
index 000000000000..2b722e69428b
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadOrder.ts
@@ -0,0 +1,120 @@
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort";
+import type { EnvironmentId } from "@t3tools/contracts";
+
+type OrderRow = Pick<
+ EnvironmentThreadShell,
+ | "id"
+ | "environmentId"
+ | "pinOrderKey"
+ | "activeOrderKey"
+ | "createdAt"
+ | "unsettledAt"
+ | "pinnedAt"
+>;
+
+export interface PendingThreadOrder {
+ readonly section: "pinned" | "active";
+ readonly orderedIds: readonly string[];
+ readonly before: ReadonlyMap;
+ readonly assignments: ReadonlyMap;
+ readonly confirmed: ReadonlySet;
+ readonly commandsComplete: boolean;
+}
+
+function rowId(row: OrderRow): string {
+ return `${row.environmentId}:${row.id}`;
+}
+
+function rowOrder(row: OrderRow, section: PendingThreadOrder["section"]) {
+ return {
+ key: (section === "pinned" ? row.pinOrderKey : row.activeOrderKey) ?? null,
+ anchor: section === "pinned" ? (row.pinnedAt ?? "") : (row.unsettledAt ?? row.createdAt),
+ };
+}
+
+/** Keep every visible row as an anchor, but only offer plans whose key writes
+ * are supported. Menu availability and execution use this same planner. */
+export function createThreadMovePlanner(input: {
+ readonly ordered: readonly OrderRow[];
+ readonly allThreads?: readonly OrderRow[];
+ readonly section: PendingThreadOrder["section"];
+ readonly reorderableEnvironmentIds: ReadonlySet;
+}) {
+ const orderedIds = input.ordered.map(rowId);
+ const keysById = new Map(
+ (input.allThreads ?? input.ordered).map((row) => [
+ rowId(row),
+ rowOrder(row, input.section).key,
+ ]),
+ );
+ const writableIds = new Set(
+ input.ordered
+ .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId))
+ .map(rowId),
+ );
+ return (movedId: string, direction: "up" | "down") => {
+ if (!writableIds.has(movedId)) return null;
+ const assignments = planPinnedMove({ orderedIds, keysById, movedId, direction });
+ return assignments === null ||
+ assignments.length === 0 ||
+ assignments.some((assignment) => !writableIds.has(assignment.id))
+ ? null
+ : assignments;
+ };
+}
+
+export function createPendingThreadOrder(input: {
+ readonly section: PendingThreadOrder["section"];
+ readonly ordered: readonly OrderRow[];
+ readonly movedId: string;
+ readonly direction: "up" | "down";
+ readonly assignments: readonly { readonly id: string; readonly orderKey: string }[];
+}): PendingThreadOrder {
+ const orderedIds = input.ordered.map(rowId);
+ const from = orderedIds.indexOf(input.movedId);
+ orderedIds.splice(from, 1);
+ orderedIds.splice(from + (input.direction === "up" ? -1 : 1), 0, input.movedId);
+ return {
+ section: input.section,
+ orderedIds,
+ before: new Map(input.ordered.map((row) => [rowId(row), rowOrder(row, input.section)])),
+ assignments: new Map(input.assignments.map(({ id, orderKey }) => [id, orderKey])),
+ confirmed: new Set(),
+ commandsComplete: false,
+ };
+}
+
+/** Receipts and shell updates arrive independently. Only our own key writes
+ * may pass through the hold; membership and other arrangement changes win. */
+export function reconcilePendingThreadOrder(
+ pending: PendingThreadOrder,
+ ordered: readonly OrderRow[],
+): PendingThreadOrder | null {
+ if (ordered.length !== pending.before.size) return null;
+ const confirmed = new Set(pending.confirmed);
+ for (const row of ordered) {
+ const id = rowId(row);
+ const before = pending.before.get(id);
+ const current = rowOrder(row, pending.section);
+ if (before === undefined || current.anchor !== before.anchor) return null;
+ const assigned = pending.assignments.get(id);
+ if (assigned !== undefined && current.key === assigned) confirmed.add(id);
+ else if (current.key !== before.key || confirmed.has(id)) return null;
+ }
+ if (pending.commandsComplete && confirmed.size === pending.assignments.size) return null;
+ return confirmed.size === pending.confirmed.size ? pending : { ...pending, confirmed };
+}
+
+/** Apply the full section's pending order after search/environment filtering. */
+export function applyPendingThreadOrder(
+ rows: readonly T[],
+ section: PendingThreadOrder["section"],
+ pending: PendingThreadOrder | null | undefined,
+): T[] {
+ if (pending == null || pending.section !== section) return [...rows];
+ const rank = new Map(pending.orderedIds.map((id, index) => [id, index]));
+ return [...rows].sort(
+ (left, right) => (rank.get(rowId(left)) ?? Infinity) - (rank.get(rowId(right)) ?? Infinity),
+ );
+}
diff --git a/apps/mobile/src/state/thread-order.test.ts b/apps/mobile/src/state/thread-order.test.ts
new file mode 100644
index 000000000000..4959ad989626
--- /dev/null
+++ b/apps/mobile/src/state/thread-order.test.ts
@@ -0,0 +1,117 @@
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import type { Atom } from "effect/unstable/reactivity";
+import { afterEach, describe, expect, it, vi } from "vite-plus/test";
+
+import { createPendingThreadOrder } from "../features/threads/threadOrder";
+import { appAtomRegistry } from "./atom-registry";
+import {
+ beginPendingThreadOrder,
+ getPendingThreadOrder,
+ pendingThreadOrderAtom,
+} from "./thread-order";
+import { environmentThreadShells } from "./threads";
+
+vi.mock("./atom-registry", async () => {
+ const { AtomRegistry } = await import("effect/unstable/reactivity");
+ return { appAtomRegistry: AtomRegistry.make() };
+});
+vi.mock("./threads", async () => {
+ const { Atom } = await import("effect/unstable/reactivity");
+ return { environmentThreadShells: { threadShellsAtom: Atom.make([]).pipe(Atom.keepAlive) } };
+});
+vi.mock("./server", async () => {
+ const { Atom } = await import("effect/unstable/reactivity");
+ return { environmentServerConfigsAtom: Atom.make(new Map()).pipe(Atom.keepAlive) };
+});
+
+// The mocked shell source is writable so tests can deliver canonical upserts.
+const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable<
+ readonly EnvironmentThreadShell[],
+ readonly EnvironmentThreadShell[]
+>;
+
+function fixture() {
+ // Only section membership and order fields are read by this coordinator.
+ const rows = ["a", "b"].map(
+ (id, index) =>
+ ({
+ id: ThreadId.make(id),
+ environmentId: EnvironmentId.make("env"),
+ createdAt: `2026-06-01T0${2 - index}:00:00.000Z`,
+ archivedAt: null,
+ pinnedAt: null,
+ activeOrderKey: null,
+ }) as EnvironmentThreadShell,
+ );
+ appAtomRegistry.set(shellsAtom, rows);
+ const pending = createPendingThreadOrder({
+ section: "active",
+ ordered: rows,
+ movedId: "env:b",
+ direction: "up",
+ assignments: [
+ { id: "env:b", orderKey: "aa" },
+ { id: "env:a", orderKey: "bb" },
+ ],
+ });
+ const start = () => beginPendingThreadOrder(pending);
+ const upsert = (id: string, key: string) => {
+ const current = appAtomRegistry.get(shellsAtom);
+ appAtomRegistry.set(
+ shellsAtom,
+ current.map((row) => (row.id === id ? { ...row, activeOrderKey: key } : row)),
+ );
+ };
+ return { rows, start, upsert };
+}
+
+afterEach(() => appAtomRegistry.reset());
+
+describe("shared mobile pending move", () => {
+ it("blocks another pickup after receipts and clears on final canonical upsert", () => {
+ const { start, upsert } = fixture();
+ const move = start();
+ move.complete();
+ expect(getPendingThreadOrder()).not.toBeNull();
+ upsert("b", "aa");
+ expect(getPendingThreadOrder()).not.toBeNull();
+ upsert("a", "bb");
+ expect(getPendingThreadOrder()).toBeNull();
+ expect(move.isPending()).toBe(false);
+ });
+
+ it("waits for receipts when shells arrive first", () => {
+ const { start, upsert } = fixture();
+ const move = start();
+ upsert("b", "aa");
+ upsert("a", "bb");
+ expect(getPendingThreadOrder()).not.toBeNull();
+ move.complete();
+ expect(getPendingThreadOrder()).toBeNull();
+ });
+
+ it.each(["failure", "interruption"])("releases a %s without restoring old canonical keys", () => {
+ const { start, upsert } = fixture();
+ const move = start();
+ upsert("b", "aa");
+ move.cancel();
+ expect(getPendingThreadOrder()).toBeNull();
+ expect(appAtomRegistry.get(shellsAtom)[1]?.activeOrderKey).toBe("aa");
+ const next = start();
+ move.cancel();
+ expect(next.isPending()).toBe(true);
+ next.cancel();
+ });
+
+ it("stops remaining writes when a canonical membership change invalidates the move", () => {
+ const { rows, start } = fixture();
+ const move = start();
+ appAtomRegistry.set(shellsAtom, rows.slice(1));
+ expect(move.isPending()).toBe(false);
+ expect(appAtomRegistry.get(pendingThreadOrderAtom)).toBeNull();
+ move.complete();
+ appAtomRegistry.set(shellsAtom, rows);
+ expect(getPendingThreadOrder()).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/state/thread-order.ts b/apps/mobile/src/state/thread-order.ts
new file mode 100644
index 000000000000..0fb57fc826e0
--- /dev/null
+++ b/apps/mobile/src/state/thread-order.ts
@@ -0,0 +1,89 @@
+import { useAtomValue } from "@effect/atom-react";
+import { useEffect } from "react";
+import { Atom } from "effect/unstable/reactivity";
+
+import {
+ reconcilePendingThreadOrder,
+ type PendingThreadOrder,
+} from "../features/threads/threadOrder";
+import { getThreadListV2OrderedSection } from "../features/threads/threadListV2";
+import { appAtomRegistry } from "./atom-registry";
+import { environmentServerConfigsAtom } from "./server";
+import { environmentThreadShells } from "./threads";
+
+export const pendingThreadOrderAtom = Atom.make(null).pipe(
+ Atom.keepAlive,
+);
+
+export function usePendingThreadOrder(nowMinute: string, snoozeWakeTick: number) {
+ const pending = useAtomValue(pendingThreadOrderAtom);
+ // A timed wake can change section membership without a shell event. Use the
+ // lists' existing clocks to retire that hold and re-enable their move menus.
+ useEffect(() => {
+ getPendingThreadOrder();
+ }, [nowMinute, snoozeWakeTick]);
+ return pending;
+}
+
+let refreshPendingOrder: (() => void) | undefined;
+
+/** Shared by Home and the navigation sidebar, including their action guards. */
+export function getPendingThreadOrder(): PendingThreadOrder | null {
+ refreshPendingOrder?.();
+ return appAtomRegistry.get(pendingThreadOrderAtom);
+}
+
+export function beginPendingThreadOrder(pending: PendingThreadOrder) {
+ const unsubscribers: (() => void)[] = [];
+ const cancel = () => {
+ if (refreshPendingOrder !== refresh) return;
+ refreshPendingOrder = undefined;
+ for (const unsubscribe of unsubscribers) unsubscribe();
+ appAtomRegistry.set(pendingThreadOrderAtom, null);
+ };
+ const refresh = () => {
+ if (refreshPendingOrder !== refresh) return;
+ const current = appAtomRegistry.get(pendingThreadOrderAtom);
+ if (current === null) return;
+ const configs = appAtomRegistry.get(environmentServerConfigsAtom);
+ const ordered = getThreadListV2OrderedSection({
+ threads: appAtomRegistry.get(environmentThreadShells.threadShellsAtom),
+ section: current.section,
+ now: new Date().toISOString(),
+ settlementEnvironmentIds: new Set(
+ [...configs].flatMap(([id, config]) =>
+ config.environment.capabilities.threadSettlement === true ? [id] : [],
+ ),
+ ),
+ snoozeEnvironmentIds: new Set(
+ [...configs].flatMap(([id, config]) =>
+ config.environment.capabilities.threadSnooze === true ? [id] : [],
+ ),
+ ),
+ });
+ const next = reconcilePendingThreadOrder(current, ordered);
+ if (next === null) cancel();
+ else if (next !== current) appAtomRegistry.set(pendingThreadOrderAtom, next);
+ };
+ refreshPendingOrder = refresh;
+ appAtomRegistry.set(pendingThreadOrderAtom, pending);
+ unsubscribers.push(
+ appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, refresh),
+ appAtomRegistry.subscribe(environmentServerConfigsAtom, refresh),
+ );
+ return {
+ isPending: () => {
+ refresh();
+ return refreshPendingOrder === refresh;
+ },
+ complete: () => {
+ if (refreshPendingOrder !== refresh) return;
+ const current = appAtomRegistry.get(pendingThreadOrderAtom);
+ if (current !== null) {
+ appAtomRegistry.set(pendingThreadOrderAtom, { ...current, commandsComplete: true });
+ refresh();
+ }
+ },
+ cancel,
+ };
+}
diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts
index b7350dd5dddf..7e012cb78903 100644
--- a/apps/mobile/src/state/use-thread-selection.ts
+++ b/apps/mobile/src/state/use-thread-selection.ts
@@ -62,6 +62,10 @@ function threadDetailToShell(
archivedAt: thread.archivedAt,
settledOverride: thread.settledOverride,
settledAt: thread.settledAt,
+ unsettledAt: thread.unsettledAt,
+ activeOrderKey: thread.activeOrderKey,
+ pinnedAt: thread.pinnedAt,
+ pinOrderKey: thread.pinOrderKey,
snoozedUntil: thread.snoozedUntil ?? null,
snoozedAt: thread.snoozedAt ?? null,
session: thread.session,
From 4023d93bce3f610349fbf38e991f737c1c5e0c98 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 6 Sep 2026 11:10:14 -0700
Subject: [PATCH 05/71] feat(web): drag threads across sections with consistent
motion (#9731)
---
apps/web/src/components/Sidebar.drag.test.ts | 616 ++++++
apps/web/src/components/Sidebar.drag.ts | 175 ++
apps/web/src/components/Sidebar.logic.test.ts | 726 ++++++-
apps/web/src/components/Sidebar.logic.ts | 276 ++-
.../web/src/components/Sidebar.motion.test.ts | 339 +++
apps/web/src/components/Sidebar.motion.ts | 168 ++
apps/web/src/components/Sidebar.tsx | 1810 +++++++++++------
apps/web/src/hooks/useThreadActions.ts | 38 +
apps/web/src/lib/threadSort.ts | 1 -
apps/web/src/state/entities.ts | 7 +
docs/user/thread-sidebar.md | 34 +-
.../client-runtime/src/state/threadSort.ts | 2 +-
12 files changed, 3534 insertions(+), 658 deletions(-)
create mode 100644 apps/web/src/components/Sidebar.drag.test.ts
create mode 100644 apps/web/src/components/Sidebar.drag.ts
create mode 100644 apps/web/src/components/Sidebar.motion.test.ts
create mode 100644 apps/web/src/components/Sidebar.motion.ts
diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts
new file mode 100644
index 000000000000..894fb7b4fee1
--- /dev/null
+++ b/apps/web/src/components/Sidebar.drag.test.ts
@@ -0,0 +1,616 @@
+import { describe, expect, it, vi } from "vite-plus/test";
+import { closestCenter, type CollisionDetection } from "@dnd-kit/core";
+import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable";
+import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag";
+import {
+ sidebarListItemId,
+ sidebarMarkerId,
+ type SidebarListItem,
+ type SidebarListMarker,
+ type SidebarSection,
+} from "./Sidebar.logic";
+
+const thread = (key: string, section: SidebarSection): SidebarListItem => ({
+ kind: "thread",
+ key,
+ section,
+});
+const marker = (marker: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker });
+const pinnedHeader = marker("pinned-header");
+const divider = marker("pinned-divider");
+const settledHeader = marker("settled-header");
+const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 };
+
+function layout(
+ items: readonly SidebarListItem[],
+ active: string,
+ over: string,
+ scale = 1,
+ cardHeight = 82,
+) {
+ let top = 100;
+ const rects = items.map((item) => {
+ const height =
+ item.kind === "thread"
+ ? (item.section === "pinned" || item.section === "active" ? cardHeight : 36) * scale
+ : item.marker === "pinned-header" || item.marker === "pinned-divider"
+ ? 0
+ : (item.marker.endsWith("placeholder") ? 36 : 32) * scale;
+ const rect = { top, height, bottom: top + height, left: 0, right: 260, width: 260 };
+ top += height + 1;
+ return rect;
+ });
+ const activeIndex = items.findIndex((item) => sidebarListItemId(item) === active);
+ return {
+ activeIndex,
+ overIndex: items.findIndex((item) => sidebarListItemId(item) === over),
+ activeNodeRect: rects[activeIndex]!,
+ rects,
+ index: 0,
+ } satisfies Parameters[0];
+}
+
+function preview(
+ input: Parameters[0],
+ active: string,
+ over: string,
+ scale = 1,
+) {
+ const strategy = createSidebarSortingStrategy(input);
+ const args = layout(input.items, active, over, scale);
+ return new Map(
+ input.items.map((item, index) => [sidebarListItemId(item), strategy({ ...args, index })]),
+ );
+}
+
+describe("sidebar collision detection", () => {
+ function collisionArgs(blockedAboveSource = false) {
+ const rows = [thread("source", "active"), thread("blocked", "active")];
+ const items = [
+ pinnedHeader,
+ divider,
+ ...(blockedAboveSource ? rows.toReversed() : rows),
+ settledHeader,
+ marker("settled-placeholder"),
+ ];
+ const { rects, activeIndex, overIndex } = layout(items, "source", "blocked");
+ const collisionRect = rects[overIndex]!;
+ return {
+ active: {
+ id: "source",
+ data: { current: {} },
+ rect: { current: { initial: rects[activeIndex]!, translated: collisionRect } },
+ },
+ collisionRect,
+ droppableRects: new Map(items.map((item, index) => [sidebarListItemId(item), rects[index]!])),
+ droppableContainers: items.map((item, index) => ({
+ id: sidebarListItemId(item),
+ key: sidebarListItemId(item),
+ disabled: false,
+ data: { current: {} },
+ node: { current: null },
+ rect: { current: rects[index]! },
+ })),
+ pointerCoordinates: null,
+ } satisfies Parameters[0];
+ }
+
+ it.each([
+ [false, sidebarMarkerId("settled-header")],
+ [true, sidebarMarkerId("pinned-divider")],
+ ] as const)(
+ "rejects unsupported Active instead of selecting %s / %s",
+ (blockedAboveSource, nearbyTarget) => {
+ const args = collisionArgs(blockedAboveSource);
+ const detector = createSidebarCollisionDetection((id) => id !== "blocked");
+ const filtered = closestCenter({
+ ...args,
+ droppableContainers: args.droppableContainers.filter(
+ (container) => container.id !== "blocked",
+ ),
+ });
+ expect(filtered[0]?.id).toBe(nearbyTarget);
+ expect(detector(args).map((collision) => collision.id)).toEqual(["source"]);
+ },
+ );
+
+ it("selects the nearest supported target", () => {
+ const detector = createSidebarCollisionDetection(() => true);
+ expect(detector(collisionArgs())[0]?.id).toBe("blocked");
+ });
+
+ function clampedArgs() {
+ const args = collisionArgs();
+ const pinned = args.droppableRects.get(sidebarMarkerId("pinned-header"))!;
+ const source = args.droppableRects.get("source")!;
+ const collisionRect = {
+ ...source,
+ top: pinned.top - 8,
+ bottom: pinned.top - 8 + source.height,
+ };
+ return {
+ ...args,
+ active: {
+ ...args.active,
+ rect: { current: { initial: source, translated: collisionRect } },
+ },
+ collisionRect,
+ pointerCoordinates: { x: pinned.left + pinned.width / 2, y: pinned.top + 8 },
+ };
+ }
+
+ it("reaches empty Pins with an upward pointer while the card is clamped at the top", () => {
+ const args = clampedArgs();
+ const detector = createSidebarCollisionDetection(() => true, {
+ emptyPins: true,
+ activationY: args.pointerCoordinates.y + 6,
+ });
+ expect(args.droppableRects.get(sidebarMarkerId("pinned-header"))?.height).toBe(0);
+ expect(closestCenter(args)[0]?.id).toBe("source");
+ expect(detector(args)[0]?.id).toBe(sidebarMarkerId("pinned-header"));
+ });
+
+ it.each([
+ { reason: "below the boundary cue", x: 130, y: 109, activationY: 140, emptyPins: true },
+ { reason: "left of the list", x: -1, y: 108, activationY: 140, emptyPins: true },
+ { reason: "right of the list", x: 261, y: 108, activationY: 140, emptyPins: true },
+ { reason: "less than 6px upward", x: 130, y: 108, activationY: 113, emptyPins: true },
+ { reason: "without an activation point", x: 130, y: 108, activationY: null, emptyPins: true },
+ { reason: "with populated Pins", x: 130, y: 108, activationY: 140, emptyPins: false },
+ ])("keeps ordinary collision behavior $reason", ({ x, y, activationY, emptyPins }) => {
+ const detector = createSidebarCollisionDetection(() => true, { emptyPins, activationY });
+ const args = { ...clampedArgs(), pointerCoordinates: { x, y } };
+ expect(detector(args)[0]?.id).toBe("source");
+ });
+
+ it("keeps ordinary collision behavior without pointer coordinates", () => {
+ const detector = createSidebarCollisionDetection(() => true, {
+ emptyPins: true,
+ activationY: 140,
+ });
+ expect(detector({ ...clampedArgs(), pointerCoordinates: null })[0]?.id).toBe("source");
+ });
+
+ it("validates the empty Pins override and caches an unsupported result", () => {
+ const isValid = vi.fn(() => false);
+ const detector = createSidebarCollisionDetection(isValid, {
+ emptyPins: true,
+ activationY: 140,
+ });
+ const args = clampedArgs();
+ expect(detector(args).map((collision) => collision.id)).toEqual(["source"]);
+ expect(detector(args).map((collision) => collision.id)).toEqual(["source"]);
+ expect(isValid.mock.calls).toEqual([[sidebarMarkerId("pinned-header")]]);
+ });
+
+ it("returns no collision if an unsupported target has no source fallback", () => {
+ const args = collisionArgs();
+ const detector = createSidebarCollisionDetection(() => false);
+ expect(
+ detector({
+ ...args,
+ droppableContainers: args.droppableContainers.filter(
+ (container) => container.id !== "source",
+ ),
+ }),
+ ).toEqual([]);
+ });
+
+ it("validates each hovered target once and always allows returning to the source", () => {
+ const args = collisionArgs();
+ const isValid = vi.fn((id: string) => id !== "blocked");
+ const detector = createSidebarCollisionDetection(isValid);
+ expect(detector(args)[0]?.id).toBe("source");
+ expect(
+ detector({
+ ...args,
+ collisionRect: {
+ ...args.collisionRect,
+ top: args.collisionRect.top + 3,
+ bottom: args.collisionRect.bottom + 3,
+ },
+ })[0]?.id,
+ ).toBe("source");
+ expect(detector({ ...args, collisionRect: args.droppableRects.get("source")! })[0]?.id).toBe(
+ "source",
+ );
+ expect(
+ detector({
+ ...args,
+ collisionRect: args.droppableRects.get(sidebarMarkerId("settled-placeholder"))!,
+ })[0]?.id,
+ ).toBe(sidebarMarkerId("settled-placeholder"));
+ expect(isValid.mock.calls).toEqual([["blocked"], [sidebarMarkerId("settled-placeholder")]]);
+ });
+});
+
+describe("sidebar drag projection", () => {
+ const pinned = [
+ pinnedHeader,
+ thread("p1", "pinned"),
+ thread("p2", "pinned"),
+ divider,
+ thread("a1", "active"),
+ settledHeader,
+ thread("s1", "settled"),
+ ];
+
+ it.each([
+ ["p1", "p2"],
+ ["p2", "p1"],
+ ])("preserves existing pinned transforms from %s to %s", (active, over) => {
+ const strategy = createSidebarSortingStrategy({
+ items: pinned,
+ settledOrder: [],
+ settledExpanded: true,
+ });
+ const args = layout(pinned, active, over);
+ for (let index = 0; index < pinned.length; index += 1) {
+ expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index }));
+ }
+ });
+
+ it("keeps the pinned header above the gap when a lower pin moves to the top", () => {
+ const result = preview(
+ { items: pinned, settledOrder: [], settledExpanded: true },
+ "p2",
+ sidebarMarkerId("pinned-header"),
+ );
+ expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
+ expect(result.get("p1")).toEqual({ ...stationary, y: 83 });
+ expect(result.get(sidebarMarkerId("pinned-divider"))).toEqual(stationary);
+ expect(result.get("a1")).toEqual(stationary);
+ });
+
+ it.each([
+ ["a1", "a2"],
+ ["a2", "a1"],
+ ])("uses pinned dragging behavior for Active from %s to %s", (active, over) => {
+ const items = [
+ pinnedHeader,
+ thread("p", "pinned"),
+ divider,
+ thread("a1", "active"),
+ thread("a2", "active"),
+ settledHeader,
+ thread("s", "settled"),
+ ];
+ const strategy = createSidebarSortingStrategy({
+ items,
+ settledOrder: [],
+ settledExpanded: true,
+ });
+ const args = layout(items, active, over);
+ for (let index = 0; index < items.length; index += 1) {
+ expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index }));
+ }
+ });
+
+ it("leaves canonically sorted settled peers in place", () => {
+ const items = [
+ pinnedHeader,
+ divider,
+ marker("active-placeholder"),
+ settledHeader,
+ thread("first", "settled"),
+ thread("second", "settled"),
+ ];
+ const result = preview(
+ { items, settledOrder: ["first", "second"], settledExpanded: true },
+ "second",
+ "first",
+ );
+ expect([...result.values()]).toEqual(items.map(() => stationary));
+ });
+
+ it.each([
+ [sidebarMarkerId("pinned-divider"), 0, 0],
+ ["a1", -83, 0],
+ ["a2", -83, -83],
+ ] as const)(
+ "opens the active pointer slot over %s without adding an empty pinned row",
+ (over, a1Offset, a2Offset) => {
+ const items = [
+ pinnedHeader,
+ thread("p", "pinned"),
+ divider,
+ thread("a1", "active"),
+ thread("a2", "active"),
+ settledHeader,
+ thread("s", "settled"),
+ ];
+ const result = preview({ items, settledOrder: [], settledExpanded: true }, "p", over);
+ expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
+ expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(-83);
+ expect(result.get("a1")?.y).toBe(a1Offset);
+ expect(result.get("a2")?.y).toBe(a2Offset);
+ expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0);
+ },
+ );
+
+ it("keeps the pinned header above the first arriving pin", () => {
+ const items = [
+ pinnedHeader,
+ divider,
+ thread("a1", "active"),
+ thread("a2", "active"),
+ settledHeader,
+ thread("s", "settled"),
+ ];
+ const result = preview(
+ { items, settledOrder: [], settledExpanded: true },
+ "a2",
+ sidebarMarkerId("pinned-header"),
+ );
+ expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
+ expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(83);
+ expect(result.get("a1")?.y).toBe(83);
+ expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0);
+ });
+
+ it.each([
+ ["p", -83, -37],
+ ["s", 0, 46],
+ ] as const)(
+ "replaces the empty Active target when %s enters",
+ (active, dividerOffset, settledOffset) => {
+ const items = [
+ pinnedHeader,
+ thread("p", "pinned"),
+ divider,
+ marker("active-placeholder"),
+ settledHeader,
+ thread("s", "settled"),
+ ];
+ const result = preview(
+ { items, settledOrder: [], settledExpanded: true },
+ active,
+ sidebarMarkerId("active-placeholder"),
+ );
+ expect(result.get(sidebarMarkerId("active-placeholder"))?.scaleY).toBe(0);
+ expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(dividerOffset);
+ expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(settledOffset);
+ },
+ );
+
+ it("uses the canonical settled rank and the destination's slim height", () => {
+ const items = [
+ pinnedHeader,
+ thread("p", "pinned"),
+ divider,
+ thread("a", "active"),
+ settledHeader,
+ thread("s1", "settled"),
+ thread("s2", "settled"),
+ ];
+ const result = preview(
+ { items, settledOrder: ["s1", "a", "s2"], settledExpanded: true },
+ "a",
+ "s2",
+ );
+ expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-46);
+ expect(result.get("s1")?.y).toBe(-46);
+ expect(result.get("s2")?.y).toBe(-9);
+ });
+
+ it.each([
+ ["a1", 83],
+ ["a2", 0],
+ ] as const)(
+ "reserves a full card at the pointer slot over %s when a slim row enters Active",
+ (over, firstOffset) => {
+ const items = [
+ pinnedHeader,
+ thread("p", "pinned"),
+ divider,
+ thread("a1", "active"),
+ thread("a2", "active"),
+ settledHeader,
+ thread("s", "settled"),
+ ];
+ const result = preview({ items, settledOrder: [], settledExpanded: true }, "s", over);
+ expect(result.get("a1")?.y).toBe(firstOffset);
+ expect(result.get("a2")?.y).toBe(83);
+ expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(83);
+ },
+ );
+
+ it("removes the snoozed header when its last row leaves", () => {
+ const items = [
+ pinnedHeader,
+ thread("p", "pinned"),
+ divider,
+ thread("a", "active"),
+ marker("snoozed-header"),
+ thread("z", "snoozed"),
+ settledHeader,
+ thread("s", "settled"),
+ ];
+ const result = preview({ items, settledOrder: [], settledExpanded: true }, "z", "a");
+ expect(result.get(sidebarMarkerId("snoozed-header"))?.scaleY).toBe(0);
+ expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(13);
+ expect(result.get("s")?.y).toBe(13);
+ });
+
+ it("keeps a collapsed settled target without inserting a hidden row", () => {
+ const items = [
+ pinnedHeader,
+ thread("p", "pinned"),
+ divider,
+ thread("a1", "active"),
+ thread("a2", "active"),
+ settledHeader,
+ marker("settled-placeholder"),
+ ];
+ const result = preview(
+ { items, settledOrder: [], settledExpanded: false },
+ "a2",
+ sidebarMarkerId("settled-placeholder"),
+ );
+ expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-83);
+ expect(result.get(sidebarMarkerId("settled-placeholder"))).toEqual({ ...stationary, y: -83 });
+ });
+
+ it("preserves a collapsed snoozed header while another section changes", () => {
+ const items = [
+ pinnedHeader,
+ thread("p", "pinned"),
+ divider,
+ thread("a", "active"),
+ marker("snoozed-header"),
+ settledHeader,
+ marker("settled-placeholder"),
+ ];
+ const result = preview(
+ { items, settledOrder: [], settledExpanded: false },
+ "a",
+ sidebarMarkerId("settled-placeholder"),
+ );
+ expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: -46 });
+ });
+
+ it("derives missing card geometry from the measured root scale", () => {
+ const items = [
+ pinnedHeader,
+ divider,
+ marker("active-placeholder"),
+ settledHeader,
+ thread("s", "settled"),
+ ];
+ const result = preview(
+ { items, settledOrder: [], settledExpanded: true },
+ "s",
+ sidebarMarkerId("pinned-header"),
+ 0.75,
+ );
+ expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
+ expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(62.5);
+ expect(result.get(sidebarMarkerId("active-placeholder"))?.y).toBe(62.5);
+ });
+
+ it("updates the projection when the target or measured geometry changes", () => {
+ const strategy = createSidebarSortingStrategy({
+ items: pinned,
+ settledOrder: [],
+ settledExpanded: true,
+ });
+ const args = layout(pinned, "p1", "p1");
+ expect(strategy({ ...args, index: 2 })?.y).toBe(0);
+ expect(strategy({ ...args, index: 2, overIndex: 4 })?.y).toBe(-83);
+ const smaller = layout(pinned, "p1", "a1", 0.75);
+ expect(strategy({ ...smaller, index: 2 })?.y).toBe(-62.5);
+ });
+
+ it("uses measured placeholder sizing when card height differs from its default", () => {
+ const items = [
+ pinnedHeader,
+ thread("p", "pinned"),
+ divider,
+ thread("a", "active"),
+ settledHeader,
+ marker("settled-placeholder"),
+ ];
+ const strategy = createSidebarSortingStrategy({
+ items,
+ settledOrder: [],
+ settledExpanded: false,
+ });
+ const args = layout(items, "a", sidebarMarkerId("settled-placeholder"), 1, 78);
+ expect(strategy({ ...args, index: 4 })?.y).toBe(-42);
+ });
+
+ it("keeps the route row visible after a settled drop pushes it beyond the page", () => {
+ const items = [
+ pinnedHeader,
+ thread("p", "pinned"),
+ divider,
+ thread("a", "active"),
+ settledHeader,
+ thread("s", "settled"),
+ ];
+ const input = {
+ items,
+ settledOrder: ["a", "s", "hidden"],
+ settledExpanded: true,
+ settledVisibleCount: 1,
+ };
+ const withRoute = preview({ ...input, routeThreadKey: "s" }, "a", "s");
+ const withoutRoute = preview(input, "a", "s");
+ expect(withRoute.get("s")).toEqual({ ...stationary, y: -9 });
+ expect(withoutRoute.get("s")?.scaleY).toBe(0);
+ });
+
+ it("reserves the next page row when a visible settled thread leaves", () => {
+ const items = [
+ pinnedHeader,
+ thread("p", "pinned"),
+ divider,
+ thread("a", "active"),
+ settledHeader,
+ thread("s1", "settled"),
+ thread("route", "settled"),
+ ];
+ const result = preview(
+ {
+ items,
+ settledOrder: ["s1", "hidden", "route"],
+ settledExpanded: true,
+ settledVisibleCount: 1,
+ routeThreadKey: "route",
+ },
+ "s1",
+ "a",
+ );
+ expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(83);
+ expect(result.get("route")?.y).toBe(83);
+ });
+
+ it("keeps the dropped route thread visible in a collapsed settled shelf", () => {
+ const items = [
+ pinnedHeader,
+ thread("p", "pinned"),
+ divider,
+ thread("a", "active"),
+ settledHeader,
+ marker("settled-placeholder"),
+ ];
+ const result = preview(
+ {
+ items,
+ settledOrder: ["a", "hidden"],
+ settledExpanded: false,
+ settledVisibleCount: 1,
+ routeThreadKey: "a",
+ },
+ "a",
+ sidebarMarkerId("settled-placeholder"),
+ );
+ expect(result.get(sidebarMarkerId("settled-placeholder"))?.scaleY).toBe(0);
+ expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-46);
+ });
+
+ it("preserves hidden snoozed membership when the only rendered route row leaves", () => {
+ const items = [
+ pinnedHeader,
+ thread("p", "pinned"),
+ divider,
+ thread("a", "active"),
+ marker("snoozed-header"),
+ thread("z", "snoozed"),
+ settledHeader,
+ thread("s", "settled"),
+ ];
+ const result = preview(
+ {
+ items,
+ settledOrder: ["s"],
+ settledExpanded: true,
+ snoozedThreadCount: 2,
+ },
+ "z",
+ "a",
+ );
+ expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: 83 });
+ expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(46);
+ });
+});
diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts
new file mode 100644
index 000000000000..9c27a31c2224
--- /dev/null
+++ b/apps/web/src/components/Sidebar.drag.ts
@@ -0,0 +1,175 @@
+import { closestCenter, type CollisionDetection } from "@dnd-kit/core";
+import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable";
+import {
+ resolveSidebarDropTarget,
+ sidebarListItemId,
+ sidebarMarkerId,
+ type SidebarListItem,
+ type SidebarListMarker,
+ type SidebarSection,
+} from "./Sidebar.logic";
+
+const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 };
+const hidden = { ...stationary, scaleY: 0 };
+type ThreadItem = Extract;
+type Layout = Parameters[0];
+
+/** Reject the nearest unsupported target without selecting another section.
+ * Recreate this detector when drop eligibility changes. */
+export function createSidebarCollisionDetection(
+ isValidTarget: (id: string) => boolean,
+ options: { emptyPins?: boolean; activationY?: number | null } = {},
+): CollisionDetection {
+ const validity = new Map();
+ const pinnedHeaderId = sidebarMarkerId("pinned-header");
+ return (args) => {
+ let collisions = closestCenter(args);
+ const pinnedRect = options.emptyPins ? args.droppableRects.get(pinnedHeaderId) : undefined;
+ const pointer = args.pointerCoordinates;
+ // The card itself is clamped by the scroll container. An upward pointer
+ // gesture can still reach the empty pinned boundary without reserving a row.
+ if (
+ pinnedRect &&
+ pointer &&
+ options.activationY != null &&
+ pointer.y <= options.activationY - 6 &&
+ pointer.y <= pinnedRect.top + 8 &&
+ pointer.x >= pinnedRect.left &&
+ pointer.x <= pinnedRect.right
+ ) {
+ const pinned = collisions.find((collision) => collision.id === pinnedHeaderId);
+ if (pinned) {
+ collisions = [pinned, ...collisions.filter((collision) => collision !== pinned)];
+ }
+ }
+ const nearest = collisions[0];
+ if (!nearest || nearest.id === args.active.id) return collisions;
+ const id = String(nearest.id);
+ const valid = validity.get(id) ?? isValidTarget(id);
+ validity.set(id, valid);
+ return valid ? collisions : collisions.filter((collision) => collision.id === args.active.id);
+ };
+}
+
+/** Preview the committed section layout without moving or mounting DOM nodes.
+ * A zero scaleY marks rows/markers to hide while retaining their measured nodes. */
+export function createSidebarSortingStrategy(input: {
+ items: readonly SidebarListItem[];
+ settledOrder: readonly string[];
+ settledExpanded: boolean;
+ settledVisibleCount?: number;
+ routeThreadKey?: string | null;
+ snoozedThreadCount?: number;
+ cardHeight?: number;
+ slimHeight?: number;
+}): SortingStrategy {
+ const { items } = input;
+ const indices = new Map(items.map((item, index) => [sidebarListItemId(item), index]));
+ let previous: Pick | undefined;
+ let transforms: ReturnType[] | null = [];
+
+ function project({ rects, activeIndex, overIndex }: Layout) {
+ const active = items[activeIndex];
+ const over = items[overIndex];
+ if (active?.kind !== "thread" || !over || !rects[0]) return [];
+ const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over));
+ if (!target) return [];
+ if (target.section === active.section && over.kind === "thread")
+ return target.section === "settled" ? [] : null;
+ const groups: Record = {
+ pinned: [],
+ active: [],
+ snoozed: [],
+ settled: [],
+ };
+ let cardHeight = input.cardHeight;
+ let slimHeight = input.slimHeight;
+ for (const [index, item] of items.entries()) {
+ if (item.kind === "marker") {
+ if (item.marker.endsWith("placeholder")) slimHeight ??= rects[index]?.height;
+ continue;
+ }
+ if (item.section === "pinned" || item.section === "active")
+ cardHeight ??= rects[index]?.height;
+ else slimHeight ??= rects[index]?.height;
+ if (item.key !== active.key) groups[item.section].push(item);
+ }
+ // Cards are 4.875rem + 0.25rem padding; slim rows/placeholders are h-9.
+ const scale = slimHeight !== undefined ? slimHeight / 36 : (cardHeight ?? 82) / 82;
+ cardHeight ??= 82 * scale;
+ slimHeight ??= 36 * scale;
+ const group = groups[target.section];
+ const order =
+ target.section === "pinned"
+ ? target.pinnedOrder
+ : target.section === "settled"
+ ? input.settledOrder
+ : target.activeOrder;
+ const ranks = new Map(order.map((key, index) => [key, index]));
+ const rank = ranks.get(active.key) ?? Number.POSITIVE_INFINITY;
+ const index = group.findIndex(
+ (item) => (ranks.get(item.key) ?? Number.POSITIVE_INFINITY) > rank,
+ );
+ group.splice(index < 0 ? group.length : index, 0, { ...active, section: target.section });
+ const settledOrder = (
+ input.settledOrder.length > 0 ? input.settledOrder : groups.settled.map((item) => item.key)
+ ).filter((key) => key !== active.key || target.section === "settled");
+ const visible = input.settledExpanded
+ ? settledOrder.slice(0, input.settledVisibleCount ?? settledOrder.length)
+ : [];
+ const routeKey = input.routeThreadKey;
+ if (routeKey && settledOrder.includes(routeKey) && !visible.includes(routeKey)) {
+ visible.push(routeKey);
+ }
+ groups.settled = visible.map((key) => ({ kind: "thread", key, section: "settled" }));
+ const projected: SidebarListItem[] = [];
+ const marker = (name: SidebarListMarker) => projected.push({ kind: "marker", marker: name });
+ const section = (name: "active" | "settled") => {
+ if (groups[name].length > 0) projected.push(...groups[name]);
+ else marker(`${name}-placeholder`);
+ };
+ marker("pinned-header");
+ projected.push(...groups.pinned);
+ marker("pinned-divider");
+ section("active");
+ if (
+ groups.snoozed.length > 0 ||
+ ((active.section !== "snoozed" || (input.snoozedThreadCount ?? 0) > 1) &&
+ items.some((item) => item.kind === "marker" && item.marker === "snoozed-header"))
+ ) {
+ marker("snoozed-header");
+ projected.push(...groups.snoozed);
+ }
+ marker("settled-header");
+ section("settled");
+ const result = items.map(() => hidden);
+ let top = rects[0].top;
+ for (const item of projected) {
+ const index = indices.get(sidebarListItemId(item));
+ const rect = index === undefined ? undefined : rects[index];
+ if (index !== undefined && rect) result[index] = { ...stationary, y: top - rect.top };
+ const fallback =
+ item.kind === "thread" && (item.section === "pinned" || item.section === "active")
+ ? cardHeight
+ : slimHeight;
+ const moved = item.kind === "thread" && item.key === active.key;
+ top += (moved ? fallback : (rect?.height ?? fallback)) + 1;
+ }
+ result[activeIndex] = stationary;
+ return result;
+ }
+
+ return (args) => {
+ if (
+ previous?.rects !== args.rects ||
+ previous.activeIndex !== args.activeIndex ||
+ previous.overIndex !== args.overIndex
+ ) {
+ previous = args;
+ transforms = project(args);
+ }
+ return transforms === null
+ ? verticalListSortingStrategy(args)
+ : (transforms[args.index] ?? stationary);
+ };
+}
diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts
index fee7ef181a59..edf51aa2d8f5 100644
--- a/apps/web/src/components/Sidebar.logic.test.ts
+++ b/apps/web/src/components/Sidebar.logic.test.ts
@@ -3,7 +3,8 @@ import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";
import {
- animatePinnedLayoutChanges,
+ animateSidebarLayoutChanges,
+ applySidebarThreadDrop,
archiveSelectedThreadEntries,
buildBulkTitleRegenerationContextMenuItem,
buildBulkUnpinContextMenuItem,
@@ -32,14 +33,21 @@ import {
shouldRecedeSidebarThread,
sortLogicalProjectsForSidebar,
sortSettledThreadsForSidebar,
+ resolveSidebarDropTarget,
pinOrderKeyBetween,
planPinnedReorder,
+ planSidebarThreadDrop,
+ sidebarMarkerId,
+ sidebarListItemId,
sortPinnedThreadsForSidebar,
sortThreadsForSidebar,
sortProjectsForSidebar,
sortScopedProjectsForSidebar,
shouldCreateNewThreadInCurrentProject,
THREAD_JUMP_HINT_SHOW_DELAY_MS,
+ type SidebarListItem,
+ type SidebarListMarker,
+ type SidebarSection,
} from "./Sidebar.logic";
import {
EnvironmentId,
@@ -53,12 +61,13 @@ import {
DEFAULT_INTERACTION_MODE,
DEFAULT_RUNTIME_MODE,
type Project,
+ type SidebarThreadSummary,
type Thread,
} from "../types";
const localEnvironmentId = EnvironmentId.make("environment-local");
-describe("animatePinnedLayoutChanges", () => {
+describe("animateSidebarLayoutChanges", () => {
const baseArgs: Parameters[0] = {
active: null,
containerId: "pinned-threads",
@@ -76,11 +85,11 @@ describe("animatePinnedLayoutChanges", () => {
it("does not replay layout movement after the pointer is released", () => {
expect(defaultAnimateLayoutChanges(baseArgs)).toBe(true);
- expect(animatePinnedLayoutChanges(baseArgs)).toBe(false);
+ expect(animateSidebarLayoutChanges(baseArgs)).toBe(false);
});
it("keeps layout movement while the user is sorting", () => {
- expect(animatePinnedLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true);
+ expect(animateSidebarLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true);
});
});
@@ -1025,6 +1034,715 @@ describe("planPinnedReorder", () => {
});
});
+describe("resolveSidebarDropTarget", () => {
+ const thread = (key: string, section: SidebarSection): SidebarListItem => ({
+ kind: "thread",
+ key,
+ section,
+ });
+ const marker = (marker: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker });
+ // Pinned p1 p2 | Active a1 a2 | Snoozed z1 | Settled s1
+ const items: readonly SidebarListItem[] = [
+ marker("pinned-header"),
+ thread("p1", "pinned"),
+ thread("p2", "pinned"),
+ marker("pinned-divider"),
+ thread("a1", "active"),
+ thread("a2", "active"),
+ marker("snoozed-header"),
+ thread("z1", "snoozed"),
+ marker("settled-header"),
+ thread("s1", "settled"),
+ ];
+ const resolve = (activeKey: string, overId: string) =>
+ resolveSidebarDropTarget(items, activeKey, overId);
+
+ it("keeps marker-like scoped thread keys draggable", () => {
+ const key = "marker:pinned-header";
+ const list: SidebarListItem[] = [
+ marker("pinned-header"),
+ thread(key, "pinned"),
+ thread("env:other", "pinned"),
+ marker("pinned-divider"),
+ ];
+ expect(new Set(list.map(sidebarListItemId)).size).toBe(list.length);
+ expect(resolveSidebarDropTarget(list, key, "env:other")).toEqual({
+ section: "pinned",
+ pinnedOrder: ["env:other", key],
+ activeOrder: [],
+ });
+ });
+
+ it("reads the section off the markers above the gap", () => {
+ expect(resolve("p1", "a2")).toEqual({
+ section: "active",
+ pinnedOrder: ["p2"],
+ activeOrder: ["a1", "a2", "p1"],
+ });
+ expect(resolve("a1", "s1")).toEqual({
+ section: "settled",
+ pinnedOrder: ["p1", "p2"],
+ activeOrder: ["a2"],
+ });
+ expect(resolve("s1", "a1")).toEqual({
+ section: "active",
+ pinnedOrder: ["p1", "p2"],
+ activeOrder: ["s1", "a1", "a2"],
+ });
+ });
+
+ it("uses arrayMove placement, so a marker hovered from below lands above it", () => {
+ // Dragging a1 up onto the divider: the divider shifts down, a1 becomes
+ // the last pinned row.
+ expect(resolve("a1", sidebarMarkerId("pinned-divider"))).toEqual({
+ section: "pinned",
+ pinnedOrder: ["p1", "p2", "a1"],
+ activeOrder: ["a2"],
+ });
+ // Dragging p2 down onto the divider: the divider shifts up, p2 is the
+ // first inbox row — an unpin.
+ expect(resolve("p2", sidebarMarkerId("pinned-divider"))).toEqual({
+ section: "active",
+ pinnedOrder: ["p1"],
+ activeOrder: ["p2", "a1", "a2"],
+ });
+ // Same on the Settled header: from above it settles; from below the
+ // gap lands in whatever is above the header — here the snoozed shelf,
+ // which is never a target.
+ expect(resolve("a2", sidebarMarkerId("settled-header"))?.section).toBe("settled");
+ expect(resolve("s1", sidebarMarkerId("settled-header"))).toBeNull();
+ });
+
+ it("reorders inside the pinned block with the dragged row at the over slot", () => {
+ expect(resolve("p1", "p2")).toEqual({
+ section: "pinned",
+ pinnedOrder: ["p2", "p1"],
+ activeOrder: ["a1", "a2"],
+ });
+ expect(resolve("a2", "p1")).toEqual({
+ section: "pinned",
+ pinnedOrder: ["a2", "p1", "p2"],
+ activeOrder: ["a1"],
+ });
+ });
+
+ it("lands first in Pinned when hovering its permanent header", () => {
+ expect(resolve("a2", sidebarMarkerId("pinned-header"))).toEqual({
+ section: "pinned",
+ pinnedOrder: ["a2", "p1", "p2"],
+ activeOrder: ["a1"],
+ });
+ });
+
+ it("reorders active rows in either direction without changing sections", () => {
+ for (const [from, to] of [
+ ["a1", "a2"],
+ ["a2", "a1"],
+ ] as const) {
+ expect(resolve(from, to)).toEqual({
+ section: "active",
+ pinnedOrder: ["p1", "p2"],
+ activeOrder: ["a2", "a1"],
+ });
+ }
+ });
+
+ it("never lands in the snoozed shelf", () => {
+ expect(resolve("a1", "z1")).toBeNull();
+ expect(resolve("a1", sidebarMarkerId("snoozed-header"))).toBeNull();
+ });
+
+ it("lands on a placeholder when the section is otherwise empty", () => {
+ const withPlaceholder: readonly SidebarListItem[] = [
+ marker("pinned-header"),
+ marker("pinned-divider"),
+ thread("a1", "active"),
+ marker("settled-header"),
+ marker("settled-placeholder"),
+ ];
+ expect(
+ resolveSidebarDropTarget(withPlaceholder, "a1", sidebarMarkerId("settled-placeholder")),
+ ).toEqual({ section: "settled", pinnedOrder: [], activeOrder: [] });
+ });
+
+ it("lands in empty Pinned using its header without an extra placeholder", () => {
+ const emptyPinned: readonly SidebarListItem[] = [
+ marker("pinned-header"),
+ marker("pinned-divider"),
+ thread("a1", "active"),
+ ];
+ expect(resolveSidebarDropTarget(emptyPinned, "a1", sidebarMarkerId("pinned-header"))).toEqual({
+ section: "pinned",
+ pinnedOrder: ["a1"],
+ activeOrder: [],
+ });
+ expect(resolveSidebarDropTarget(emptyPinned, "a1", sidebarMarkerId("pinned-divider"))).toEqual({
+ section: "pinned",
+ pinnedOrder: ["a1"],
+ activeOrder: [],
+ });
+ });
+
+ it("rejects ids that are not in the list", () => {
+ expect(resolve("a1", "nope")).toBeNull();
+ expect(resolve("nope", "a1")).toBeNull();
+ expect(resolve(sidebarMarkerId("pinned-divider"), "a1")).toBeNull();
+ });
+});
+
+describe("planSidebarThreadDrop", () => {
+ const pinnedKeysById = new Map([
+ ["p1", "f"],
+ ["p2", "m"],
+ ["p3", "t"],
+ ]);
+ const activeKeysById = new Map([
+ ["a1", "f"],
+ ["a2", "m"],
+ ["a3", "t"],
+ ]);
+ const plan = (
+ overrides: Partial[0], "target">> & {
+ activeKey: string;
+ activeSection: "pinned" | "active" | "snoozed" | "settled";
+ target: Omit[0]["target"], "activeOrder"> & {
+ activeOrder?: readonly string[];
+ };
+ },
+ ) =>
+ planSidebarThreadDrop({
+ pinnedOrder: ["p1", "p2", "p3"],
+ pinnedKeysById,
+ activeOrder: ["a1", "a2", "a3"],
+ activeKeysById,
+ ...overrides,
+ target: { activeOrder: [], ...overrides.target },
+ });
+
+ it("allows old-server pinned reordering while rejecting settlement", () => {
+ expect(
+ plan({
+ activeKey: "p1",
+ activeSection: "pinned",
+ supportsSettlement: false,
+ target: { section: "pinned", pinnedOrder: ["p2", "p1", "p3"] },
+ }).kind,
+ ).toBe("reorder-pinned");
+ expect(
+ plan({
+ activeKey: "p1",
+ activeSection: "pinned",
+ supportsSettlement: false,
+ target: { section: "settled", pinnedOrder: ["p2", "p3"] },
+ }),
+ ).toEqual({ kind: "none" });
+ });
+
+ it.each(["pinned", "active"] as const)("reserves hidden %s slots during a drop", (section) => {
+ const order = section === "pinned" ? ["p2", "p1", "p3"] : ["a2", "a1", "a3"];
+ const keys = new Map(section === "pinned" ? pinnedKeysById : activeKeysById);
+ const moved = section === "pinned" ? "p1" : "a1";
+ const reserved = pinOrderKeyBetween(keys.get(order[0]!)!, keys.get(order[2]!)!)!;
+ keys.set("snoozed", reserved);
+ const result = plan({
+ activeKey: moved,
+ activeSection: section,
+ pinnedKeysById: section === "pinned" ? keys : pinnedKeysById,
+ activeKeysById: section === "active" ? keys : activeKeysById,
+ target: {
+ section,
+ pinnedOrder: section === "pinned" ? order : [],
+ activeOrder: section === "active" ? order : [],
+ },
+ });
+ if (result.kind !== "reorder-pinned" && result.kind !== "move-active")
+ throw new Error("Expected reorder");
+ expect(result.assignments).toHaveLength(1);
+ expect(result.assignments[0]!.orderKey).not.toBe(reserved);
+ });
+
+ it.each([
+ { key: "p2", section: "pinned" as const, unpin: true, unsettle: false, unsnooze: false },
+ { key: "s1", section: "settled" as const, unpin: false, unsettle: true, unsnooze: false },
+ { key: "z1", section: "snoozed" as const, unpin: false, unsettle: false, unsnooze: true },
+ ])("moves a $section thread to the chosen Active slot", (source) => {
+ const order = ["a1", source.key, "a2", "a3"];
+ const result = plan({
+ activeKey: source.key,
+ activeSection: source.section,
+ target: { section: "active", pinnedOrder: [], activeOrder: order },
+ });
+ expect(result).toEqual({
+ kind: "move-active",
+ order,
+ assignments: [{ id: source.key, orderKey: expect.any(String) }],
+ unpin: source.unpin,
+ unsettle: source.unsettle,
+ unsnooze: source.unsnooze,
+ });
+ if (result.kind !== "move-active") return;
+ const key = result.assignments[0]!.orderKey;
+ expect(key > "f" && key < "m").toBe(true);
+ });
+
+ it.each([
+ { state: "pinned", activePinned: true, activeSettled: false },
+ { state: "settled", activePinned: false, activeSettled: true },
+ { state: "pinned and settled", activePinned: true, activeSettled: true },
+ ])("clears a snoozed thread's $state state before waking it into Active", (hiddenState) => {
+ expect(
+ plan({
+ activeKey: "z1",
+ activeSection: "snoozed",
+ activePinned: hiddenState.activePinned,
+ activeSettled: hiddenState.activeSettled,
+ target: {
+ section: "active",
+ pinnedOrder: ["p1", "p2", "p3"],
+ activeOrder: ["a1", "z1", "a2", "a3"],
+ },
+ }),
+ ).toEqual({
+ kind: "move-active",
+ order: ["a1", "z1", "a2", "a3"],
+ assignments: [{ id: "z1", orderKey: expect.any(String) }],
+ unpin: hiddenState.activePinned,
+ unsettle: hiddenState.activeSettled,
+ unsnooze: true,
+ });
+ });
+
+ it("saves the first Active reorder, then moves only one key on subsequent drops", () => {
+ const rows = ["a1", "a2", "a3"].map((id, index) => ({
+ id,
+ createdAt: new Date(Date.UTC(2026, 8, 4, 12 - index)).toISOString(),
+ activeOrderKey: null as string | null,
+ }));
+ const firstOrder = ["a2", "a3", "a1"];
+ const first = plan({
+ activeKey: "a1",
+ activeSection: "active",
+ target: { section: "active", pinnedOrder: [], activeOrder: firstOrder },
+ activeKeysById: new Map(rows.map((row) => [row.id, row.activeOrderKey])),
+ });
+ expect(first.kind).toBe("move-active");
+ if (first.kind !== "move-active") return;
+ expect(first.unpin || first.unsettle || first.unsnooze).toBe(false);
+ const savedKeys = new Map(first.assignments.map(({ id, orderKey }) => [id, orderKey]));
+ const savedRows = rows.map((row) => ({
+ ...row,
+ activeOrderKey: savedKeys.get(row.id) ?? null,
+ }));
+ expect(sortThreadsForSidebar(savedRows).map((row) => row.id)).toEqual(firstOrder);
+
+ const secondOrder = ["a2", "a1", "a3"];
+ const second = plan({
+ activeKey: "a1",
+ activeSection: "active",
+ activeOrder: firstOrder,
+ activeKeysById: savedKeys,
+ target: { section: "active", pinnedOrder: [], activeOrder: secondOrder },
+ });
+ expect(second.kind).toBe("move-active");
+ if (second.kind !== "move-active") return;
+ expect(second.assignments).toEqual([{ id: "a1", orderKey: expect.any(String) }]);
+ const finalRows = savedRows.map((row) =>
+ row.id === "a1" ? { ...row, activeOrderKey: second.assignments[0]!.orderKey } : row,
+ );
+ expect(sortThreadsForSidebar(finalRows).map((row) => row.id)).toEqual(secondOrder);
+ });
+
+ it("does not write when an Active thread is dropped in its existing slot", () => {
+ expect(
+ plan({
+ activeKey: "a2",
+ activeSection: "active",
+ target: { section: "active", pinnedOrder: [], activeOrder: ["a1", "a2", "a3"] },
+ }),
+ ).toEqual({ kind: "none" });
+ });
+
+ it("requires Active ordering support only for the threads whose keys must change", () => {
+ const input = {
+ activeKey: "a3",
+ activeSection: "active" as const,
+ target: { section: "active" as const, pinnedOrder: [], activeOrder: ["a1", "a3", "a2"] },
+ activeReorderableKeys: new Set(["a3"]),
+ };
+ expect(plan(input).kind).toBe("move-active");
+ expect(
+ plan({
+ ...input,
+ activeKeysById: new Map([
+ ["a1", null],
+ ["a2", "m"],
+ ["a3", "t"],
+ ]),
+ }),
+ ).toEqual({ kind: "none" });
+ expect(plan({ ...input, activeReorderableKeys: new Set() })).toEqual({ kind: "none" });
+ });
+
+ it("settles anything dropped on Settled except a settled thread", () => {
+ const target = { section: "settled", pinnedOrder: ["p1", "p2", "p3"] } as const;
+ expect(plan({ activeKey: "a1", activeSection: "active", target })).toEqual({ kind: "settle" });
+ expect(plan({ activeKey: "p1", activeSection: "pinned", target })).toEqual({ kind: "settle" });
+ expect(plan({ activeKey: "z1", activeSection: "snoozed", target })).toEqual({ kind: "settle" });
+ expect(plan({ activeKey: "s1", activeSection: "settled", target })).toEqual({ kind: "none" });
+ });
+
+ it("pins a foreign thread with a key between its new neighbors", () => {
+ const result = plan({
+ activeKey: "a1",
+ activeSection: "active",
+ target: { section: "pinned", pinnedOrder: ["p1", "a1", "p2", "p3"] },
+ });
+ expect(result.kind).toBe("pin");
+ if (result.kind !== "pin") return;
+ expect(result.order).toEqual(["p1", "a1", "p2", "p3"]);
+ expect(result.orderKey).toBeDefined();
+ expect(result.orderKey! > "f" && result.orderKey! < "m").toBe(true);
+ expect(result.extraAssignments).toEqual([]);
+
+ const empty = plan({
+ activeKey: "a1",
+ activeSection: "active",
+ target: { section: "pinned", pinnedOrder: ["a1"] },
+ pinnedOrder: [],
+ pinnedKeysById: new Map(),
+ });
+ expect(empty.kind).toBe("pin");
+ if (empty.kind !== "pin") return;
+ expect(empty.orderKey).toBeDefined();
+ });
+
+ it("reorders an already-pinned snoozed thread after pinning wakes it", () => {
+ const result = plan({
+ activeKey: "z1",
+ activeSection: "snoozed",
+ activePinned: true,
+ target: { section: "pinned", pinnedOrder: ["p1", "z1", "p2", "p3"] },
+ pinnedKeysById: new Map([...pinnedKeysById, ["z1", "x"]]),
+ });
+ expect(result.kind).toBe("pin");
+ if (result.kind !== "pin") return;
+ expect(result.extraAssignments).toEqual([{ id: "z1", orderKey: result.orderKey }]);
+ expect(result.orderKey! > "f" && result.orderKey! < "m").toBe(true);
+ });
+
+ it("uses keyed disabled neighbors as anchors without writing to them", () => {
+ const insertion = plan({
+ activeKey: "a1",
+ activeSection: "active",
+ target: { section: "pinned", pinnedOrder: ["p1", "a1", "p2", "p3"] },
+ reorderableKeys: new Set(["a1"]),
+ });
+ expect(insertion.kind).toBe("pin");
+ if (insertion.kind !== "pin") return;
+ expect(insertion.order).toEqual(["p1", "a1", "p2", "p3"]);
+ expect(insertion.orderKey! > "f" && insertion.orderKey! < "m").toBe(true);
+ expect(insertion.extraAssignments).toEqual([]);
+
+ const reorder = plan({
+ activeKey: "p3",
+ activeSection: "pinned",
+ target: { section: "pinned", pinnedOrder: ["p1", "p3", "p2"] },
+ reorderableKeys: new Set(["p3"]),
+ });
+ expect(reorder.kind).toBe("reorder-pinned");
+ if (reorder.kind !== "reorder-pinned") return;
+ expect(reorder.assignments).toEqual([{ id: "p3", orderKey: expect.any(String) }]);
+ expect(reorder.assignments[0]!.orderKey > "f").toBe(true);
+ expect(reorder.assignments[0]!.orderKey < "m").toBe(true);
+ });
+
+ it.each([
+ {
+ activeKey: "a1",
+ activeSection: "active" as const,
+ order: ["p1", "p3", "a1", "p2"],
+ },
+ { activeKey: "p1", activeSection: "pinned" as const, order: ["p3", "p1", "p2"] },
+ ])("rejects $activeSection drops that require rewriting a disabled neighbor", (source) => {
+ expect(
+ plan({
+ activeKey: source.activeKey,
+ activeSection: source.activeSection,
+ target: { section: "pinned", pinnedOrder: source.order },
+ pinnedOrder: ["p1", "p3", "p2"],
+ pinnedKeysById: new Map([
+ ["p1", "f"],
+ ["p2", null],
+ ["p3", "t"],
+ ]),
+ reorderableKeys: new Set(["p1", "p3", source.activeKey]),
+ }),
+ ).toEqual({ kind: "none" });
+ });
+
+ it("rewrites the section when a foreign thread lands next to a keyless pin", () => {
+ const result = plan({
+ activeKey: "a1",
+ activeSection: "active",
+ target: { section: "pinned", pinnedOrder: ["p1", "a1", "p2", "p3"] },
+ pinnedKeysById: new Map([
+ ["p1", null],
+ ["p2", "m"],
+ ["p3", "t"],
+ ]),
+ });
+ expect(result.kind).toBe("pin");
+ if (result.kind !== "pin") return;
+ expect(result.orderKey).toBeDefined();
+ expect(result.extraAssignments.map((entry) => entry.id)).toEqual(["p1", "p2", "p3"]);
+ const byId = new Map([
+ ["a1", result.orderKey!],
+ ...result.extraAssignments.map((e) => [e.id, e.orderKey] as const),
+ ]);
+ const ordered = result.order.map((id) => byId.get(id)!);
+ expect([...ordered].sort()).toEqual(ordered);
+ });
+
+ it("reorders within the pinned block, and is a no-op when the order is unchanged", () => {
+ const down = plan({
+ activeKey: "p1",
+ activeSection: "pinned",
+ target: { section: "pinned", pinnedOrder: ["p2", "p3", "p1"] },
+ });
+ expect(down.kind).toBe("reorder-pinned");
+ if (down.kind !== "reorder-pinned") return;
+ expect(down.assignments).toEqual([{ id: "p1", orderKey: expect.any(String) }]);
+ expect(down.assignments[0]!.orderKey > "t").toBe(true);
+
+ expect(
+ plan({
+ activeKey: "p1",
+ activeSection: "pinned",
+ target: { section: "pinned", pinnedOrder: ["p1", "p2", "p3"] },
+ }),
+ ).toEqual({ kind: "none" });
+ });
+});
+
+describe("applySidebarThreadDrop", () => {
+ const createdAt = "2026-03-09T08:00:00.000Z";
+ const earlier = "2026-03-09T09:00:00.000Z";
+ const now = "2026-03-09T12:00:00.000Z";
+ const serverNow = "2026-03-09T12:00:01.000Z";
+ const wakeAt = "2026-03-10T08:00:00.000Z";
+ const thread = (overrides: Partial = {}) => ({
+ id: ThreadId.make("dragged"),
+ title: "Keep this title",
+ createdAt,
+ updatedAt: earlier,
+ latestUserMessageAt: null,
+ latestTurn: null,
+ pinnedAt: null,
+ pinOrderKey: null,
+ activeOrderKey: null,
+ snoozedAt: null,
+ snoozedUntil: null,
+ settledAt: null,
+ settledOverride: null,
+ unsettledAt: null,
+ ...overrides,
+ });
+ const newer = thread({ id: ThreadId.make("newer"), createdAt: "2026-03-09T11:00:00.000Z" });
+
+ it("previews an un-settle at the same active position as the eventual server row", () => {
+ const source = thread({ settledOverride: "settled", settledAt: earlier });
+ const preview = applySidebarThreadDrop(source, "active", now);
+ const final = {
+ ...source,
+ settledOverride: "active" as const,
+ settledAt: null,
+ unsettledAt: serverNow,
+ };
+ expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual([
+ "dragged",
+ "newer",
+ ]);
+ expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual(
+ sortThreadsForSidebar([newer, final]).map((row) => row.id),
+ );
+ });
+
+ it.each([
+ { state: "pin", pinnedAt: earlier, pinOrderKey: "m", snoozedAt: null, snoozedUntil: null },
+ {
+ state: "snooze",
+ pinnedAt: null,
+ pinOrderKey: null,
+ snoozedAt: earlier,
+ snoozedUntil: wakeAt,
+ },
+ {
+ state: "snoozed pin",
+ pinnedAt: earlier,
+ pinOrderKey: "m",
+ snoozedAt: earlier,
+ snoozedUntil: wakeAt,
+ },
+ ])("preserves the active sort anchor when clearing a $state", ({ state: _state, ...parked }) => {
+ const source = thread({ ...parked, settledOverride: "active", unsettledAt: earlier });
+ const preview = applySidebarThreadDrop(source, "active", now);
+ const final = {
+ ...source,
+ pinnedAt: null,
+ pinOrderKey: null,
+ snoozedAt: null,
+ snoozedUntil: null,
+ updatedAt: serverNow,
+ };
+ expect(preview).toEqual({ ...final, updatedAt: source.updatedAt });
+ expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual([
+ "newer",
+ "dragged",
+ ]);
+ expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual(
+ sortThreadsForSidebar([newer, final]).map((row) => row.id),
+ );
+ });
+
+ it("clears underlying pinning and settlement when waking into Active", () => {
+ const source = thread({
+ pinnedAt: earlier,
+ pinOrderKey: "m",
+ snoozedAt: earlier,
+ snoozedUntil: wakeAt,
+ settledOverride: "settled",
+ settledAt: earlier,
+ });
+ expect(applySidebarThreadDrop(source, "active", now)).toEqual({
+ ...source,
+ pinnedAt: null,
+ pinOrderKey: null,
+ snoozedAt: null,
+ snoozedUntil: null,
+ settledOverride: "active",
+ settledAt: null,
+ unsettledAt: now,
+ });
+ });
+
+ it("previews a new settlement at the same position as the eventual server row", () => {
+ const source = thread({
+ pinnedAt: earlier,
+ pinOrderKey: "m",
+ snoozedAt: earlier,
+ snoozedUntil: wakeAt,
+ unsettledAt: earlier,
+ });
+ const preview = applySidebarThreadDrop(source, "settled", now);
+ const final = {
+ ...source,
+ pinnedAt: null,
+ pinOrderKey: null,
+ snoozedAt: null,
+ snoozedUntil: null,
+ settledOverride: "settled" as const,
+ settledAt: serverNow,
+ unsettledAt: null,
+ };
+ const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt };
+ expect(preview).toEqual({ ...final, settledAt: now });
+ expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([
+ "dragged",
+ "newer",
+ ]);
+ expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual(
+ sortSettledThreadsForSidebar([existing, final]).map((row) => row.id),
+ );
+ });
+
+ it("retains a snoozed thread's earlier settlement and its position when settling again", () => {
+ const source = thread({
+ snoozedAt: earlier,
+ snoozedUntil: wakeAt,
+ settledOverride: "settled",
+ settledAt: earlier,
+ });
+ const preview = applySidebarThreadDrop(source, "settled", now);
+ const final = { ...source, snoozedAt: null, snoozedUntil: null };
+ const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt };
+ expect(preview).toEqual(final);
+ expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([
+ "newer",
+ "dragged",
+ ]);
+ });
+
+ it("pins a settled thread at its requested slot and projects the re-entry stamp", () => {
+ const source = thread({
+ snoozedAt: earlier,
+ snoozedUntil: wakeAt,
+ settledOverride: "settled",
+ settledAt: earlier,
+ });
+ const original = { ...source };
+ const preview = applySidebarThreadDrop(source, "pinned", now, "m");
+ expect(preview).toEqual({
+ ...source,
+ pinnedAt: now,
+ pinOrderKey: "m",
+ snoozedAt: null,
+ snoozedUntil: null,
+ settledOverride: "active",
+ settledAt: null,
+ unsettledAt: now,
+ });
+ expect(
+ sortPinnedThreadsForSidebar([
+ thread({ id: ThreadId.make("after"), pinnedAt: earlier, pinOrderKey: "t" }),
+ preview,
+ thread({ id: ThreadId.make("before"), pinnedAt: earlier, pinOrderKey: "f" }),
+ ]).map((row) => row.id),
+ ).toEqual(["before", "dragged", "after"]);
+ expect(source).toEqual(original);
+ });
+
+ it("keeps an existing pin's timestamp and key unless the drop supplies a new key", () => {
+ const source = thread({
+ pinnedAt: earlier,
+ pinOrderKey: "t",
+ snoozedAt: earlier,
+ snoozedUntil: wakeAt,
+ settledOverride: "active",
+ unsettledAt: earlier,
+ });
+ const unchangedSlot = applySidebarThreadDrop(source, "pinned", now);
+ expect(unchangedSlot).toEqual({ ...source, snoozedAt: null, snoozedUntil: null });
+ expect(applySidebarThreadDrop(source, "pinned", now, "m")).toEqual({
+ ...unchangedSlot,
+ pinOrderKey: "m",
+ });
+ });
+
+ it("keeps an Active drop at its chosen position after unpinning", () => {
+ const source = thread({ pinnedAt: earlier, pinOrderKey: "g", activeOrderKey: "z" });
+ const preview = applySidebarThreadDrop(source, "active", now, "m");
+ expect(preview).toMatchObject({ pinnedAt: null, pinOrderKey: null, activeOrderKey: "m" });
+ expect(
+ sortThreadsForSidebar([
+ thread({ id: ThreadId.make("after"), activeOrderKey: "t" }),
+ preview,
+ thread({ id: ThreadId.make("before"), activeOrderKey: "f" }),
+ ]).map((row) => row.id),
+ ).toEqual(["before", "dragged", "after"]);
+ });
+
+ it("clears the manual Active position when settling so reopening returns to the top", () => {
+ const source = thread({ activeOrderKey: "z" });
+ const settled = applySidebarThreadDrop(source, "settled", now);
+ expect(settled.activeOrderKey).toBeNull();
+ const reopened = applySidebarThreadDrop(settled, "active", serverNow);
+ expect(sortThreadsForSidebar([newer, reopened]).map((row) => row.id)).toEqual([
+ "dragged",
+ "newer",
+ ]);
+ });
+});
+
describe("sortPinnedThreadsForSidebar", () => {
const pinnable = (input: { id: string; createdAt: string; pinOrderKey?: string | null }) => ({
id: input.id,
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index de06237ae41d..0b406c92c442 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -7,8 +7,8 @@ import {
import type { ContextMenuItem } from "@t3tools/contracts";
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
import type { AsyncResult } from "effect/unstable/reactivity";
+import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
import {
- activeThreadAnchorTimestampMs,
getThreadSortTimestamp,
resolveSettledThreadTimestamp,
sortThreads,
@@ -81,12 +81,257 @@ export function useRetainedValue(key: string | null, value: T | null): T | nu
return key !== null && retained.current?.key === key ? retained.current.value : null;
}
-// The list already reaches its destination through sortable transforms while
-// the pointer is down. dnd-kit's default also animates the committed DOM order
-// after release, replaying the same movement across every affected row.
-export const animatePinnedLayoutChanges: AnimateLayoutChanges = (args) =>
+// Sidebar.motion handles ordinary section changes. Sortable transforms own
+// dragging; replaying their committed DOM order would animate the drop twice.
+export const animateSidebarLayoutChanges: AnimateLayoutChanges = (args) =>
args.isSorting ? defaultAnimateLayoutChanges(args) : false;
+// Rows and section markers share one sortable list. The separators resolve
+// the lifecycle action; Sidebar.drag previews the resulting layout. Pinned
+// and active threads keep the dragged position; settled threads use time
+// order. Snoozed rows can leave the shelf, but dropping into it is not
+// supported because snoozing requires a wake time.
+
+export type SidebarSection = "pinned" | "active" | "snoozed" | "settled";
+
+/** Sortable ids: thread rows use their scoped key; structural items use a
+ colon-free prefix: scoped thread keys always contain a colon. */
+const SIDEBAR_MARKER_PREFIX = "sidebar-marker-";
+
+export type SidebarListMarker =
+ /** The top boundary is also a landing target when there are no pins. */
+ | "pinned-header"
+ /** Stand-in rows so an empty section has somewhere for the gap to open. */
+ | "active-placeholder"
+ | "settled-placeholder"
+ /** The boundary between pinned and active rows. */
+ | "pinned-divider"
+ | "snoozed-header"
+ | "settled-header";
+
+export function sidebarMarkerId(marker: SidebarListMarker): string {
+ return `${SIDEBAR_MARKER_PREFIX}${marker}`;
+}
+
+export type SidebarListItem =
+ | { readonly kind: "thread"; readonly key: string; readonly section: SidebarSection }
+ | { readonly kind: "marker"; readonly marker: SidebarListMarker };
+
+export function sidebarListItemId(item: SidebarListItem): string {
+ return item.kind === "thread" ? item.key : sidebarMarkerId(item.marker);
+}
+
+/** The section a slot belongs to, read off the markers around it: from
+ the top down, everything before the pinned divider is pinned, then the
+ inbox until the snoozed header, the shelf until the settled header,
+ then settled. */
+function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): SidebarSection {
+ let section: SidebarSection = "pinned";
+ for (let i = 0; i < index && i < items.length; i += 1) {
+ const item = items[i]!;
+ if (item.kind !== "marker") continue;
+ if (item.marker === "pinned-divider") section = "active";
+ else if (item.marker === "snoozed-header") section = "snoozed";
+ else if (item.marker === "settled-header") section = "settled";
+ }
+ return section;
+}
+
+/** Resolve the destination section and manual order from an arrayMove across
+ * the separators. The snoozed shelf is never a destination. */
+export type SidebarDropTarget = {
+ readonly section: "pinned" | "active" | "settled";
+ readonly pinnedOrder: readonly string[];
+ readonly activeOrder: readonly string[];
+};
+
+export function resolveSidebarDropTarget(
+ items: readonly SidebarListItem[],
+ activeKey: string,
+ overId: string,
+): SidebarDropTarget | null {
+ const activeIndex = items.findIndex((item) => sidebarListItemId(item) === activeKey);
+ const overIndex = items.findIndex((item) => sidebarListItemId(item) === overId);
+ if (activeIndex === -1 || overIndex === -1 || items[activeIndex]?.kind !== "thread") return null;
+ const moved = items.filter((_, index) => index !== activeIndex);
+ moved.splice(overIndex, 0, items[activeIndex]!);
+ const section = sectionAtSidebarSlot(moved, overIndex);
+ if (section === "snoozed") return null;
+ const pinnedOrder: string[] = [];
+ const activeOrder: string[] = [];
+ let currentSection: SidebarSection = "pinned";
+ for (const item of moved) {
+ if (item.kind === "marker") {
+ if (item.marker === "pinned-divider") currentSection = "active";
+ else if (item.marker === "snoozed-header" || item.marker === "settled-header") break;
+ } else if (currentSection === "pinned") pinnedOrder.push(item.key);
+ else activeOrder.push(item.key);
+ }
+ return { section, pinnedOrder, activeOrder };
+}
+
+export type SidebarThreadDropPlan =
+ | { readonly kind: "none" }
+ /** Within the pinned block: the existing key writes. */
+ | {
+ readonly kind: "reorder-pinned";
+ readonly order: readonly string[];
+ readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
+ }
+ /** From another section into the pinned block. Fresh pins take `orderKey`
+ on the pin command. `extraAssignments` land afterward, including the
+ moved row when it was already pinned beneath a snooze. */
+ | {
+ readonly kind: "pin";
+ readonly order: readonly string[];
+ readonly orderKey: string | undefined;
+ readonly extraAssignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
+ }
+ | {
+ readonly kind: "move-active";
+ readonly order: readonly string[];
+ readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
+ readonly unpin: boolean;
+ readonly unsettle: boolean;
+ readonly unsnooze: boolean;
+ }
+ | { readonly kind: "settle" };
+
+export function planSidebarThreadDrop(input: {
+ readonly activeKey: string;
+ readonly activeSection: SidebarSection;
+ /** Snoozed threads can retain pinning and settlement beneath the shelf. */
+ readonly activePinned?: boolean;
+ readonly activeSettled?: boolean;
+ readonly supportsSettlement?: boolean;
+ readonly target: SidebarDropTarget;
+ /** All pinned keys in displayed order before the drop. */
+ readonly pinnedOrder: readonly string[];
+ readonly pinnedKeysById: ReadonlyMap;
+ readonly reorderableKeys?: ReadonlySet;
+ readonly activeOrder: readonly string[];
+ readonly activeKeysById: ReadonlyMap;
+ readonly activeReorderableKeys?: ReadonlySet;
+}): SidebarThreadDropPlan {
+ const {
+ activeKey,
+ activeSection,
+ activePinned = activeSection === "pinned",
+ activeSettled = activeSection === "settled",
+ target,
+ pinnedOrder,
+ pinnedKeysById,
+ reorderableKeys,
+ activeOrder,
+ activeKeysById,
+ activeReorderableKeys,
+ } = input;
+ if (input.supportsSettlement === false && (target.section === "settled" || activeSettled)) {
+ return { kind: "none" };
+ }
+ switch (target.section) {
+ case "active": {
+ const order = target.activeOrder;
+ if (
+ activeSection === "active" &&
+ order.length === activeOrder.length &&
+ order.every((key, index) => key === activeOrder[index])
+ ) {
+ return { kind: "none" };
+ }
+ const assignments = planPinnedReorder({
+ orderedIds: order,
+ keysById: activeKeysById,
+ movedId: activeKey,
+ });
+ if (activeReorderableKeys && assignments.some(({ id }) => !activeReorderableKeys.has(id))) {
+ return { kind: "none" };
+ }
+ return {
+ kind: "move-active",
+ order,
+ assignments,
+ unpin: activePinned,
+ unsettle: activeSettled,
+ unsnooze: activeSection === "snoozed",
+ };
+ }
+ case "settled":
+ return activeSection === "settled" ? { kind: "none" } : { kind: "settle" };
+ case "pinned": {
+ const order = target.pinnedOrder;
+ // Dropped back where it started: nothing to write.
+ if (
+ activeSection === "pinned" &&
+ order.length === pinnedOrder.length &&
+ order.every((key, index) => key === pinnedOrder[index])
+ ) {
+ return { kind: "none" };
+ }
+ const assignments = planPinnedReorder({
+ orderedIds: order,
+ keysById: pinnedKeysById,
+ movedId: activeKey,
+ });
+ if (reorderableKeys && assignments.some(({ id }) => !reorderableKeys.has(id))) {
+ return { kind: "none" };
+ }
+ if (activeSection === "pinned") {
+ return assignments.length === 0
+ ? { kind: "none" }
+ : { kind: "reorder-pinned", order, assignments };
+ }
+ return {
+ kind: "pin",
+ order,
+ orderKey: assignments.find((assignment) => assignment.id === activeKey)?.orderKey,
+ extraAssignments: activePinned
+ ? assignments
+ : assignments.filter((assignment) => assignment.id !== activeKey),
+ };
+ }
+ }
+}
+
+/** Project a drop's lifecycle fields before sorting its destination. Reusing
+ the server's re-entry rules keeps the preview in place when events arrive. */
+export function applySidebarThreadDrop<
+ T extends Pick<
+ SidebarThreadSummary,
+ | "pinnedAt"
+ | "pinOrderKey"
+ | "activeOrderKey"
+ | "snoozedAt"
+ | "snoozedUntil"
+ | "settledAt"
+ | "settledOverride"
+ | "unsettledAt"
+ >,
+>(thread: T, section: "pinned" | "active" | "settled", now: string, orderKey?: string): T {
+ const wasSettled = thread.settledOverride === "settled";
+ const awake = { ...thread, snoozedAt: null, snoozedUntil: null };
+ if (section === "settled") {
+ return {
+ ...awake,
+ pinnedAt: null,
+ pinOrderKey: null,
+ activeOrderKey: null,
+ settledOverride: "settled",
+ settledAt: wasSettled ? (thread.settledAt ?? now) : now,
+ unsettledAt: null,
+ };
+ }
+ const resumed = wasSettled
+ ? { ...awake, settledOverride: "active" as const, settledAt: null, unsettledAt: now }
+ : awake;
+ return {
+ ...resumed,
+ pinnedAt: section === "pinned" ? (thread.pinnedAt ?? now) : null,
+ pinOrderKey: section === "pinned" ? (orderKey ?? thread.pinOrderKey) : null,
+ ...(section === "active" && orderKey !== undefined ? { activeOrderKey: orderKey } : {}),
+ };
+}
+
type SidebarProject = {
id: string;
title: string;
@@ -605,26 +850,7 @@ function firstValidTimestamp(
return null;
}
-// Sidebar sort: static order, newest anchor on top. Activity NEVER reorders
-// the list — a row holds its position between lifecycle transitions, so the
-// screen only moves when a thread enters or leaves the active list. The
-// anchor is creation time until an un-settle re-anchors it (see
-// activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the
-// top instead of sinking back to its creation-order slot. Status (including
-// pending approval) is carried by each card's edge strip, not by position.
-export function sortThreadsForSidebar<
- T extends {
- readonly id: string;
- readonly createdAt: string;
- readonly unsettledAt?: string | null | undefined;
- },
->(threads: readonly T[]): T[] {
- return [...threads].toSorted(
- (left, right) =>
- activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) ||
- left.id.localeCompare(right.id),
- );
-}
+export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";
// Pinned-reorder key math and the keyed sort live in client-runtime
// (state/thread-sort) so web and mobile compute identical pinned orders.
diff --git a/apps/web/src/components/Sidebar.motion.test.ts b/apps/web/src/components/Sidebar.motion.test.ts
new file mode 100644
index 000000000000..f8570553058a
--- /dev/null
+++ b/apps/web/src/components/Sidebar.motion.test.ts
@@ -0,0 +1,339 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+import { createSidebarListMotion } from "./Sidebar.motion";
+
+class TestAnimation {
+ progress: number | null = 0;
+ playState: AnimationPlayState = "running";
+ effect = { getComputedTiming: () => ({ progress: this.progress }) };
+ cancel = vi.fn(() => {
+ this.playState = "idle";
+ });
+ private onFinish: (() => void) | undefined;
+ addEventListener(_type: string, listener: () => void) {
+ this.onFinish = listener;
+ }
+ finish() {
+ this.playState = "finished";
+ this.onFinish?.();
+ }
+}
+
+class TestRow {
+ offsetTop = 0;
+ offsetLeft = 4;
+ offsetWidth = 260;
+ namespaceURI = "http://www.w3.org/1999/xhtml";
+ dragTranslate = 0;
+ style: Record = {};
+ inert = false;
+ attributes: { name: string; value: string }[] = [];
+ children: TestRow[] = [];
+ clones: TestRow[] = [];
+ remove = vi.fn();
+ animations: TestAnimation[] = [];
+ constructor(
+ readonly name: string,
+ public offsetHeight = 82,
+ ) {}
+ getBoundingClientRect() {
+ return { top: this.offsetTop + this.dragTranslate, height: this.offsetHeight };
+ }
+ setAttribute(name: string, value: string) {
+ this.removeAttribute(name);
+ this.attributes.push({ name, value });
+ }
+ removeAttribute(name: string) {
+ this.attributes = this.attributes.filter((attribute) => attribute.name !== name);
+ }
+ querySelectorAll(_selector: string): TestRow[] {
+ return this.children.flatMap((child) => [child, ...child.querySelectorAll("*")]);
+ }
+ cloneNode(_deep: boolean): TestRow {
+ const clone = new TestRow(`${this.name} clone`, this.offsetHeight);
+ clone.namespaceURI = this.namespaceURI;
+ clone.style = { ...this.style };
+ clone.attributes = this.attributes.map((attribute) => ({ ...attribute }));
+ clone.children = this.children.map((child) => child.cloneNode(true));
+ this.clones.push(clone);
+ return clone;
+ }
+ animate = vi.fn((_frames: Keyframe[], _options: KeyframeAnimationOptions) => {
+ const animation = new TestAnimation();
+ this.animations.push(animation);
+ return animation;
+ });
+}
+
+function fixture(rows: TestRow[]) {
+ const media = { matches: false };
+ const parent = {
+ children: rows,
+ ownerDocument: { defaultView: { matchMedia: () => media } },
+ append(node: TestRow) {
+ parent.children.push(node);
+ node.remove.mockImplementation(() => {
+ parent.children = parent.children.filter((child) => child !== node);
+ });
+ },
+ };
+ function layout(next: TestRow[]) {
+ let top = 8;
+ for (const row of next) {
+ row.offsetTop = top;
+ top += row.offsetHeight + 1;
+ }
+ parent.children = [
+ ...next,
+ ...parent.children.filter((row) => row.style.position === "absolute"),
+ ];
+ }
+ layout(rows);
+ const motion = createSidebarListMotion(parent as unknown as HTMLUListElement);
+ return { motion, layout, media, parent };
+}
+
+function expectMove(row: TestRow, offset: number) {
+ expect(row.animate).toHaveBeenLastCalledWith(
+ [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }],
+ { duration: 150, easing: "ease-out" },
+ );
+}
+
+beforeEach(() => vi.stubGlobal("HTMLElement", TestRow));
+afterEach(() => vi.unstubAllGlobals());
+
+describe("sidebar list motion", () => {
+ it("moves a retained Active row into Settled with its displaced peers", () => {
+ const pinnedHeader = new TestRow("Pinned", 0);
+ const pinned = new TestRow("pin");
+ const divider = new TestRow("Active", 0);
+ const a = new TestRow("a");
+ const b = new TestRow("b");
+ const settledHeader = new TestRow("Settled", 32);
+ const settled = new TestRow("settled", 36);
+ const rows = [pinnedHeader, pinned, divider, a, b, settledHeader, settled];
+ const { motion, layout } = fixture(rows);
+ motion.update(true);
+ expect(rows.every((row) => row.animations.length === 0)).toBe(true);
+
+ a.offsetHeight = 36;
+ layout([pinnedHeader, pinned, divider, b, settledHeader, settled, a]);
+ motion.update(true);
+ expectMove(a, -153);
+ expectMove(b, 83);
+ expectMove(settledHeader, 83);
+ expectMove(settled, 83);
+ expect(pinned.animate).not.toHaveBeenCalled();
+ expect(divider.animate).not.toHaveBeenCalled();
+ });
+
+ it("refreshes the drop baseline without replay and animates the next ordinary move", () => {
+ const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")];
+ const { motion, layout } = fixture([a, b, c]);
+ motion.update(true);
+ motion.suspend();
+ a.dragTranslate = 300;
+ b.dragTranslate = -83;
+ motion.update(false);
+ motion.suspend();
+ layout([b, a, c]);
+ a.dragTranslate = b.dragTranslate = 0;
+ motion.update(true);
+ expect([a, b, c].every((row) => row.animations.length === 0)).toBe(true);
+
+ layout([c, b, a]);
+ motion.update(true);
+ expectMove(c, 166);
+ expectMove(b, -83);
+ expectMove(a, -83);
+ });
+
+ it("does not carry a canceled drag's transformed position into the next move", () => {
+ const a = new TestRow("a");
+ const b = new TestRow("b");
+ const { motion, layout } = fixture([a, b]);
+ motion.update(true);
+ motion.suspend();
+ a.dragTranslate = 500;
+ b.dragTranslate = -83;
+ motion.update(false);
+ motion.suspend();
+ a.dragTranslate = b.dragTranslate = 0;
+ motion.update(true);
+ expect(a.animate).not.toHaveBeenCalled();
+ expect(b.animate).not.toHaveBeenCalled();
+
+ layout([b, a]);
+ motion.update(true);
+ expectMove(a, -83);
+ expectMove(b, 83);
+ });
+
+ it("retargets rapid changes from the current visual position", () => {
+ const [a, b, c] = [new TestRow("a", 99), new TestRow("b", 99), new TestRow("c", 99)];
+ const { motion, layout } = fixture([a, b, c]);
+ motion.update(true);
+ layout([b, c, a]);
+ motion.update(true);
+ expectMove(a, -200);
+ const first = a.animations[0]!;
+ first.progress = 0.25;
+
+ layout([b, a, c]);
+ motion.update(true);
+ expect(first.cancel).toHaveBeenCalledOnce();
+ expectMove(a, -50);
+ first.finish();
+ motion.suspend();
+ expect(a.animations[1]!.cancel).toHaveBeenCalledOnce();
+ });
+
+ it("keeps an uninterrupted movement when the layout position does not change", () => {
+ const a = new TestRow("a");
+ const b = new TestRow("b");
+ const { motion, layout } = fixture([a, b]);
+ motion.update(true);
+ layout([b, a]);
+ motion.update(true);
+ a.animations[0]!.progress = 0.5;
+ motion.update(true);
+ expect(a.animate).toHaveBeenCalledOnce();
+ expect(a.animations[0]!.cancel).not.toHaveBeenCalled();
+ });
+
+ it("cancels owned motion on suspension and never animates a disposed list", () => {
+ const a = new TestRow("a");
+ const b = new TestRow("b");
+ const { motion, layout } = fixture([a, b]);
+ motion.update(true);
+ layout([b, a]);
+ motion.update(true);
+ motion.suspend();
+ expect(a.animations[0]!.cancel).toHaveBeenCalledOnce();
+ expect(b.animations[0]!.cancel).toHaveBeenCalledOnce();
+ motion.update(false);
+ layout([a, b]);
+ motion.update(true);
+ motion.dispose();
+ expect(a.animations[1]!.cancel).toHaveBeenCalledOnce();
+ layout([b, a]);
+ motion.update(true);
+ expect(a.animate).toHaveBeenCalledTimes(2);
+ });
+
+ it("fades a collapsed-shelf exit at its current visual box and a new wake in", () => {
+ const a = new TestRow("a");
+ const b = new TestRow("b");
+ const fresh = new TestRow("new");
+ a.setAttribute("data-thread-item", "a");
+ a.children = [new TestRow("button")];
+ a.children[0]!.setAttribute("id", "thread-control");
+ a.children[0]!.setAttribute("data-testid", "thread-control");
+ a.children[0]!.setAttribute("data-state", "open");
+ const icon = new TestRow("provider icon");
+ icon.namespaceURI = "http://www.w3.org/2000/svg";
+ icon.setAttribute("id", "provider-mask");
+ icon.setAttribute("mask", "url(#provider-mask)");
+ a.children.push(icon);
+ const { motion, layout, parent } = fixture([a, b]);
+ motion.update(true);
+ layout([b, a]);
+ motion.update(true);
+ a.animations[0]!.progress = 0.5;
+ layout([b, fresh]);
+ motion.update(true);
+ expect(a.animations[0]!.cancel).toHaveBeenCalledOnce();
+ expect(fresh.animate).toHaveBeenLastCalledWith([{ opacity: 0 }, { opacity: 1 }], {
+ duration: 150,
+ easing: "ease-out",
+ });
+ const clone = a.clones[0]!;
+ expect(clone.style).toMatchObject({
+ position: "absolute",
+ top: "49.5px",
+ left: "4px",
+ width: "260px",
+ height: "82px",
+ transform: "none",
+ pointerEvents: "none",
+ });
+ expect(clone.inert).toBe(true);
+ expect(clone.attributes).toEqual([{ name: "aria-hidden", value: "true" }]);
+ expect(clone.children[0]!.attributes).toEqual([{ name: "data-state", value: "open" }]);
+ expect(clone.children[1]!.attributes).toEqual(icon.attributes);
+ expect(clone.animate).toHaveBeenCalledWith([{ opacity: 1 }, { opacity: 0 }], {
+ duration: 150,
+ easing: "ease-out",
+ });
+ expect(parent.children.includes(clone)).toBe(true);
+ motion.update(true);
+ expect(clone.animations).toHaveLength(1);
+ expect(clone.clones).toHaveLength(0);
+ clone.animations[0]!.finish();
+ expect(parent.children.includes(clone)).toBe(false);
+ });
+
+ it("clears exit clones on pickup and does not fade the release commit", () => {
+ const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")];
+ const { motion, layout, parent } = fixture([a, b]);
+ motion.update(false);
+ layout([b]);
+ motion.update(true);
+ const clone = a.clones[0]!;
+ motion.suspend();
+ expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce();
+ expect(parent.children.includes(clone)).toBe(false);
+ motion.update(false);
+ motion.suspend();
+ layout([c]);
+ motion.update(true);
+ expect(b.clones).toHaveLength(0);
+ expect(c.animations).toHaveLength(0);
+ layout([c, a]);
+ motion.update(true);
+ expect(a.animate).toHaveBeenCalledWith([{ opacity: 0 }, { opacity: 1 }], {
+ duration: 150,
+ easing: "ease-out",
+ });
+ motion.dispose();
+ expect(a.animations.at(-1)!.cancel).toHaveBeenCalledOnce();
+ });
+
+ it("carries entry opacity into a quick exit and removes artifacts on a silent update", () => {
+ const a = new TestRow("a");
+ const marker = new TestRow("boundary", 0);
+ const { motion, layout, parent } = fixture([marker]);
+ motion.update(false);
+ layout([marker, a]);
+ motion.update(true);
+ a.animations[0]!.progress = 0.4;
+ layout([]);
+ motion.update(true);
+ expect(marker.clones).toHaveLength(0);
+ const clone = a.clones[0]!;
+ expect(clone.animate).toHaveBeenCalledWith([{ opacity: 0.4 }, { opacity: 0 }], {
+ duration: 150,
+ easing: "ease-out",
+ });
+ motion.update(false);
+ expect(parent.children).toEqual([]);
+ expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce();
+ });
+
+ it("respects reduced motion while keeping the next baseline fresh", () => {
+ const a = new TestRow("a");
+ const b = new TestRow("b");
+ const { motion, layout, media } = fixture([a, b]);
+ motion.update(true);
+ media.matches = true;
+ layout([b, a]);
+ motion.update(true);
+ expect(a.animate).not.toHaveBeenCalled();
+ media.matches = false;
+ layout([a, b]);
+ motion.update(true);
+ expectMove(a, 83);
+ expectMove(b, -83);
+ });
+});
diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts
new file mode 100644
index 000000000000..065e22be1b30
--- /dev/null
+++ b/apps/web/src/components/Sidebar.motion.ts
@@ -0,0 +1,168 @@
+const motionTiming = { duration: 150, easing: "ease-out" };
+
+type RowPosition = { top: number; left: number; width: number; height: number };
+
+function progress(animation: Animation) {
+ return animation.playState === "finished"
+ ? 1
+ : (animation.effect?.getComputedTiming().progress ?? 0);
+}
+
+/** Animate rows between their layout positions. The list must be
+ * positioned so every direct child's offsetTop has the same origin. */
+export function createSidebarListMotion(parent: HTMLUListElement) {
+ let positions: Map | null = null;
+ let disposed = false;
+ const reducedMotion = parent.ownerDocument.defaultView?.matchMedia(
+ "(prefers-reduced-motion: reduce)",
+ );
+ const running = new Map();
+ const entering = new Map();
+ const exiting = new Map();
+
+ const remainingOffset = (node: HTMLElement) => {
+ const current = running.get(node);
+ return current ? current.offset * (1 - progress(current.animation)) : 0;
+ };
+ const clearFades = () => {
+ for (const animation of [...entering.values(), ...exiting.values()]) animation.cancel();
+ for (const node of exiting.keys()) node.remove();
+ entering.clear();
+ exiting.clear();
+ };
+ const fadeOut = (node: HTMLElement, position: RowPosition) => {
+ if (position.height === 0) return;
+ // React owns the removed row; only a noninteractive copy stays for the fade.
+ const clone = node.cloneNode(true) as HTMLElement;
+ for (const element of [clone, ...clone.querySelectorAll("*")]) {
+ for (const attribute of Array.from(element.attributes)) {
+ if (
+ (attribute.name === "id" && element.namespaceURI !== "http://www.w3.org/2000/svg") ||
+ attribute.name === "data-thread-item" ||
+ attribute.name === "data-thread-selection-safe" ||
+ attribute.name === "data-testid"
+ ) {
+ element.removeAttribute(attribute.name);
+ }
+ }
+ }
+ clone.setAttribute("aria-hidden", "true");
+ clone.inert = true;
+ Object.assign(clone.style, {
+ position: "absolute",
+ top: `${position.top + remainingOffset(node)}px`,
+ left: `${position.left}px`,
+ width: `${position.width}px`,
+ height: `${position.height}px`,
+ margin: "0",
+ boxSizing: "border-box",
+ contentVisibility: "visible",
+ transform: "none",
+ transition: "none",
+ pointerEvents: "none",
+ });
+ parent.append(clone);
+ const entry = entering.get(node);
+ const animation = clone.animate(
+ [{ opacity: entry ? progress(entry) : 1 }, { opacity: 0 }],
+ motionTiming,
+ );
+ exiting.set(clone, animation);
+ animation.addEventListener(
+ "finish",
+ () => {
+ clone.remove();
+ exiting.delete(clone);
+ },
+ { once: true },
+ );
+ };
+
+ const cancel = (node: HTMLElement) => {
+ running.get(node)?.animation.cancel();
+ running.delete(node);
+ };
+ const suspend = () => {
+ for (const node of running.keys()) cancel(node);
+ clearFades();
+ positions = null;
+ };
+
+ return {
+ update(animate: boolean) {
+ if (disposed) return;
+ const next = new Map(
+ Array.from(parent.children)
+ .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node))
+ .map((node) => [
+ node,
+ {
+ top: node.offsetTop,
+ left: node.offsetLeft,
+ width: node.offsetWidth,
+ height: node.offsetHeight,
+ },
+ ]),
+ );
+ const shouldAnimate = animate && positions !== null && !reducedMotion?.matches;
+ if (!shouldAnimate) clearFades();
+ else {
+ for (const [node, position] of positions!) {
+ if (!next.has(node)) fadeOut(node, position);
+ }
+ }
+ for (const [node, animation] of entering) {
+ if (!next.has(node)) {
+ animation.cancel();
+ entering.delete(node);
+ }
+ }
+ for (const node of running.keys()) {
+ if (!shouldAnimate || !next.has(node)) cancel(node);
+ }
+ if (shouldAnimate) {
+ for (const [node, position] of next) {
+ const previousTop = positions?.get(node)?.top;
+ if (previousTop === undefined) {
+ if (position.height > 0) {
+ const animation = node.animate([{ opacity: 0 }, { opacity: 1 }], motionTiming);
+ entering.set(node, animation);
+ animation.addEventListener(
+ "finish",
+ () => {
+ if (entering.get(node) === animation) entering.delete(node);
+ },
+ { once: true },
+ );
+ }
+ continue;
+ }
+ if (previousTop === position.top) continue;
+ // Computed progress includes the effect's easing. Only our own
+ // translate is carried forward; dnd-kit's transforms are never read.
+ const offset = previousTop + remainingOffset(node) - position.top;
+ cancel(node);
+ if (offset === 0) continue;
+ const animation = node.animate(
+ [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }],
+ motionTiming,
+ );
+ running.set(node, { animation, offset });
+ animation.addEventListener(
+ "finish",
+ () => {
+ if (running.get(node)?.animation === animation) running.delete(node);
+ },
+ { once: true },
+ );
+ }
+ }
+ positions = next;
+ },
+ suspend,
+ dispose() {
+ suspend();
+ disposed = true;
+ },
+ };
+}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index a0d983976379..1a66dbdd9b5f 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -1,20 +1,15 @@
-import { autoAnimate } from "@formkit/auto-animate";
import { useAtomValue } from "@effect/atom-react";
import * as Schema from "effect/Schema";
import {
DndContext,
PointerSensor,
- closestCenter,
useSensor,
useSensors,
type DragEndEvent,
+ type DragOverEvent,
+ type DragStartEvent,
} from "@dnd-kit/core";
-import {
- SortableContext,
- arrayMove,
- useSortable,
- verticalListSortingStrategy,
-} from "@dnd-kit/sortable";
+import { SortableContext, useSortable } from "@dnd-kit/sortable";
import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import {
@@ -63,6 +58,7 @@ import {
memo,
useCallback,
useEffect,
+ useLayoutEffect,
useMemo,
useReducer,
useRef,
@@ -77,6 +73,7 @@ import {
isAtomCommandInterrupted,
settlePromise,
squashAtomCommandFailure,
+ type AtomCommandResult,
} from "@t3tools/client-runtime/state/runtime";
import { isElectron } from "../env";
import {
@@ -137,7 +134,8 @@ import { cn } from "~/lib/utils";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
import {
- animatePinnedLayoutChanges,
+ animateSidebarLayoutChanges,
+ applySidebarThreadDrop,
buildBulkTitleRegenerationContextMenuItem,
buildBulkUnpinContextMenuItem,
deleteSelectedThreadEntries,
@@ -148,14 +146,17 @@ import {
isSidebarNestedLinkClick,
isTrailingDoubleClick,
orderItemsByPreferredIds,
- planPinnedReorder,
+ planSidebarThreadDrop,
reduceSidebarProjectScopeMenuState,
resolveAdjacentThreadId,
+ resolveSidebarDropTarget,
resolveSidebarThreadStatus,
searchSidebarThreadsByTitle,
shouldCreateNewThreadInCurrentProject,
shouldRecedeSidebarThread,
resolveWorkingStartedAt,
+ sidebarListItemId,
+ sidebarMarkerId,
sortLogicalProjectsForSidebar,
sortPinnedThreadsForSidebar,
sortSettledThreadsForSidebar,
@@ -163,8 +164,13 @@ import {
useRetainedValue,
useSidebarRowSubscriptionLease,
useThreadJumpHintVisibility,
+ type SidebarListItem,
+ type SidebarListMarker,
+ type SidebarSection,
} from "./Sidebar.logic";
import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
+import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag";
+import { createSidebarListMotion } from "./Sidebar.motion";
import {
ThreadWorktreeIndicator,
prStatusIndicator,
@@ -473,23 +479,25 @@ function SnoozePopoverButton(props: {
);
}
-// Subset of useSortable applied to a pinned card's root . Listeners go
-// on the whole card (no dedicated handle): the pointer sensor's distance
+// Subset of useSortable applied to a thread row's root . Listeners go
+// on the whole row (no dedicated handle): the pointer sensor's distance
// constraint keeps plain clicks working, and we skip dnd-kit's aria
-// attributes since there is no keyboard sensor and the card body already
+// attributes since there is no keyboard sensor and the row body already
// carries its own button semantics.
-type SortablePinnedRowBag = Pick<
+type SortableThreadRowBag = Pick<
ReturnType,
"listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"
>;
-function SortablePinnedThreadRow(props: {
+function SortableThreadRow(props: {
id: string;
- children: (bag: SortablePinnedRowBag) => ReactNode;
+ disabled: boolean;
+ children: (bag: SortableThreadRowBag) => ReactNode;
}) {
const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.id,
- animateLayoutChanges: animatePinnedLayoutChanges,
+ disabled: { draggable: props.disabled },
+ animateLayoutChanges: animateSidebarLayoutChanges,
});
return props.children({ listeners, setNodeRef, transform, transition, isDragging });
}
@@ -499,6 +507,152 @@ function SortablePinnedThreadRow(props: {
const draftSurfaceClassName = "bg-amber-400/[0.04] hover:bg-amber-400/[0.08]";
const draftPenClassName = "size-3 shrink-0 text-amber-600 dark:text-amber-300/80";
+// Structural list items — the section headers and the
+// empty-section placeholders — take part in the sortable list so they shift
+// with the rows and the gap can open on either side of them. They can't be
+// picked up, and a marker is the sortable `over` when the pointer is on it,
+// which resolveSidebarDropTarget turns into the section the gap sits in.
+function SortableSidebarMarker(props: {
+ marker: SidebarListMarker;
+ className?: string;
+ children?: ReactNode;
+ "data-testid"?: string;
+}) {
+ const { setNodeRef, transform, transition } = useSortable({
+ id: sidebarMarkerId(props.marker),
+ disabled: { draggable: true },
+ animateLayoutChanges: animateSidebarLayoutChanges,
+ });
+ return (
+
+ {props.children}
+
+ );
+}
+
+// Empty targets stay mounted before pickup so starting a drag never changes
+// the list's measured positions.
+function SidebarSectionPlaceholder(props: {
+ marker: "active-placeholder" | "settled-placeholder";
+ label: string;
+ showHint: boolean;
+ isDropTarget: boolean;
+}) {
+ return (
+
+ {props.showHint ? props.label : null}
+
+ );
+}
+
+// Boundary labels overlay the cards' padding during a drag. The measured
+// marker stays empty, so showing a label never pushes a row out of the way.
+function SidebarDragBoundary(props: {
+ marker: "pinned-header" | "pinned-divider";
+ label: string;
+ hint: string | null;
+ visible: boolean;
+ isDropTarget: boolean;
+}) {
+ return (
+
+ {props.visible ? (
+
+
+ {props.label}
+ {props.hint ? {props.hint} : null}
+
+
+
+ ) : null}
+
+ );
+}
+
+// Shelf headers stay visible and keep their measured height while dragging.
+function SidebarSectionHeader(props: {
+ marker: "snoozed-header" | "settled-header";
+ label: string;
+ hint?: string | null;
+ isDropTarget?: boolean;
+ toggle: { expanded: boolean; onToggle: () => void };
+}) {
+ const snoozed = props.marker === "snoozed-header";
+ const className = cn(
+ "flex h-full w-full items-center gap-2 rounded-md border border-dashed border-transparent px-2 text-left text-xs font-medium",
+ snoozed ? "text-blue-600 dark:text-blue-400" : "text-sidebar-muted-foreground/60",
+ props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",
+ );
+ const content = (
+ <>
+ {props.label}
+
+ {props.hint ? {props.hint} : null}
+
+ >
+ );
+ return (
+
+
+ {content}
+
+
+ );
+}
+
// One unsent draft session the user has invested content in. Two lines,
// nothing else: project name, then the typed prompt. All the draft's
// settings (model, env mode, branch, worktree) still travel with it —
@@ -752,10 +906,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
// rows. The marker can unpin the thread when the server supports pinning.
pinningSupported: boolean;
isPinned: boolean;
- // Present only on pinned cards whose server supports reordering: dnd-kit
- // sortable bag applied to the card root so the whole card drags (the
+ // Present on rows whose server supports every drop outcome: dnd-kit
+ // sortable bag applied to the row root so the whole row drags (the
// pointer sensor's distance constraint keeps plain clicks working).
- sortable?: SortablePinnedRowBag | undefined;
+ sortable?: SortableThreadRowBag | undefined;
+ dropSection: SidebarSection | null;
// Compact wake countdown ("2h") for rows in the snoozed shelf.
snoozeWakeLabelText: string | null;
// When a snooze ended (timer or early wake); drives the Woke pill until
@@ -1151,6 +1306,41 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
!isSelected &&
"opacity-70 transition-opacity hover:opacity-100",
);
+ // dnd-kit props for the row root. Same bag on both variants: every row in
+ // the list translates around the gap as the drag passes it.
+ const sortable = props.sortable;
+ const sortableRootProps = sortable
+ ? {
+ ref: sortable.setNodeRef,
+ style: {
+ transform: CSS.Translate.toString(sortable.transform),
+ transition: sortable.transition,
+ // A zero-height boundary also makes dnd-kit scale the source to
+ // zero. Only projected peers use scaleY as a visibility sentinel.
+ visibility:
+ !sortable.isDragging && sortable.transform?.scaleY === 0
+ ? ("hidden" as const)
+ : undefined,
+ },
+ ...sortable.listeners,
+ }
+ : {};
+ const dragDestination =
+ sortable?.isDragging && props.dropSection !== null ? (
+
+ →
+ {props.dropSection === "pinned"
+ ? "Pinned"
+ : props.dropSection === "active"
+ ? "Active"
+ : props.dropSection === "settled"
+ ? "Settled"
+ : "Snoozed"}
+
+ ) : null;
const title = isRenaming ? (
-
+
-
- {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
- // Snoozed rows show when they come BACK, not when they were
- // last touched — the return ticket is the row's whole story.
-
- {props.snoozeWakeLabelText}
-
- ) : isWoke ? (
- // A wake can land straight in the settled tail (e.g. PR
- // merged while snoozed); the signal must survive the trip.
+ {dragDestination ?? (
+
+
+ {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
+ // Snoozed rows show when they come BACK, not when they were
+ // last touched — the return ticket is the row's whole story.
+
+ {props.snoozeWakeLabelText}
+
+ ) : isWoke ? (
+ // A wake can land straight in the settled tail (e.g. PR
+ // merged while snoozed); the signal must survive the trip.
+
+
+
+ Woke
+
+ }
+ />
+ Dismiss Woke notification
+
+ ) : (
+
+ {variantAction === "unsettle"
+ ? settledTimeLabel(thread)
+ : threadTimeLabel(thread)}
+
+ )}
+
+ {variantAction === "unsnooze" ? (
+ !props.snoozeSupported ? null : (
+
+
+
+ )
+ ) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
-
- Woke
-
+ aria-label="Un-settle thread"
+ onClick={handleUnsettleClick}
+ className={cn(
+ "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100",
+ isWoke && "group-hover/sidebar-row:static",
+ )}
+ />
}
- />
- Dismiss Woke notification
+ >
+
+
+ Un-settle thread
) : (
-
- {variantAction === "unsettle"
- ? settledTimeLabel(thread)
- : threadTimeLabel(thread)}
-
- )}
-
- {variantAction === "unsnooze" ? (
- !props.snoozeSupported ? null : (
-
+
- )
- ) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
-
-
- }
- >
-
-
- Un-settle thread
-
- ) : (
-
-
-
- )}
-
+ )}
+
+ )}
{props.jumpLabel ? : null}
{detailsTooltip}
@@ -1429,26 +1625,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
const diff = latestTurnDiff(thread);
- const sortable = props.sortable;
return (
-
+
- {/* Read-only status labels yield to the hover actions. Woke is
+ {dragDestination ?? (
+
+ {/* Read-only status labels yield to the hover actions. Woke is
itself an action, so it stays pointer-enabled and visible
while the other controls appear beside it. */}
-
- {topStatus ? (
- isWokeStatus ? (
-
-
-
- {topStatus.label}
-
- }
- />
- Dismiss Woke notification
-
- ) : (
-
- {topStatus.icon === "working" ? (
-
- ) : topStatus.icon === "done" ? (
-
- ) : null}
- {/* The label alone is the live region: a role="status"
- wrapper around the ticking duration would make
- screen readers announce every second. */}
- {topStatus.label}
- {status === "working" ? (
-
-
-
- ) : null}
-
- )
- ) : (
- threadTimeLabel(thread)
- )}
-
- {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? (
- {hasUnsentDraft ? (
-
-
- }
- >
-
-
- Discard draft
-
- ) : null}
- {showSnoozeButton ? (
-
- ) : null}
- {props.settlementSupported ? (
-
-
- }
+ {topStatus ? (
+ isWokeStatus ? (
+
+
+
+ {topStatus.label}
+
+ }
+ />
+ Dismiss Woke notification
+
+ ) : (
+
-
- Settle
-
- Settle thread
-
- ) : null}
+ {topStatus.icon === "working" ? (
+
+ ) : topStatus.icon === "done" ? (
+
+ ) : null}
+ {/* The label alone is the live region: a role="status"
+ wrapper around the ticking duration would make
+ screen readers announce every second. */}
+ {topStatus.label}
+ {status === "working" ? (
+
+
+
+ ) : null}
+
+ )
+ ) : (
+ threadTimeLabel(thread)
+ )}
- ) : null}
-
+ {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? (
+
+ {hasUnsentDraft ? (
+
+
+ }
+ >
+
+
+ Discard draft
+
+ ) : null}
+ {showSnoozeButton ? (
+
+ ) : null}
+ {props.settlementSupported ? (
+
+
+ }
+ >
+
+ Settle
+
+ Settle thread
+
+ ) : null}
+
+ ) : null}
+
+ )}
{title}
@@ -1834,8 +2022,10 @@ export default function Sidebar() {
snoozeThread,
unsnoozeThread,
pinThread,
+ unpinThread,
confirmAndUnpinThread,
reorderPinnedThread,
+ reorderActiveThread,
archiveThread,
deleteThread,
} = useThreadActions();
@@ -2196,13 +2386,27 @@ export default function Sidebar() {
[openProjectSettings],
);
- // Settled threads stay in the live shell stream (settled ≠ archived), so
- // the partition works directly off live shells: no archived-snapshot
- // merging, no optimistic holds. Archived threads remain hidden here —
- // archive keeps its original "remove from sidebar" meaning.
+ // Keep a dropped row at its destination while its server applies the
+ // lifecycle command and any order-key writes. The next pickup waits for
+ // this hold so a second drop cannot replace an unconfirmed placement.
+ const [optimisticDrop, setOptimisticDrop] = useState<{
+ readonly key: string;
+ readonly sourceSection: SidebarSection;
+ readonly section: "pinned" | "active" | "settled";
+ readonly occurredAt: string;
+ readonly clearsSnooze: boolean;
+ /** Full destination order for pinned and active drops. */
+ readonly order: readonly string[] | null;
+ /** Destination order keys before the drop, to recognize concurrent writes. */
+ readonly keysAtDrop: ReadonlyMap
;
+ /** The keys this drop writes (one per planned assignment). The
+ override holds until all of them appear in canonical state. */
+ readonly assignedKeys: ReadonlyMap;
+ } | null>(null);
const {
pinnedThreads,
- reorderablePinnedKeys,
+ draggableThreadKeys,
+ activeReorderableThreadKeys,
activeThreads,
snoozedThreads,
settledThreads,
@@ -2224,17 +2428,42 @@ export default function Sidebar() {
const active: EnvironmentThreadShell[] = [];
const snoozed: EnvironmentThreadShell[] = [];
const settled: EnvironmentThreadShell[] = [];
+ const draggable = new Set();
+ const activeReorderable = new Set();
for (const thread of visible) {
+ const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities;
// Threads on servers without the settlement capability (old server,
// or descriptor not loaded yet) never classify as settled: the user
// could neither un-settle nor pin them, so auto-settling them would
// strand rows in a tail with no working affordances.
- const supportsSettlement =
- serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true;
- const supportsSnooze =
- serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
- // Snooze outranks settlement and pinning until the thread wakes.
- if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
+ const supportsSettlement = capabilities?.threadSettlement === true;
+ const supportsSnooze = capabilities?.threadSnooze === true;
+ const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+ if (capabilities?.threadActiveReorder === true) activeReorderable.add(threadKey);
+ // Older servers retain their existing drag actions. Active placement
+ // additionally requires its own ordering capability at the drop target.
+ if (capabilities?.threadPinning === true && capabilities.threadPinReorder === true) {
+ draggable.add(threadKey);
+ }
+ if (optimisticDrop?.key === threadKey) {
+ const projected = applySidebarThreadDrop(
+ thread,
+ optimisticDrop.section,
+ optimisticDrop.occurredAt,
+ optimisticDrop.assignedKeys.get(threadKey),
+ );
+ (optimisticDrop.section === "pinned"
+ ? pinned
+ : optimisticDrop.section === "settled"
+ ? settled
+ : active
+ ).push(
+ optimisticDrop.clearsSnooze
+ ? projected
+ : { ...projected, snoozedAt: thread.snoozedAt, snoozedUntil: thread.snoozedUntil },
+ );
+ } else if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
+ // Snooze outranks settlement and pinning until the thread wakes.
snoozed.push(thread);
} else if (supportsSettlement && thread.settledOverride === "settled") {
settled.push(thread);
@@ -2249,18 +2478,27 @@ export default function Sidebar() {
// Server capability only gates DRAGGING — it must not influence the
// sort, or mixed-version fleets would render different pinned orders on
// web and mobile from the same data.
+ const sortedPinned = sortPinnedThreadsForSidebar(pinned);
+ const sortedActive = sortThreadsForSidebar(active);
return {
- pinnedThreads: sortPinnedThreadsForSidebar(pinned),
- reorderablePinnedKeys: new Set(
- pinned
- .filter(
- (thread) =>
- serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder ===
- true,
- )
- .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
- ),
- activeThreads: sortThreadsForSidebar(active),
+ pinnedThreads:
+ optimisticDrop?.section !== "pinned" || optimisticDrop.order === null
+ ? sortedPinned
+ : orderItemsByPreferredIds({
+ items: sortedPinned,
+ preferredIds: optimisticDrop.order,
+ getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ }),
+ draggableThreadKeys: draggable,
+ activeReorderableThreadKeys: activeReorderable,
+ activeThreads:
+ optimisticDrop?.section !== "active" || optimisticDrop.order === null
+ ? sortedActive
+ : orderItemsByPreferredIds({
+ items: sortedActive,
+ preferredIds: optimisticDrop.order,
+ getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ }),
// Soonest wake first: "what comes back next" is the shelf's question.
snoozedThreads: snoozed.toSorted(
(left, right) =>
@@ -2270,7 +2508,7 @@ export default function Sidebar() {
settledThreads: sortSettledThreadsForSidebar(settled),
snoozeNow: preciseNow,
};
- }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);
+ }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);
const threadSearchInputRef = useRef(null);
const [threadSearchQuery, setThreadSearchQuery] = useState("");
@@ -2715,77 +2953,122 @@ export default function Sidebar() {
},
[unsnoozeThread],
);
- // Drag-to-reorder for the pinned block. A drop computes ONE fractional key
- // for the moved thread and sends it to that thread's own server (see
- // planPinnedReorder for the keyless-neighbor materialization case, which
- // instead rewrites every key in the section). The optimistic order keeps
- // the card where it was dropped until EVERY key the drop wrote is
- // reflected in canonical state — a section rewrite is several sequential
- // writes, and releasing on the first landed key would expose the
- // half-written canonical order, reshuffling the block once per write.
- // A failed write clears the override (the card snaps back) with a toast.
- // A key we did NOT write landing (a concurrent client's reorder that must
- // win) and ANY membership change (new pin, unpin, snooze/wake) also
- // release it: the override can't say where members it never saw belong,
- // and holding it would launder a stale order into later drags.
- const pinnedDndSensors = useSensors(
+ const listMotionRef = useRef | null>(null);
+ const attachListMotionRef = useCallback((node: HTMLUListElement | null) => {
+ listMotionRef.current?.dispose();
+ listMotionRef.current = node === null ? null : createSidebarListMotion(node);
+ listMotionRef.current?.update(false);
+ }, []);
+
+ // Hold the chosen section and order until every key write arrives. This
+ // also covers first-time ordering, which assigns keys to keyless neighbors.
+ // A failed write, concurrent reorder, or membership change releases the hold.
+ const dndSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
);
- const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{
- readonly order: readonly string[];
- /** pinOrderKey per thread as of the drop — the baseline that tells a
- concurrent client's write apart from one of our own landing. */
- readonly keysAtDrop: ReadonlyMap;
- /** The keys this drop writes (one per planned assignment). The
- override holds until all of them appear in canonical state. */
- readonly assignedKeys: ReadonlyMap;
+ const [dragState, setDragState] = useState<{
+ readonly activeKey: string;
+ readonly activeSection: SidebarSection;
+ readonly occurredAt: string;
+ readonly activationY: number | null;
} | null>(null);
- const orderedPinnedThreads = useMemo(() => {
- if (optimisticPinnedOrder === null) return pinnedThreads;
- return orderItemsByPreferredIds({
- items: pinnedThreads,
- preferredIds: optimisticPinnedOrder.order,
- getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
- });
- }, [optimisticPinnedOrder, pinnedThreads]);
+ const [dragTargetSection, setDragTargetSection] = useState(null);
+ const sectionByThreadKey = useMemo(() => {
+ const map = new Map();
+ const add = (list: readonly EnvironmentThreadShell[], section: SidebarSection) => {
+ for (const thread of list) {
+ map.set(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), section);
+ }
+ };
+ add(pinnedThreads, "pinned");
+ add(activeThreads, "active");
+ add(snoozedThreads, "snoozed");
+ add(settledThreads, "settled");
+ return map;
+ }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads]);
+ const pinnedKeys = useMemo(
+ () =>
+ pinnedThreads.map((thread) =>
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ ),
+ [pinnedThreads],
+ );
+ const activeKeys = useMemo(
+ () =>
+ activeThreads.map((thread) =>
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ ),
+ [activeThreads],
+ );
useEffect(() => {
- if (optimisticPinnedOrder === null) return;
- const canonical = pinnedThreads.filter((thread) =>
- reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
+ if (optimisticDrop === null) return;
+ const canonicalByKey = new Map(
+ threads.map((thread) => [
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ thread,
+ ]),
);
- const canonicalKeys = canonical.map((thread) =>
- scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ const thread = canonicalByKey.get(optimisticDrop.key);
+ if (thread === undefined || thread.archivedAt !== null) {
+ setOptimisticDrop(null);
+ return;
+ }
+ const canonicalSection = effectiveSnoozed(thread, { now: new Date().toISOString() })
+ ? "snoozed"
+ : thread.settledOverride === "settled"
+ ? "settled"
+ : thread.pinnedAt != null
+ ? "pinned"
+ : "active";
+ if (
+ canonicalSection !== optimisticDrop.sourceSection &&
+ canonicalSection !== optimisticDrop.section
+ ) {
+ setOptimisticDrop(null);
+ return;
+ }
+ if (optimisticDrop.order === null) {
+ // Settle also emits unpin/unsnooze events. Wait for the entire move
+ // before releasing the projected fields and sort timestamps.
+ if (
+ canonicalSection === optimisticDrop.section &&
+ thread.pinnedAt == null &&
+ (!optimisticDrop.clearsSnooze || thread.snoozedUntil == null)
+ ) {
+ setOptimisticDrop(null);
+ }
+ return;
+ }
+ if (canonicalSection !== optimisticDrop.section) return;
+ if (optimisticDrop.clearsSnooze && thread.snoozedUntil != null) return;
+ const destinationKeys = optimisticDrop.section === "pinned" ? pinnedKeys : activeKeys;
+ const canonicalDestination = destinationKeys.flatMap((key) => {
+ const canonical = canonicalByKey.get(key);
+ return canonical === undefined ? [] : [canonical];
+ });
+ const keyByThread = new Map(
+ canonicalDestination.map((thread) => [
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ (optimisticDrop.section === "pinned" ? thread.pinOrderKey : thread.activeOrderKey) ?? null,
+ ]),
);
- // The override represents one drop against one snapshot of the world.
- // Release it when the world moves on: membership changed (pin/unpin/
- // snooze/wake — the override can't say where members it never saw
- // belong), a key changed to something we did NOT write (a concurrent
- // client's reorder that must win), every key we wrote has landed, or
- // canonical already matches. Releasing on the FIRST landed key instead
- // of the last exposes the half-written order mid-materialization and
- // the block visibly reshuffles once per write.
+ const heldOrder = optimisticDrop.order;
+ const heldKeys = new Set(heldOrder);
const membershipChanged =
- canonicalKeys.length !== optimisticPinnedOrder.order.length ||
- canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key));
- const foreignKeyLanded = canonical.some((thread, index) => {
- const threadKey = canonicalKeys[index]!;
- const currentKey = thread.pinOrderKey ?? null;
- if (currentKey === optimisticPinnedOrder.keysAtDrop.get(threadKey)) return false;
- return currentKey !== optimisticPinnedOrder.assignedKeys.get(threadKey);
+ destinationKeys.length !== heldOrder.length ||
+ destinationKeys.some((key) => !heldKeys.has(key));
+ const foreignKeyLanded = destinationKeys.some((threadKey) => {
+ const currentKey = keyByThread.get(threadKey) ?? null;
+ if (currentKey === (optimisticDrop.keysAtDrop.get(threadKey) ?? null)) return false;
+ return currentKey !== optimisticDrop.assignedKeys.get(threadKey);
});
- const currentKeyByThreadKey = new Map(
- canonical.map((thread, index) => [canonicalKeys[index]!, thread.pinOrderKey ?? null]),
- );
- const allAssignmentsLanded = [...optimisticPinnedOrder.assignedKeys].every(
- ([threadKey, orderKey]) => currentKeyByThreadKey.get(threadKey) === orderKey,
+ const allAssignmentsLanded = [...optimisticDrop.assignedKeys].every(
+ ([threadKey, orderKey]) => keyByThread.get(threadKey) === orderKey,
);
- const orderConfirmed =
- !membershipChanged &&
- canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]);
- if (membershipChanged || foreignKeyLanded || allAssignmentsLanded || orderConfirmed) {
- setOptimisticPinnedOrder(null);
+ if (membershipChanged || foreignKeyLanded || allAssignmentsLanded) {
+ setOptimisticDrop(null);
}
- }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]);
+ }, [activeKeys, optimisticDrop, pinnedKeys, threads]);
const attemptPin = useCallback(
(threadRef: ScopedThreadRef) => {
void (async () => {
@@ -2827,71 +3110,338 @@ export default function Sidebar() {
[confirmAndUnpinThread],
);
- const handlePinnedDragEnd = useCallback(
- (event: DragEndEvent) => {
+ const handleThreadDragStart = useCallback(
+ (event: DragStartEvent) => {
const activeKey = String(event.active.id);
- const overKey = event.over === null ? null : String(event.over.id);
- if (overKey === null || activeKey === overKey) return;
- const reorderable = orderedPinnedThreads.filter((thread) =>
- reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
- );
- const keys = reorderable.map((thread) =>
- scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
- );
- const fromIndex = keys.indexOf(activeKey);
- const toIndex = keys.indexOf(overKey);
- if (fromIndex === -1 || toIndex === -1) return;
- const newOrder = arrayMove([...keys], fromIndex, toIndex);
- const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread]));
- const keysAtDrop = new Map(
- reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]),
- );
- const assignments = planPinnedReorder({
- orderedIds: newOrder,
- keysById: keysAtDrop,
- movedId: activeKey,
+ const activeSection = sectionByThreadKey.get(activeKey);
+ if (activeSection === undefined) return;
+ // Stop normal section motion before dnd-kit measures the picked-up row.
+ listMotionRef.current?.suspend();
+ setDragState({
+ activeKey,
+ activeSection,
+ occurredAt: new Date().toISOString(),
+ activationY:
+ event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null,
});
- if (assignments.length === 0) return;
- setOptimisticPinnedOrder({
- order: newOrder,
- keysAtDrop,
- assignedKeys: new Map(
- assignments.map((assignment) => [assignment.id, assignment.orderKey]),
- ),
+ setDragTargetSection(activeSection);
+ },
+ [sectionByThreadKey],
+ );
+ const handleThreadDragCancel = useCallback(() => {
+ listMotionRef.current?.suspend();
+ setDragState(null);
+ setDragTargetSection(null);
+ }, []);
+ // Include every visible row in the measured order. Older servers disable
+ // pickup on their rows without changing where those rows render.
+ const sidebarListItems = useMemo((): readonly SidebarListItem[] => {
+ const rowsOf = (
+ list: readonly EnvironmentThreadShell[],
+ section: SidebarSection,
+ ): SidebarListItem[] =>
+ list.map((thread) => {
+ const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+ return { kind: "thread", key, section };
+ });
+ if (
+ pinnedThreads.length +
+ activeThreads.length +
+ snoozedThreads.length +
+ settledThreads.length ===
+ 0
+ ) {
+ return [];
+ }
+ const items: SidebarListItem[] = [{ kind: "marker", marker: "pinned-header" }];
+ const pinnedRows = rowsOf(pinnedThreads, "pinned");
+ items.push(...pinnedRows);
+ items.push({ kind: "marker", marker: "pinned-divider" });
+ const activeRows = rowsOf(activeThreads, "active");
+ if (activeRows.length === 0) {
+ items.push({ kind: "marker", marker: "active-placeholder" });
+ }
+ items.push(...activeRows);
+ if (snoozedThreads.length > 0) {
+ items.push({ kind: "marker", marker: "snoozed-header" });
+ items.push(...rowsOf(visibleSnoozedThreads, "snoozed"));
+ }
+ items.push({ kind: "marker", marker: "settled-header" });
+ const settledRows = rowsOf(renderedSettledThreads, "settled");
+ if (settledRows.length === 0) {
+ items.push({ kind: "marker", marker: "settled-placeholder" });
+ }
+ items.push(...settledRows);
+ return items;
+ }, [
+ activeThreads,
+ pinnedThreads,
+ renderedSettledThreads,
+ settledThreads.length,
+ snoozedThreads.length,
+ visibleSnoozedThreads,
+ ]);
+ const listMotionPaused = dragState !== null;
+ useLayoutEffect(() => {
+ // Drag release clears the baseline, so its commit cannot replay the
+ // sortable preview. Later thread actions can animate while writes settle.
+ // Draft navigation can reveal a frozen row without changing the draft count.
+ listMotionRef.current?.update(
+ !listMotionPaused && sidebarListItems.length + visibleDraftSessionCount > 0,
+ );
+ }, [listMotionPaused, routeDraftIdForRows, sidebarListItems, visibleDraftSessionCount]);
+ const handleThreadDragOver = useCallback(
+ (event: DragOverEvent) => {
+ const target = event.over
+ ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id))
+ : null;
+ setDragTargetSection(target?.section ?? null);
+ },
+ [sidebarListItems],
+ );
+ const sortableIds = useMemo(() => sidebarListItems.map(sidebarListItemId), [sidebarListItems]);
+ const draggedSettledOrder = useMemo(() => {
+ const thread = dragState === null ? undefined : threadByKey.get(dragState.activeKey);
+ if (dragState === null || thread === undefined) return [];
+ const key = (candidate: EnvironmentThreadShell) =>
+ scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id));
+ return sortSettledThreadsForSidebar([
+ ...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey),
+ applySidebarThreadDrop(thread, "settled", dragState.occurredAt),
+ ]).map(key);
+ }, [dragState, settledThreads, threadByKey]);
+ const sidebarSortingStrategy = useMemo(
+ () =>
+ createSidebarSortingStrategy({
+ items: sidebarListItems,
+ settledOrder: draggedSettledOrder,
+ settledExpanded: settledShelfExpanded,
+ settledVisibleCount,
+ routeThreadKey,
+ snoozedThreadCount: snoozedThreads.length,
+ }),
+ [
+ draggedSettledOrder,
+ routeThreadKey,
+ settledShelfExpanded,
+ settledVisibleCount,
+ sidebarListItems,
+ snoozedThreads.length,
+ ],
+ );
+ // Hidden and filtered threads keep their keys. Reserve those slots without
+ // including the rows in the visible drop order or writing to them.
+ const { pinnedKeysById, activeKeysById } = useMemo(
+ () => ({
+ pinnedKeysById: new Map(
+ threads.map((thread) => [
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ thread.pinOrderKey ?? null,
+ ]),
+ ),
+ activeKeysById: new Map(
+ threads.map((thread) => [
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ thread.activeOrderKey ?? null,
+ ]),
+ ),
+ }),
+ [threads],
+ );
+ const dndCollisionDetection = useMemo(() => {
+ if (dragState === null) return createSidebarCollisionDetection(() => true);
+ const source = threadByKey.get(dragState.activeKey);
+ if (source === undefined) return createSidebarCollisionDetection(() => false);
+ return createSidebarCollisionDetection(
+ (id) => {
+ const target = resolveSidebarDropTarget(sidebarListItems, dragState.activeKey, id);
+ if (target === null) return false;
+ return (
+ planSidebarThreadDrop({
+ activeKey: dragState.activeKey,
+ activeSection: dragState.activeSection,
+ activePinned: source.pinnedAt != null,
+ activeSettled: source.settledOverride === "settled",
+ supportsSettlement:
+ serverConfigs.get(source.environmentId)?.environment.capabilities.threadSettlement ===
+ true,
+ target,
+ pinnedOrder: pinnedKeys,
+ pinnedKeysById,
+ reorderableKeys: draggableThreadKeys,
+ activeOrder: activeKeys,
+ activeKeysById,
+ activeReorderableKeys: activeReorderableThreadKeys,
+ }).kind !== "none"
+ );
+ },
+ { emptyPins: pinnedKeys.length === 0, activationY: dragState.activationY },
+ );
+ }, [
+ activeKeysById,
+ pinnedKeysById,
+ serverConfigs,
+ activeKeys,
+ activeReorderableThreadKeys,
+ dragState,
+ draggableThreadKeys,
+ pinnedKeys,
+ sidebarListItems,
+ threadByKey,
+ ]);
+ const handleThreadDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ listMotionRef.current?.suspend();
+ setDragState(null);
+ setDragTargetSection(null);
+ const activeKey = String(event.active.id);
+ const activeSection = sectionByThreadKey.get(activeKey);
+ const target =
+ event.over === null
+ ? null
+ : resolveSidebarDropTarget(sidebarListItems, activeKey, String(event.over.id));
+ const activeThread = threadByKey.get(activeKey);
+ if (activeSection === undefined || target === null || activeThread === undefined) return;
+ const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id);
+ const plan = planSidebarThreadDrop({
+ activeKey,
+ activeSection,
+ activePinned: activeThread.pinnedAt != null,
+ activeSettled: activeThread.settledOverride === "settled",
+ supportsSettlement:
+ serverConfigs.get(activeThread.environmentId)?.environment.capabilities
+ .threadSettlement === true,
+ target,
+ pinnedOrder: pinnedKeys,
+ pinnedKeysById,
+ reorderableKeys: draggableThreadKeys,
+ activeOrder: activeKeys,
+ activeKeysById,
+ activeReorderableKeys: activeReorderableThreadKeys,
});
+ if (plan.kind === "none") return;
+ if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return;
+ const assignments =
+ plan.kind === "pin"
+ ? [
+ ...(plan.orderKey === undefined ? [] : [{ id: activeKey, orderKey: plan.orderKey }]),
+ ...plan.extraAssignments,
+ ]
+ : plan.kind === "reorder-pinned" || plan.kind === "move-active"
+ ? plan.assignments
+ : [];
+ const drop = {
+ key: activeKey,
+ sourceSection: activeSection,
+ section: target.section,
+ occurredAt: new Date().toISOString(),
+ clearsSnooze:
+ plan.kind === "pin" ||
+ plan.kind === "settle" ||
+ (plan.kind === "move-active" && plan.unsnooze),
+ order: plan.kind === "settle" ? null : plan.order,
+ keysAtDrop: target.section === "active" ? activeKeysById : pinnedKeysById,
+ assignedKeys: new Map(assignments.map(({ id, orderKey }) => [id, orderKey])),
+ };
+ setOptimisticDrop(drop);
void (async () => {
- // Sequential, stop on first failure. There is deliberately no
- // rollback: every key write is a complete, valid placement on its
- // own, so a partial materialization leaves a sensible order (and
- // the next drag repairs the rest) — unwinding writes across
- // servers would trade that for real inconsistency windows.
- for (const assignment of assignments) {
- const thread = threadByKey.get(assignment.id);
- if (thread === undefined) continue;
- const result = await reorderPinnedThread(
- scopeThreadRef(thread.environmentId, thread.id),
- assignment.orderKey,
- );
- if (result._tag === "Failure") {
- // Any failure — interrupted included — releases the override:
- // a key that never lands would otherwise hold it until some
- // unrelated world change came along.
- setOptimisticPinnedOrder(null);
- if (isAtomCommandInterrupted(result)) return;
+ const run = async (
+ operation: Promise>,
+ title: string,
+ ) => {
+ const result = await operation;
+ if (result._tag === "Success") return true;
+ // A late failure must not cancel a newer drag's preview.
+ setOptimisticDrop((current) => (current === drop ? null : current));
+ if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
type: "error",
- title: "Failed to reorder pinned threads",
+ title,
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
+ }
+ return false;
+ };
+ switch (plan.kind) {
+ case "settle": {
+ settlingThreadKeysRef.current.add(activeKey);
+ const navigateAfterSettle = planForwardNavigation(activeKey);
+ const settled = await run(settleThread(threadRef), "Failed to settle thread").finally(
+ () => settlingThreadKeysRef.current.delete(activeKey),
+ );
+ if (settled && routeThreadKeyRef.current === activeKey) navigateAfterSettle?.();
return;
}
+ case "move-active":
+ // The drag expresses unpin intent; button/menu confirmation is unchanged.
+ if (plan.unpin && !(await run(unpinThread(threadRef), "Failed to unpin thread")))
+ return;
+ if (
+ plan.unsettle &&
+ !(await run(unsettleThread(threadRef), "Failed to un-settle thread"))
+ )
+ return;
+ if (plan.unsnooze && !(await run(unsnoozeThread(threadRef), "Failed to wake thread")))
+ return;
+ break;
+ case "pin":
+ if (
+ !(await run(
+ pinThread(
+ threadRef,
+ plan.orderKey === undefined ? {} : { orderKey: plan.orderKey },
+ ),
+ "Failed to pin thread",
+ ))
+ )
+ return;
+ break;
+ case "reorder-pinned":
+ break;
+ }
+ // Stop on failure; each successful key write remains a valid placement.
+ const keyWrites = plan.kind === "pin" ? plan.extraAssignments : plan.assignments;
+ for (const assignment of keyWrites) {
+ const thread = threadByKey.get(assignment.id);
+ if (thread === undefined) continue;
+ if (
+ !(await run(
+ (plan.kind === "move-active" ? reorderActiveThread : reorderPinnedThread)(
+ scopeThreadRef(thread.environmentId, thread.id),
+ assignment.orderKey,
+ ),
+ plan.kind === "move-active"
+ ? "Failed to reorder active threads"
+ : "Failed to reorder pinned threads",
+ ))
+ )
+ return;
}
})();
},
- [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys],
+ [
+ activeKeysById,
+ pinnedKeysById,
+ serverConfigs,
+ activeKeys,
+ activeReorderableThreadKeys,
+ draggableThreadKeys,
+ pinThread,
+ pinnedKeys,
+ planForwardNavigation,
+ reorderPinnedThread,
+ reorderActiveThread,
+ sectionByThreadKey,
+ settleThread,
+ sidebarListItems,
+ threadByKey,
+ unpinThread,
+ unsettleThread,
+ unsnoozeThread,
+ ],
);
// One snooze per thread at a time — same double-dispatch guard as settle.
const snoozingThreadKeysRef = useRef(new Set());
@@ -3535,11 +4085,6 @@ export default function Sidebar() {
updateThreadJumpHintsVisibility(shouldShowJumpHintsNow);
}, [shouldShowJumpHintsNow, updateThreadJumpHintsVisibility]);
- const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => {
- if (!node) return;
- autoAnimate(node, { duration: 150, easing: "ease-out" });
- }, []);
-
// New thread defaults to the project you're in (active thread's project,
// falling back to the top project) — same resolution the command palette
// uses. The command palette already offers a "New thread in..." submenu
@@ -3929,281 +4474,298 @@ export default function Sidebar() {
closeDelay={0}
timeout={400}
>
-
- {(() => {
- const renderThreadRow = (
- thread: EnvironmentThreadShell,
- section: "pinned" | "active" | "snoozed" | "settled",
- sortable?: SortablePinnedRowBag,
- ) => {
- const threadKey = scopedThreadKey(
- scopeThreadRef(thread.environmentId, thread.id),
- );
- // Settled and snoozed are the ONLY things that collapse a
- // row: every other thread is a full card. Density comes
- // from users (or the auto rules) actually parking work,
- // not from the sidebar second-guessing what still matters.
- const isCard = section === "active" || section === "pinned";
- const rowVariant = isCard ? "card" : "slim";
- return (
-
- );
- };
- // Draft block above everything, then the pinned block:
- // full cards above the inbox, closed by a thin divider (the
- // pin glyphs carry the meaning, so no header text). Both
- // vanish entirely at count 0.
- // Pinned rows render in the one shared pinned order; only
- // reorder-capable rows register as sortable (legacy-server
- // pins render in place as plain rows).
- const items: ReactNode[] = [
- ,
- pinnedThreads.length > 0 ? (
-
-
-
- scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
- )
- .filter((threadKey) => reorderablePinnedKeys.has(threadKey))}
- strategy={verticalListSortingStrategy}
+
+
+
+ {(() => {
+ const renderThreadRowInner = (
+ thread: EnvironmentThreadShell,
+ section: SidebarSection,
+ sortable?: SortableThreadRowBag,
+ ) => {
+ const threadKey = scopedThreadKey(
+ scopeThreadRef(thread.environmentId, thread.id),
+ );
+ // Settled and snoozed are the ONLY things that collapse a
+ // row: every other thread is a full card. Density comes
+ // from users (or the auto rules) actually parking work,
+ // not from the sidebar second-guessing what still matters.
+ const isCard = section === "active" || section === "pinned";
+ const rowVariant = isCard ? "card" : "slim";
+ return (
+
+ );
+ };
+ const renderThreadRow = (
+ thread: EnvironmentThreadShell,
+ section: SidebarSection,
+ ) => {
+ const threadKey = scopedThreadKey(
+ scopeThreadRef(thread.environmentId, thread.id),
+ );
+ return (
+
-
- {orderedPinnedThreads.map((thread) => {
- const threadKey = scopedThreadKey(
- scopeThreadRef(thread.environmentId, thread.id),
- );
- if (!reorderablePinnedKeys.has(threadKey)) {
- return renderThreadRow(thread, "pinned");
+ {(bag) => renderThreadRowInner(thread, section, bag)}
+
+ );
+ };
+ const from = dragState?.activeSection ?? null;
+ const previewPinnedCount =
+ pinnedThreads.length +
+ (from !== "pinned" && dragTargetSection === "pinned" ? 1 : 0) -
+ (from === "pinned" &&
+ dragTargetSection !== null &&
+ dragTargetSection !== "pinned"
+ ? 1
+ : 0);
+ const activeHint =
+ from === "pinned"
+ ? "Drop to unpin"
+ : from === "settled"
+ ? "Drop to un-settle"
+ : from === "snoozed"
+ ? "Drop to wake"
+ : null;
+ const items: ReactNode[] = [
+ ,
+ ];
+ for (const item of sidebarListItems) {
+ if (item.kind === "thread") {
+ items.push(renderThreadRow(threadByKey.get(item.key)!, item.section));
+ continue;
+ }
+ switch (item.marker) {
+ case "pinned-header":
+ items.push(
+ ,
+ );
+ break;
+ case "pinned-divider":
+ items.push(
+ 0}
+ />,
+ );
+ break;
+ case "active-placeholder":
+ items.push(
+ ,
+ );
+ break;
+ case "snoozed-header":
+ items.push(
+
- {(bag) => renderThreadRow(thread, "pinned", bag)}
-
- );
- })}
-
-
-
-
- ) : null,
- ];
- if (pinnedThreads.length > 0) {
- items.push(
- ,
- );
- }
- for (const thread of activeThreads) {
- items.push(renderThreadRow(thread, "active"));
- }
- // Snoozed shelf: between the inbox and Settled — out of the
- // way, never gone. The header always renders while anything
- // is snoozed (the count is the whole footprint when
- // collapsed); rows only when expanded. Vanishes entirely at
- // count 0.
- if (snoozedThreads.length > 0) {
- items.push(
-
-
-
- {snoozedShelfExpanded
- ? "Snoozed"
- : `Snoozed (${snoozedThreads.length})`}
-
-
-
-
- ,
- );
- for (const thread of visibleSnoozedThreads) {
- items.push(renderThreadRow(thread, "snoozed"));
- }
- }
- if (settledThreads.length > 0) {
- items.push(
-
+ toggle={{
+ expanded: snoozedShelfExpanded,
+ onToggle: toggleSnoozedShelf,
+ }}
+ />,
+ );
+ break;
+ case "settled-header":
+ items.push(
+ ,
+ );
+ break;
+ case "settled-placeholder":
+ items.push(
+ ,
+ );
+ break;
+ }
+ }
+ return items;
+ })()}
+ {settledShelfExpanded && hiddenSettledCount > 0 ? (
+
-
- {settledShelfExpanded
- ? "Settled"
- : `Settled (${settledThreads.length})`}
-
-
-
+
+ Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
- ,
- );
- }
- for (const thread of renderedSettledThreads) {
- items.push(renderThreadRow(thread, "settled"));
- }
- return items;
- })()}
- {settledShelfExpanded && hiddenSettledCount > 0 ? (
-
-
-
- Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
-
-
- ) : null}
-
+
+ ) : null}
+
+
+
) : null}
{!isSearchingThreads &&
diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts
index 9b162bc8cce3..cefe81a7fa09 100644
--- a/apps/web/src/hooks/useThreadActions.ts
+++ b/apps/web/src/hooks/useThreadActions.ts
@@ -25,6 +25,7 @@ import { readLocalApi } from "../localApi";
import {
readEnvironmentSupportsPinning,
readEnvironmentSupportsPinReorder,
+ readEnvironmentSupportsActiveReorder,
readEnvironmentSupportsSettlement,
readEnvironmentSupportsSnooze,
readEnvironmentThreadRefs,
@@ -124,6 +125,18 @@ export class ThreadPinReorderUnsupportedError extends Schema.TaggedErrorClass()(
+ "ThreadActiveReorderUnsupportedError",
+ {
+ environmentId: EnvironmentId,
+ threadId: ThreadId,
+ },
+) {
+ override get message(): string {
+ return "Update this environment's server to reorder active threads.";
+ }
+}
+
export async function requestThreadUnpinConfirmation(input: {
enabled: boolean;
title: string;
@@ -185,6 +198,9 @@ export function useThreadActions() {
const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, {
reportFailure: false,
});
+ const reorderActiveThreadMutation = useAtomCommand(threadEnvironment.reorderActive, {
+ reportFailure: false,
+ });
const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, {
reportFailure: false,
});
@@ -631,6 +647,26 @@ export function useThreadActions() {
[reorderPinnedThreadMutation],
);
+ const reorderActiveThread = useCallback(
+ async (target: ScopedThreadRef, orderKey: string) => {
+ if (!readEnvironmentSupportsActiveReorder(target.environmentId)) {
+ return AsyncResult.failure(
+ Cause.fail(
+ new ThreadActiveReorderUnsupportedError({
+ environmentId: target.environmentId,
+ threadId: target.threadId,
+ }),
+ ),
+ );
+ }
+ return reorderActiveThreadMutation({
+ environmentId: target.environmentId,
+ input: { threadId: target.threadId, orderKey },
+ });
+ },
+ [reorderActiveThreadMutation],
+ );
+
const snoozeThread = useCallback(
async (target: ScopedThreadRef, snoozedUntil: string) => {
// Version skew: never send the command to a server that predates it.
@@ -729,6 +765,7 @@ export function useThreadActions() {
unpinThread,
confirmAndUnpinThread,
reorderPinnedThread,
+ reorderActiveThread,
}),
[
archiveThread,
@@ -737,6 +774,7 @@ export function useThreadActions() {
deleteThread,
pinThread,
reorderPinnedThread,
+ reorderActiveThread,
settleThread,
snoozeThread,
unarchiveThread,
diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts
index 2644ea67adec..7785bceaac73 100644
--- a/apps/web/src/lib/threadSort.ts
+++ b/apps/web/src/lib/threadSort.ts
@@ -1,5 +1,4 @@
export {
- activeThreadAnchorTimestampMs,
getLatestThreadForProject,
getThreadSortTimestamp,
resolveSettledThreadTimestamp,
diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts
index deb4948a0f5a..d9610e20717f 100644
--- a/apps/web/src/state/entities.ts
+++ b/apps/web/src/state/entities.ts
@@ -229,6 +229,13 @@ export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId):
);
}
+export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean {
+ return (
+ appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
+ .threadActiveReorder === true
+ );
+}
+
export function readEnvironmentThreadRefs(
environmentId: EnvironmentId,
): ReadonlyArray {
diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md
index 0a2e7aebad76..5168041471d3 100644
--- a/docs/user/thread-sidebar.md
+++ b/docs/user/thread-sidebar.md
@@ -22,12 +22,40 @@ worktree**, each background submission creates its own worktree.
## Pin and reorder threads
-Pin a thread from its menu to keep it above your active work. Drag pinned threads
-to reorder them on web and desktop, or use **Move up** and **Move down** on mobile.
-The order syncs across devices.
+Pin a thread from its menu to keep it above your active work.
Pinning does not prevent automatic settlement. Settling a thread removes its pin.
+On web and desktop, drag a thread between sections to change its state. Drag a thread up into
+the pinned section to pin it at the spot you drop it; drag a pinned thread down into the active
+list to unpin it. Dragging a thread onto the **Settled** header settles it, and dragging a settled
+thread into the active list un-settles it. A snoozed thread can be dragged out of the snoozed
+shelf, which wakes it, but threads cannot be dragged into the shelf because snoozing needs a wake
+time. Dragging a pinned thread out of the pinned section does not ask for unpin confirmation.
+Pinned and active boundary labels appear only while dragging, without moving the rows. The
+destination boundary highlights and the thread shows which section it will land in. When there
+are no pins, drag to the top edge to pin a thread. Drop instructions also appear for empty sections
+and a collapsed settled shelf.
+
+Drag within the pinned or active section to change its order. Other rows slide aside to show the
+spot where the thread will land. Drops into either section keep the position you choose. On
+mobile, open a pinned or active thread's menu and choose **Move up** or **Move down**. The server
+saves the order, so it survives a refresh and appears on your other connected devices.
+
+On web and desktop, the list also animates section changes made with thread actions such as
+**Pin**, **Settle**, and **Snooze**. These transitions respect your system's reduced-motion
+preference. While dragging, rows follow the insertion gap without replaying a second transition
+after the drop.
+
+New threads appear above the active threads you have arranged. Settling clears a thread's active
+position, so using **Un-settle** returns it to the top. Pinning and snoozing preserve its active
+position until you move it again. Thread activity does not change the order. The settled shelf
+continues to use settlement time.
+
+If dragging is unavailable for one environment, update the T3 Code server running in that
+environment. Pinned and active reordering require server support. Threads from older servers keep
+their default order until the server is updated.
+
## Settle finished work
Choose **Settle thread** from its menu to move finished work out of the active list
diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts
index f06c95919554..3a4a9d284a18 100644
--- a/packages/client-runtime/src/state/threadSort.ts
+++ b/packages/client-runtime/src/state/threadSort.ts
@@ -107,7 +107,7 @@ export function getThreadSortTimestamp(
* top instead of sinking back to its creation-order slot. Shared by web and
* mobile so both render the same order. Malformed timestamps sink to 0.
*/
-export function activeThreadAnchorTimestampMs(thread: {
+function activeThreadAnchorTimestampMs(thread: {
readonly createdAt: string;
readonly unsettledAt?: string | null | undefined;
}): number {
From 9a47c7bd40522977c7df022a5b64759aae7b58e0 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 6 Sep 2026 11:10:14 -0700
Subject: [PATCH 06/71] feat(web): simplify sidebar drag destination cues
(#9750)
---
apps/web/src/components/Sidebar.tsx | 95 +++++++++++++----------------
docs/user/thread-sidebar.md | 8 ++-
2 files changed, 47 insertions(+), 56 deletions(-)
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 1a66dbdd9b5f..63c7deca4c5e 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -568,7 +568,6 @@ function SidebarSectionPlaceholder(props: {
function SidebarDragBoundary(props: {
marker: "pinned-header" | "pinned-divider";
label: string;
- hint: string | null;
visible: boolean;
isDropTarget: boolean;
}) {
@@ -587,7 +586,6 @@ function SidebarDragBoundary(props: {
)}
>
{props.label}
- {props.hint ? {props.hint} : null}
void };
}) {
@@ -624,7 +621,6 @@ function SidebarSectionHeader(props: {
props.isDropTarget && "bg-primary/30",
)}
/>
- {props.hint ? {props.hint} : null}
| null;
// Compact wake countdown ("2h") for rows in the snoozed shelf.
snoozeWakeLabelText: string | null;
// When a snooze ended (timer or early wake); drives the Woke pill until
@@ -1331,14 +1327,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
role="status"
className="pointer-events-none ml-auto inline-flex h-5 shrink-0 items-center gap-1 rounded-sm border border-primary/30 bg-sidebar px-1.5 text-[11px] font-medium text-primary"
>
+ Move to
→
{props.dropSection === "pinned"
? "Pinned"
: props.dropSection === "active"
? "Active"
- : props.dropSection === "settled"
- ? "Settled"
- : "Snoozed"}
+ : "Settled"}
) : null;
@@ -1442,31 +1437,32 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
Unsent draft
) : null;
- const pinIndicator = props.isPinned ? (
- props.pinningSupported ? (
-
-
- }
- >
-
-
- Unpin thread
-
- ) : (
-
- )
- ) : null;
+ const pinIndicator =
+ props.isPinned && !sortable?.isDragging ? (
+ props.pinningSupported ? (
+
+
+ }
+ >
+
+
+ Unpin thread
+
+ ) : (
+
+ )
+ ) : null;
if (variant === "slim") {
return (
@@ -1526,7 +1522,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
remain visible AND clickable while the row is hovered. Only
the time/jump label yields to the settle affordance. */}
{prBadge}
- {dragDestination ?? (
+ {sortable?.isDragging ? (
+ dragDestination
+ ) : (
{/* Read-only status labels yield to the hover actions. Woke is
itself an action, so it stays pointer-enabled and visible
@@ -4534,7 +4534,11 @@ export default function Sidebar() {
isPinned={thread.pinnedAt != null}
sortable={sortable}
dropSection={
- dragState?.activeKey === threadKey ? dragTargetSection : null
+ dragState?.activeKey === threadKey &&
+ dragTargetSection !== dragState.activeSection &&
+ dragTargetSection !== "snoozed"
+ ? dragTargetSection
+ : null
}
snoozeWakeLabelText={
section === "snoozed" && thread.snoozedUntil != null
@@ -4634,14 +4638,6 @@ export default function Sidebar() {
dragTargetSection !== "pinned"
? 1
: 0);
- const activeHint =
- from === "pinned"
- ? "Drop to unpin"
- : from === "settled"
- ? "Drop to un-settle"
- : from === "snoozed"
- ? "Drop to wake"
- : null;
const items: ReactNode[] = [
,
@@ -4679,7 +4674,6 @@ export default function Sidebar() {
key="pinned-divider"
marker="pinned-divider"
label="Active"
- hint={dragTargetSection === "active" ? activeHint : null}
isDropTarget={dragTargetSection === "active"}
visible={from !== null && previewPinnedCount > 0}
/>,
@@ -4690,7 +4684,7 @@ export default function Sidebar() {
,
@@ -4723,11 +4717,6 @@ export default function Sidebar() {
? "Settled"
: `Settled (${settledThreads.length})`
}
- hint={
- dragTargetSection === "settled" && from !== "settled"
- ? "Drop to settle"
- : null
- }
isDropTarget={dragTargetSection === "settled"}
toggle={{
expanded: settledShelfExpanded,
@@ -4741,7 +4730,7 @@ export default function Sidebar() {
,
diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md
index 5168041471d3..4286ed654d81 100644
--- a/docs/user/thread-sidebar.md
+++ b/docs/user/thread-sidebar.md
@@ -33,9 +33,11 @@ thread into the active list un-settles it. A snoozed thread can be dragged out o
shelf, which wakes it, but threads cannot be dragged into the shelf because snoozing needs a wake
time. Dragging a pinned thread out of the pinned section does not ask for unpin confirmation.
Pinned and active boundary labels appear only while dragging, without moving the rows. The
-destination boundary highlights and the thread shows which section it will land in. When there
-are no pins, drag to the top edge to pin a thread. Drop instructions also appear for empty sections
-and a collapsed settled shelf.
+destination boundary highlights. When you cross into another section, the dragged thread shows
+its destination, such as **→ Active**. Its usual pin, status, and hover actions hide during the
+drag. Reordering within the same section does not show a destination badge. When there are no
+pins, drag to the top edge to pin a thread. Section labels also identify empty sections and a
+collapsed settled shelf.
Drag within the pinned or active section to change its order. Other rows slide aside to show the
spot where the thread will land. Drops into either section keep the position you choose. On
From 36c48a6b7c03c9a9adbc3e55d4d9ae77eae6f816 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 6 Sep 2026 12:31:03 -0700
Subject: [PATCH 07/71] fix(mobile): keep pending tasks queued when a send
fails in flight (#10245)
Co-authored-by: Claude Fable 5
---
apps/mobile/src/state/thread-outbox-model.ts | 30 +++++++---
apps/mobile/src/state/thread-outbox.test.ts | 63 ++++++++++++++++++++
2 files changed, 86 insertions(+), 7 deletions(-)
diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts
index 99f32e5c1764..2676c935e01e 100644
--- a/apps/mobile/src/state/thread-outbox-model.ts
+++ b/apps/mobile/src/state/thread-outbox-model.ts
@@ -251,14 +251,30 @@ function errorMessage(error: unknown): string | null {
return typeof error === "string" ? error : null;
}
+/**
+ * Only a failure the server actually decided (`OrchestrationDispatchCommandError`,
+ * or an authorization rejection) means the payload itself is bad. The other
+ * typed failures a queued send can hit are transport-shaped: a socket that
+ * dropped mid-request (`RpcClientError` wrapping a Socket read/write/close
+ * reason), or an environment that is not connected or not registered. Those
+ * are matched by tag, not by message text, because a `SocketReadError` message
+ * is just "An error occurred during Read". A wrong answer here restores the
+ * pending task into a draft and it disappears from the list.
+ */
export function shouldRetryThreadOutboxDelivery(error: unknown): boolean {
- if (
- typeof error === "object" &&
- error !== null &&
- "_tag" in error &&
- error._tag === "ConnectionTransientError"
- ) {
- return true;
+ if (typeof error === "object" && error !== null && "_tag" in error) {
+ switch (error._tag) {
+ case "OrchestrationDispatchCommandError":
+ case "EnvironmentAuthorizationError":
+ return false;
+ case "ConnectionTransientError":
+ case "RpcClientError":
+ case "EnvironmentRpcUnavailableError":
+ case "EnvironmentNotRegisteredError":
+ return true;
+ default:
+ break;
+ }
}
return isTransportConnectionErrorMessage(errorMessage(error));
}
diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts
index c426d552a7e7..c186bc098e5b 100644
--- a/apps/mobile/src/state/thread-outbox.test.ts
+++ b/apps/mobile/src/state/thread-outbox.test.ts
@@ -1,13 +1,20 @@
import { describe, expect, it } from "@effect/vitest";
+import { EnvironmentNotRegisteredError } from "@t3tools/client-runtime/connection";
+import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors";
+import { EnvironmentRpcUnavailableError } from "@t3tools/client-runtime/rpc";
import {
CommandId,
+ EnvironmentAuthorizationError,
EnvironmentId,
MessageId,
+ OrchestrationDispatchCommandError,
ProjectId,
ProviderInstanceId,
ThreadId,
} from "@t3tools/contracts";
import { AtomRegistry } from "effect/unstable/reactivity";
+import * as RpcClientError from "effect/unstable/rpc/RpcClientError";
+import * as Socket from "effect/unstable/socket/Socket";
import { onTestFinished, vi } from "vite-plus/test";
const outboxFiles = vi.hoisted(() => new Map());
@@ -1399,6 +1406,62 @@ describe("thread outbox", () => {
}),
).toBe(true);
expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false);
+ expect(
+ shouldRetryThreadOutboxDelivery(
+ new OrchestrationDispatchCommandError({ message: "Thread no longer exists" }),
+ ),
+ ).toBe(false);
+ expect(
+ shouldRetryThreadOutboxDelivery(
+ new EnvironmentAuthorizationError({
+ message: "Missing scope",
+ requiredScope: "orchestration:operate",
+ }),
+ ),
+ ).toBe(false);
+ });
+
+ // A pending task created offline drains the moment the phone reconnects,
+ // which is exactly when the socket is most likely to drop again. Every way a
+ // request can fail in flight must retry; a restore turns the pending task
+ // into a draft and it disappears from the list.
+ it("retries every in-flight transport failure by tag, not by message text", () => {
+ const socketReasons = [
+ new Socket.SocketReadError({ cause: new Error("The network connection was lost.") }),
+ new Socket.SocketWriteError({ cause: new Error("Broken pipe") }),
+ new Socket.SocketCloseError({ code: 1006 }),
+ new Socket.SocketOpenError({ kind: "Timeout", cause: new Error("timeout") }),
+ ];
+ for (const reason of socketReasons) {
+ const error = new RpcClientError.RpcClientError({ reason });
+ expect(isTransportConnectionErrorMessage(error.message)).toBe(
+ reason._tag === "SocketCloseError" || reason._tag === "SocketOpenError",
+ );
+ expect(shouldRetryThreadOutboxDelivery(error)).toBe(true);
+ }
+ expect(
+ shouldRetryThreadOutboxDelivery(
+ new RpcClientError.RpcClientError({
+ reason: new RpcClientError.RpcClientDefect({
+ message: "Error decoding message",
+ cause: new Error("Unexpected end of JSON input"),
+ }),
+ }),
+ ),
+ ).toBe(true);
+ expect(
+ shouldRetryThreadOutboxDelivery(
+ new EnvironmentRpcUnavailableError({
+ environmentId: "environment-1",
+ message: "Home is not connected.",
+ }),
+ ),
+ ).toBe(true);
+ expect(
+ shouldRetryThreadOutboxDelivery(
+ new EnvironmentNotRegisteredError({ environmentId: EnvironmentId.make("environment-1") }),
+ ),
+ ).toBe(true);
});
it("retains queued messages when settings synchronization fails before startTurn", () => {
From c0bf35466c8aa1862572dfa84b51b6fe30fc82d6 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 6 Sep 2026 12:31:03 -0700
Subject: [PATCH 08/71] feat(mobile): show new-task drafts alongside pending
tasks in the thread list (#10260)
Co-authored-by: Claude Fable 5
---
apps/mobile/src/features/home/HomeScreen.tsx | 23 ++-
.../mobile/src/features/home/homeListItems.ts | 2 +-
.../src/features/home/homeThreadList.ts | 28 ++--
.../home/usePendingTaskListActions.ts | 29 +++-
.../threads/ThreadNavigationSidebar.tsx | 24 ++--
.../features/threads/thread-list-items.tsx | 56 +++++---
.../features/threads/thread-list-v2-items.tsx | 48 +++++--
.../src/features/threads/threadListV2.test.ts | 30 ++--
.../src/features/threads/threadListV2.ts | 2 +-
.../src/state/pending-new-tasks-model.test.ts | 126 ++++++++++++++++
.../src/state/pending-new-tasks-model.ts | 134 ++++++++++++++++++
.../mobile/src/state/use-pending-new-tasks.ts | 48 +++----
12 files changed, 430 insertions(+), 120 deletions(-)
create mode 100644 apps/mobile/src/state/pending-new-tasks-model.test.ts
create mode 100644 apps/mobile/src/state/pending-new-tasks-model.ts
diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index 4a0095165a2c..43640f9c070b 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -370,7 +370,7 @@ export function HomeScreen(props: HomeScreenProps) {
? props.pendingTasks
: props.pendingTasks.filter((pendingTask) =>
selectedProjectRefKeys.has(
- scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
+ scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
),
),
[threadListV2Enabled, props.pendingTasks, selectedProjectRefKeys],
@@ -740,10 +740,10 @@ export function HomeScreen(props: HomeScreenProps) {
props.pendingTasks.filter(
(pendingTask) =>
(props.selectedEnvironmentId === null ||
- pendingTask.message.environmentId === props.selectedEnvironmentId) &&
+ pendingTask.environmentId === props.selectedEnvironmentId) &&
(v2ScopedProjectKeys === null ||
v2ScopedProjectKeys.has(
- scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
+ scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
)) &&
(v2SearchQuery.length === 0 ||
pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
@@ -774,8 +774,8 @@ export function HomeScreen(props: HomeScreenProps) {
(nextItem?.type === "v2-pending" && !nextItem.showPendingDivider);
if (item.type === "v2-pending") {
const pendingScopeKey = scopedProjectKey(
- item.pendingTask.message.environmentId,
- item.pendingTask.creation.projectId,
+ item.pendingTask.environmentId,
+ item.pendingTask.projectId,
);
return (
1
- ? (props.savedConnectionsById[item.pendingTask.message.environmentId]
- ?.environmentLabel ?? null)
+ ? (props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ??
+ null)
: null
}
- environmentMachine={machineByEnvironmentId.get(item.pendingTask.message.environmentId)}
+ environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
showPendingDivider={item.showPendingDivider}
showTrailingDivider={showTrailingDivider}
onSelectPendingTask={props.onSelectPendingTask}
@@ -984,12 +984,9 @@ export function HomeScreen(props: HomeScreenProps) {
variant="compact"
pendingTask={item.pendingTask}
environmentLabel={
- props.savedConnectionsById[item.pendingTask.message.environmentId]
- ?.environmentLabel ?? null
+ props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null
}
- environmentMachine={machineByEnvironmentId.get(
- item.pendingTask.message.environmentId,
- )}
+ environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
isLast={item.isLast}
onSelectPendingTask={props.onSelectPendingTask}
onDeletePendingTask={props.onDeletePendingTask}
diff --git a/apps/mobile/src/features/home/homeListItems.ts b/apps/mobile/src/features/home/homeListItems.ts
index 6709a81e9d1e..910ddb5896b7 100644
--- a/apps/mobile/src/features/home/homeListItems.ts
+++ b/apps/mobile/src/features/home/homeListItems.ts
@@ -173,7 +173,7 @@ export function buildHomeListLayout(input: {
for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) {
items.push({
type: "pending-task",
- key: `pending-task:${pendingTask.message.messageId}`,
+ key: pendingTask.key,
pendingTask,
isLast:
pendingIndex === group.pendingTasks.length - 1 &&
diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts
index 2a9e0ec2cb86..f0c9e1bc602d 100644
--- a/apps/mobile/src/features/home/homeThreadList.ts
+++ b/apps/mobile/src/features/home/homeThreadList.ts
@@ -105,10 +105,8 @@ export function sortHomeProjectScopes(input: {
}
for (const pendingTask of input.pendingTasks) {
recordActivity(
- scopeKeyByProjectRef.get(
- scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
- ),
- Date.parse(pendingTask.message.createdAt),
+ scopeKeyByProjectRef.get(scopedProjectKey(pendingTask.environmentId, pendingTask.projectId)),
+ Date.parse(pendingTask.createdAt),
);
}
@@ -177,7 +175,7 @@ function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOr
Number.NEGATIVE_INFINITY,
);
return group.pendingTasks.reduce((latest, pendingTask) => {
- const timestamp = Date.parse(pendingTask.message.createdAt);
+ const timestamp = Date.parse(pendingTask.createdAt);
return Number.isNaN(timestamp) ? latest : Math.max(latest, timestamp);
}, latestThread);
}
@@ -235,14 +233,11 @@ export function buildHomeThreadGroups(input: {
}
for (const pendingTask of input.pendingTasks ?? []) {
- if (input.environmentId !== null && pendingTask.message.environmentId !== input.environmentId) {
+ if (input.environmentId !== null && pendingTask.environmentId !== input.environmentId) {
continue;
}
- const physicalKey = scopedProjectKey(
- pendingTask.message.environmentId,
- pendingTask.creation.projectId,
- );
+ const physicalKey = scopedProjectKey(pendingTask.environmentId, pendingTask.projectId);
let groupKey = groupKeyByProjectKey.get(physicalKey);
if (!groupKey) {
// The project shell is not loaded (environment offline / project gone).
@@ -254,16 +249,15 @@ export function buildHomeThreadGroups(input: {
key: groupKey,
projects: [
{
- environmentId: pendingTask.message.environmentId,
- id: pendingTask.creation.projectId,
- title: pendingTask.creation.projectTitle ?? "Unknown project",
- workspaceRoot:
- pendingTask.creation.projectCwd ?? String(pendingTask.creation.projectId),
+ environmentId: pendingTask.environmentId,
+ id: pendingTask.projectId,
+ title: pendingTask.projectTitle ?? "Unknown project",
+ workspaceRoot: pendingTask.projectCwd ?? String(pendingTask.projectId),
repositoryIdentity: null,
defaultModelSelection: null,
scripts: [],
- createdAt: pendingTask.message.createdAt,
- updatedAt: pendingTask.message.createdAt,
+ createdAt: pendingTask.createdAt,
+ updatedAt: pendingTask.createdAt,
},
],
pendingTasks: [],
diff --git a/apps/mobile/src/features/home/usePendingTaskListActions.ts b/apps/mobile/src/features/home/usePendingTaskListActions.ts
index 403c3af391de..e87df9dc243e 100644
--- a/apps/mobile/src/features/home/usePendingTaskListActions.ts
+++ b/apps/mobile/src/features/home/usePendingTaskListActions.ts
@@ -3,6 +3,7 @@ import { useCallback } from "react";
import { Alert } from "react-native";
import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal";
+import { clearComposerDraftContent } from "../../state/use-composer-drafts";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox";
@@ -14,12 +15,16 @@ export function usePendingTaskListActions(): {
const openPendingTask = useCallback(
(pendingTask: PendingNewTask) => {
+ // A draft is the project's own new-task composer content, so opening
+ // the project's new-task screen lands on it without extra params.
navigation.navigate("NewTaskSheet", {
screen: "NewTaskDraft",
params: {
- environmentId: String(pendingTask.message.environmentId),
- projectId: String(pendingTask.creation.projectId),
- pendingTaskId: String(pendingTask.message.messageId),
+ environmentId: String(pendingTask.environmentId),
+ projectId: String(pendingTask.projectId),
+ ...(pendingTask.kind === "pending"
+ ? { pendingTaskId: String(pendingTask.message.messageId) }
+ : {}),
},
});
},
@@ -27,6 +32,24 @@ export function usePendingTaskListActions(): {
);
const confirmDeletePendingTask = useCallback((pendingTask: PendingNewTask) => {
+ if (pendingTask.kind === "draft") {
+ Alert.alert("Discard draft?", `“${pendingTask.title}” will be removed.`, [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Discard",
+ style: "destructive",
+ onPress: () => {
+ // Same reset a submit performs: the next task in this project
+ // re-resolves project defaults instead of inheriting the pick.
+ clearComposerDraftContent(pendingTask.draftKey, {
+ clearModelSelection: true,
+ clearWorkspaceSelection: true,
+ });
+ },
+ },
+ ]);
+ return;
+ }
Alert.alert(
"Delete pending task?",
`“${pendingTask.title}” has not been sent yet and will be removed from the outbox.`,
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index 12f5ac8ce4f3..4a49a34fc0ab 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -300,7 +300,7 @@ function ThreadNavigationSidebarPane(
? pendingTasks
: pendingTasks.filter((pendingTask) =>
selectedProjectRefs.has(
- scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
+ scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
),
),
[threadListV2Enabled, pendingTasks, selectedProjectRefs],
@@ -569,10 +569,10 @@ function ThreadNavigationSidebarPane(
const v2PendingTasks = pendingTasks.filter(
(pendingTask) =>
(options.selectedEnvironmentId === null ||
- pendingTask.message.environmentId === options.selectedEnvironmentId) &&
+ pendingTask.environmentId === options.selectedEnvironmentId) &&
(selectedProjectRefs === null ||
selectedProjectRefs.has(
- scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId),
+ scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
)) &&
(v2SearchQuery.length === 0 ||
pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
@@ -852,8 +852,8 @@ function ThreadNavigationSidebarPane(
switch (item.type) {
case "v2-pending": {
const pendingScopeKey = scopedProjectKey(
- item.pendingTask.message.environmentId,
- item.pendingTask.creation.projectId,
+ item.pendingTask.environmentId,
+ item.pendingTask.projectId,
);
return (
1
- ? (savedConnectionsById[item.pendingTask.message.environmentId]
- ?.environmentLabel ?? null)
+ ? (savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null)
: null
}
- environmentMachine={machineByEnvironmentId.get(
- item.pendingTask.message.environmentId,
- )}
+ environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
pane="sidebar"
showPendingDivider={item.showPendingDivider}
onSelectPendingTask={openPendingTask}
@@ -1006,12 +1003,9 @@ function ThreadNavigationSidebarPane(
variant="sidebar"
pendingTask={item.pendingTask}
environmentLabel={
- savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ??
- null
+ savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null
}
- environmentMachine={machineByEnvironmentId.get(
- item.pendingTask.message.environmentId,
- )}
+ environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
isLast={item.isLast}
onSelectPendingTask={openPendingTask}
onDeletePendingTask={confirmDeletePendingTask}
diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx
index e65cf7a9fb43..e50d8b8151e6 100644
--- a/apps/mobile/src/features/threads/thread-list-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-items.tsx
@@ -267,10 +267,17 @@ const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [
{ id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
];
+const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [
+ { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } },
+];
+
/**
- * A queued new task waiting in the outbox for its environment to reconnect.
- * Tapping reopens the new-task composer with everything prefilled; the row
- * disappears once the task is delivered and the real thread arrives.
+ * Unsent work: a task queued in the outbox for its environment to reconnect,
+ * or a draft still sitting in the project's new-task composer. Tapping
+ * reopens the composer with everything prefilled; the row disappears once
+ * the work is sent and the real thread arrives. The two kinds differ in what
+ * happens next, so the pill and icon say which one this is: a queued task
+ * sends itself, a draft waits for the user.
*/
export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
readonly variant: ThreadListVariant;
@@ -284,10 +291,15 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
const compact = props.variant === "compact";
const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;
- const timestamp = relativeTime(pendingTask.message.createdAt);
- const subtitleParts = [props.environmentLabel, pendingTask.creation.branch].filter(
- (part): part is string => Boolean(part),
- );
+ const isDraft = pendingTask.kind === "draft";
+ const timestamp = isDraft ? null : relativeTime(pendingTask.createdAt);
+ // The pill only has room for one word, so what happens next goes in the
+ // subtitle: a queued task sends itself, a draft waits for the user.
+ const subtitleParts = [
+ isDraft ? null : "Sends on reconnect",
+ props.environmentLabel,
+ pendingTask.branch,
+ ].filter((part): part is string => Boolean(part));
const handleMenuAction = useCallback(
({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
@@ -296,7 +308,11 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
[onDeletePendingTask, pendingTask],
);
- const statusPill = (
+ const statusPill = isDraft ? (
+
+ Draft
+
+ ) : (
Pending
@@ -306,7 +322,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
subtitleParts.length > 0 ? (
) : null;
+ const accessibilityHint = isDraft
+ ? "Opens the draft in the new task composer"
+ : "Sends when the environment reconnects. Opens the task for editing";
+
const rowContent = compact ? (
{statusPill}
- {timestamp}
+ {timestamp !== null ? (
+ {timestamp}
+ ) : null}
) : (
{statusPill}
-
- {timestamp}
-
+ {timestamp !== null ? (
+
+ {timestamp}
+
+ ) : null}
{subtitleRow}
@@ -395,7 +419,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
return (
diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
index 37b211e835f9..fc77f40c5afc 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -186,12 +186,16 @@ const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [
{ id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
];
+const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [
+ { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } },
+];
+
/**
- * A queued new task, in the same idiom as an active v2 row: it is work the
- * user wrote, so it reads like the threads it will become. "Queued" takes
- * the status slot — the state is the one thing that differs — and stays
- * uncolored because nothing is asked of the user; the environment is simply
- * not reachable yet.
+ * Unsent work, in the same idiom as an active v2 row: it is work the user
+ * wrote, so it reads like the thread it will become. The status slot says
+ * what happens next, not where the item sits: "Sends on reconnect" stays
+ * uncolored because nothing is asked of the user; "Draft" takes the amber the
+ * web sidebar uses for drafts, because this one waits on the user.
*/
export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props: {
readonly pendingTask: PendingNewTask;
@@ -201,7 +205,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
/** Drawn beside the label; ignored while the label is null. */
readonly environmentMachine?: EnvironmentMachineKind;
readonly pane?: "screen" | "sidebar";
- /** Draws the "Pending" divider above the first queued row. */
+ /** Draws the "Unsent" divider above the first draft or queued row. */
readonly showPendingDivider: boolean;
/** Keeps row hairlines inside a section; section headers draw their own rule. */
readonly showTrailingDivider?: boolean;
@@ -210,9 +214,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
}) {
const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;
const sidebarPane = props.pane === "sidebar";
- const projectTitle =
- props.projectTitle ?? props.project?.title ?? pendingTask.creation.projectTitle ?? "";
- const branch = pendingTask.creation.branch;
+ const isDraft = pendingTask.kind === "draft";
+ const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.projectTitle ?? "";
+ const branch = pendingTask.branch;
const handleMenuAction = useCallback(
({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
@@ -226,7 +230,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
{props.project ? (
{projectTitle}
- Queued
+ {isDraft ? (
+
+
+ Draft
+
+ ) : (
+ Sends on reconnect
+ )}
{/* One line, unlike the two an active row allows: a queued title is
derived from the whole prompt rather than written as a title, so the
@@ -272,15 +288,19 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
return (
<>
{props.showPendingDivider ? (
-
+
) : null}
{
});
function makePendingTask(id: string): PendingNewTask {
+ const creation = {
+ projectId: ProjectId.make("project-1"),
+ workspaceMode: "worktree" as const,
+ branch: null,
+ worktreePath: null,
+ };
return {
+ kind: "pending",
+ key: `pending-task:${id}`,
+ environmentId,
+ projectId: creation.projectId,
+ projectTitle: undefined,
+ projectCwd: undefined,
+ branch: null,
+ title: id,
+ createdAt: NOW,
message: {
environmentId,
threadId: ThreadId.make(`thread-${id}`),
@@ -875,20 +890,9 @@ function makePendingTask(id: string): PendingNewTask {
text: id,
attachments: [],
createdAt: NOW,
- creation: {
- projectId: ProjectId.make("project-1"),
- workspaceMode: "worktree",
- branch: null,
- worktreePath: null,
- },
+ creation,
},
- creation: {
- projectId: ProjectId.make("project-1"),
- workspaceMode: "worktree",
- branch: null,
- worktreePath: null,
- },
- title: id,
+ creation,
};
}
diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts
index 7b6b44c00bdf..a4a977c9df67 100644
--- a/apps/mobile/src/features/threads/threadListV2.ts
+++ b/apps/mobile/src/features/threads/threadListV2.ts
@@ -298,7 +298,7 @@ export function buildThreadListV2ListItems(input: {
}));
const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({
type: "v2-pending",
- key: `v2-pending:${pendingTask.message.messageId}`,
+ key: `v2-${pendingTask.key}`,
pendingTask,
showPendingDivider: index === 0,
}));
diff --git a/apps/mobile/src/state/pending-new-tasks-model.test.ts b/apps/mobile/src/state/pending-new-tasks-model.test.ts
new file mode 100644
index 000000000000..6cedca3814a8
--- /dev/null
+++ b/apps/mobile/src/state/pending-new-tasks-model.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from "@effect/vitest";
+import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
+
+import type { QueuedThreadMessage } from "./thread-outbox-model";
+import type { ComposerDraft } from "./use-composer-drafts";
+import { buildPendingNewTasks, parseNewTaskDraftKey } from "./pending-new-tasks-model";
+
+const environmentId = EnvironmentId.make("env-1");
+const projectId = ProjectId.make("project-1");
+const NOW = "2026-09-05T12:00:00.000Z";
+
+function queuedCreation(id: string, createdAt: string): QueuedThreadMessage {
+ return {
+ environmentId,
+ threadId: ThreadId.make(`thread-${id}`),
+ messageId: MessageId.make(id),
+ commandId: CommandId.make(`command-${id}`),
+ text: `queued ${id}`,
+ attachments: [],
+ createdAt,
+ creation: {
+ projectId,
+ workspaceMode: "local",
+ branch: "main",
+ worktreePath: null,
+ },
+ };
+}
+
+function draft(text: string, overrides: Partial = {}): ComposerDraft {
+ return { text, attachments: [], ...overrides };
+}
+
+describe("parseNewTaskDraftKey", () => {
+ it("splits the environment and project ids", () => {
+ expect(parseNewTaskDraftKey(`new-task:${environmentId}:${projectId}`)).toEqual({
+ environmentId,
+ projectId,
+ });
+ });
+
+ it("ignores thread drafts and pending-task editor drafts", () => {
+ expect(parseNewTaskDraftKey(`${environmentId}:thread-1`)).toBeNull();
+ expect(parseNewTaskDraftKey("pending-task:message-1")).toBeNull();
+ expect(parseNewTaskDraftKey("new-task:")).toBeNull();
+ expect(parseNewTaskDraftKey("new-task:env-only")).toBeNull();
+ });
+});
+
+describe("buildPendingNewTasks", () => {
+ it("surfaces new-task drafts with content alongside queued creations", () => {
+ const tasks = buildPendingNewTasks({
+ queuedMessages: [queuedCreation("a", "2026-09-05T10:00:00.000Z")],
+ drafts: {
+ [`new-task:${environmentId}:${projectId}`]: draft("fix the offline outbox", {
+ workspaceSelection: { mode: "worktree", branch: "main", worktreePath: null },
+ }),
+ },
+ now: NOW,
+ });
+
+ expect(tasks.map((task) => [task.kind, task.title, task.branch])).toEqual([
+ ["draft", "fix the offline outbox", "main"],
+ ["pending", "queued a", "main"],
+ ]);
+ expect(tasks[0]).toMatchObject({
+ key: `draft-task:new-task:${environmentId}:${projectId}`,
+ environmentId,
+ projectId,
+ draftKey: `new-task:${environmentId}:${projectId}`,
+ });
+ });
+
+ it("hides settings-only drafts and drafts for other surfaces", () => {
+ const tasks = buildPendingNewTasks({
+ queuedMessages: [],
+ drafts: {
+ [`new-task:${environmentId}:${projectId}`]: draft("", {
+ modelSelection: { instanceId: "codex" as never, model: "gpt" },
+ }),
+ [`new-task:${environmentId}:${projectId}-2`]: draft(" "),
+ [`${environmentId}:thread-1`]: draft("thread composer text"),
+ "pending-task:message-1": draft("editor copy of a queued task"),
+ },
+ now: NOW,
+ });
+
+ expect(tasks).toEqual([]);
+ });
+
+ it("titles an attachment-only draft by its attachment count", () => {
+ const attachment = {
+ type: "image",
+ id: "image-1",
+ uri: "file:///image-1.png",
+ mimeType: "image/png",
+ name: "image-1.png",
+ width: 1,
+ height: 1,
+ sizeBytes: 1,
+ } as unknown as ComposerDraft["attachments"][number];
+ const tasks = buildPendingNewTasks({
+ queuedMessages: [],
+ drafts: {
+ [`new-task:${environmentId}:${projectId}`]: draft("", { attachments: [attachment] }),
+ },
+ now: NOW,
+ });
+
+ expect(tasks.map((task) => task.title)).toEqual(["1 attachment"]);
+ });
+
+ it("orders queued creations newest first and skips existing-thread messages", () => {
+ const tasks = buildPendingNewTasks({
+ queuedMessages: [
+ queuedCreation("old", "2026-09-05T08:00:00.000Z"),
+ { ...queuedCreation("follow-up", "2026-09-05T11:00:00.000Z"), creation: undefined },
+ queuedCreation("new", "2026-09-05T10:00:00.000Z"),
+ ],
+ drafts: {},
+ now: NOW,
+ });
+
+ expect(tasks.map((task) => task.title)).toEqual(["queued new", "queued old"]);
+ });
+});
diff --git a/apps/mobile/src/state/pending-new-tasks-model.ts b/apps/mobile/src/state/pending-new-tasks-model.ts
new file mode 100644
index 000000000000..3dafea540456
--- /dev/null
+++ b/apps/mobile/src/state/pending-new-tasks-model.ts
@@ -0,0 +1,134 @@
+import { EnvironmentId, ProjectId } from "@t3tools/contracts";
+
+import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
+import type { QueuedThreadCreation, QueuedThreadMessage } from "./thread-outbox-model";
+import type { ComposerDraft } from "./use-composer-drafts";
+
+/**
+ * Unsent work that will become a thread, shaped for thread-list presentation.
+ * A `pending` task sits in the outbox and sends itself when its environment
+ * reconnects; a `draft` is the project's new-task composer content, which
+ * only sends when the user submits it. Both share the list slot so the user
+ * can find everything they have written but not yet started in one place.
+ */
+export type PendingNewTask = PendingQueuedTask | PendingDraftTask;
+
+export interface PendingQueuedTask {
+ readonly kind: "pending";
+ readonly key: string;
+ readonly environmentId: EnvironmentId;
+ readonly projectId: ProjectId;
+ readonly projectTitle: string | undefined;
+ readonly projectCwd: string | undefined;
+ readonly branch: string | null;
+ readonly title: string;
+ readonly createdAt: string;
+ readonly message: QueuedThreadMessage;
+ readonly creation: QueuedThreadCreation;
+}
+
+export interface PendingDraftTask {
+ readonly kind: "draft";
+ readonly key: string;
+ readonly environmentId: EnvironmentId;
+ readonly projectId: ProjectId;
+ readonly projectTitle: undefined;
+ readonly projectCwd: undefined;
+ readonly branch: string | null;
+ readonly title: string;
+ /** Drafts have no creation timestamp; they sort as current work. */
+ readonly createdAt: string;
+ readonly draftKey: string;
+ readonly draft: ComposerDraft;
+}
+
+const NEW_TASK_DRAFT_PREFIX = "new-task:";
+
+/** Parses a `new-task::` composer draft key. */
+export function parseNewTaskDraftKey(
+ draftKey: string,
+): { readonly environmentId: EnvironmentId; readonly projectId: ProjectId } | null {
+ if (!draftKey.startsWith(NEW_TASK_DRAFT_PREFIX)) {
+ return null;
+ }
+ const scope = draftKey.slice(NEW_TASK_DRAFT_PREFIX.length);
+ const separator = scope.lastIndexOf(":");
+ if (separator <= 0 || separator === scope.length - 1) {
+ return null;
+ }
+ return {
+ environmentId: EnvironmentId.make(scope.slice(0, separator)),
+ projectId: ProjectId.make(scope.slice(separator + 1)),
+ };
+}
+
+/**
+ * Settings-only drafts (a model pick with no text) are not work the user
+ * would look for in the list; only text or attachments make a draft visible.
+ */
+export function composerDraftHasUserContent(draft: ComposerDraft): boolean {
+ return draft.text.trim().length > 0 || draft.attachments.length > 0;
+}
+
+function draftTitle(draft: ComposerDraft): string {
+ if (draft.text.trim().length > 0) {
+ return deriveThreadTitleFromPrompt(draft.text);
+ }
+ const count = draft.attachments.length;
+ return count === 1 ? "1 attachment" : `${count} attachments`;
+}
+
+export function buildPendingNewTasks(input: {
+ readonly queuedMessages: ReadonlyArray;
+ readonly drafts: Readonly>;
+ /** ISO timestamp drafts sort by; they carry no creation time of their own. */
+ readonly now: string;
+}): ReadonlyArray {
+ const tasks: PendingNewTask[] = [];
+ for (const message of input.queuedMessages) {
+ if (!message.creation) {
+ continue;
+ }
+ tasks.push({
+ kind: "pending",
+ key: `pending-task:${message.messageId}`,
+ environmentId: message.environmentId,
+ projectId: message.creation.projectId,
+ projectTitle: message.creation.projectTitle,
+ projectCwd: message.creation.projectCwd,
+ branch: message.creation.branch,
+ title: deriveThreadTitleFromPrompt(message.text),
+ createdAt: message.createdAt,
+ message,
+ creation: message.creation,
+ });
+ }
+ for (const [draftKey, draft] of Object.entries(input.drafts)) {
+ const ref = parseNewTaskDraftKey(draftKey);
+ if (ref === null || !composerDraftHasUserContent(draft)) {
+ continue;
+ }
+ tasks.push({
+ kind: "draft",
+ key: `draft-task:${draftKey}`,
+ environmentId: ref.environmentId,
+ projectId: ref.projectId,
+ projectTitle: undefined,
+ projectCwd: undefined,
+ branch: draft.workspaceSelection?.branch ?? null,
+ title: draftTitle(draft),
+ createdAt: input.now,
+ draftKey,
+ draft,
+ });
+ }
+ // Drafts are what the user is writing now, so they lead; queued tasks
+ // follow newest-first.
+ tasks.sort((left, right) => {
+ if (left.kind !== right.kind) {
+ return left.kind === "draft" ? -1 : 1;
+ }
+ return right.createdAt.localeCompare(left.createdAt) || left.key.localeCompare(right.key);
+ });
+ return tasks;
+}
diff --git a/apps/mobile/src/state/use-pending-new-tasks.ts b/apps/mobile/src/state/use-pending-new-tasks.ts
index ccfe1527b3dc..d4d4d5c7c9ea 100644
--- a/apps/mobile/src/state/use-pending-new-tasks.ts
+++ b/apps/mobile/src/state/use-pending-new-tasks.ts
@@ -1,35 +1,29 @@
+import { useAtomValue } from "@effect/atom-react";
import { useMemo } from "react";
-import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
-import {
- flattenQueuedThreadMessages,
- type QueuedThreadCreation,
- type QueuedThreadMessage,
-} from "./thread-outbox-model";
+import { buildPendingNewTasks, type PendingNewTask } from "./pending-new-tasks-model";
+import { flattenQueuedThreadMessages } from "./thread-outbox-model";
+import { composerDraftsAtom } from "./use-composer-drafts";
import { useThreadOutboxMessages } from "./use-thread-outbox";
-/** A queued new-task creation, shaped for thread-list presentation. */
-export interface PendingNewTask {
- readonly message: QueuedThreadMessage;
- readonly creation: QueuedThreadCreation;
- readonly title: string;
-}
+export type {
+ PendingDraftTask,
+ PendingNewTask,
+ PendingQueuedTask,
+} from "./pending-new-tasks-model";
export function usePendingNewTasks(): ReadonlyArray {
const queuedMessagesByThreadKey = useThreadOutboxMessages();
- return useMemo(() => {
- const tasks: PendingNewTask[] = [];
- for (const message of flattenQueuedThreadMessages(queuedMessagesByThreadKey)) {
- if (!message.creation) {
- continue;
- }
- tasks.push({
- message,
- creation: message.creation,
- title: deriveThreadTitleFromPrompt(message.text),
- });
- }
- tasks.sort((left, right) => right.message.createdAt.localeCompare(left.message.createdAt));
- return tasks;
- }, [queuedMessagesByThreadKey]);
+ const drafts = useAtomValue(composerDraftsAtom);
+ return useMemo(
+ () =>
+ buildPendingNewTasks({
+ queuedMessages: flattenQueuedThreadMessages(queuedMessagesByThreadKey),
+ drafts,
+ // Stamped when the inputs change, not per render, so a draft keeps one
+ // sort position while the user is not typing in it.
+ now: new Date().toISOString(),
+ }),
+ [queuedMessagesByThreadKey, drafts],
+ );
}
From 8e129a0dfb35ac2c03a7320245b5bfb976f5d3bf Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 6 Sep 2026 12:31:03 -0700
Subject: [PATCH 09/71] feat(mobile): allow several new-task drafts per project
(#10327)
Co-authored-by: Claude Fable 5
---
.../home/usePendingTaskListActions.ts | 4 +-
.../threads/NewTaskDraftRouteScreen.tsx | 2 +
.../features/threads/NewTaskDraftScreen.tsx | 50 ++-
.../threads/new-task-flow-provider.tsx | 81 ++++-
.../lib/composerAttachmentUploadQueue.test.ts | 18 +
.../src/lib/composerAttachmentUploadQueue.ts | 20 +-
.../src/state/composer-attachment-uploads.ts | 4 +-
apps/mobile/src/state/new-task-draft-key.ts | 30 ++
.../src/state/pending-new-tasks-model.test.ts | 66 ++--
.../src/state/pending-new-tasks-model.ts | 45 +--
.../src/state/use-composer-drafts.test.ts | 323 ++++++++++-------
apps/mobile/src/state/use-composer-drafts.ts | 325 +++++++++++-------
.../mobile/src/state/use-pending-new-tasks.ts | 3 -
.../src/state/use-thread-outbox-drain.test.ts | 13 +-
.../src/state/use-thread-outbox-drain.ts | 25 +-
15 files changed, 664 insertions(+), 345 deletions(-)
create mode 100644 apps/mobile/src/state/new-task-draft-key.ts
diff --git a/apps/mobile/src/features/home/usePendingTaskListActions.ts b/apps/mobile/src/features/home/usePendingTaskListActions.ts
index e87df9dc243e..dcf127ccae20 100644
--- a/apps/mobile/src/features/home/usePendingTaskListActions.ts
+++ b/apps/mobile/src/features/home/usePendingTaskListActions.ts
@@ -15,8 +15,6 @@ export function usePendingTaskListActions(): {
const openPendingTask = useCallback(
(pendingTask: PendingNewTask) => {
- // A draft is the project's own new-task composer content, so opening
- // the project's new-task screen lands on it without extra params.
navigation.navigate("NewTaskSheet", {
screen: "NewTaskDraft",
params: {
@@ -24,7 +22,7 @@ export function usePendingTaskListActions(): {
projectId: String(pendingTask.projectId),
...(pendingTask.kind === "pending"
? { pendingTaskId: String(pendingTask.message.messageId) }
- : {}),
+ : { draftId: pendingTask.draftKey }),
},
});
},
diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx
index 8e6819378a6d..dc1ee942d13a 100644
--- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx
@@ -9,6 +9,7 @@ type NewTaskDraftRouteParams = {
readonly projectId?: string | string[];
readonly title?: string | string[];
readonly pendingTaskId?: string | string[];
+ readonly draftId?: string | string[];
readonly incomingShareId?: string | string[];
};
@@ -43,6 +44,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps
>
);
diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
index a50895bd33da..e2afdbb498a4 100644
--- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@@ -83,6 +83,7 @@ import {
restoreComposerDraftSnapshot,
scheduleUnusedComposerAttachmentCleanup,
type ComposerDraft,
+ waitForComposerDraftsLoaded,
} from "../../state/use-composer-drafts";
import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
import {
@@ -150,6 +151,8 @@ export function NewTaskDraftScreen(props: {
};
/** Queued outbox message id when editing an existing pending task. */
readonly pendingTaskId?: string;
+ /** Existing new-task draft key to resume (a Draft row in the thread list). */
+ readonly draftId?: string;
/** Durable native share inbox item to merge into this project draft. */
readonly incomingShareId?: string;
}) {
@@ -420,7 +423,44 @@ export function NewTaskDraftScreen(props: {
};
}, []);
- const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask } = flow;
+ const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask, openDraft } = flow;
+ // A Draft row opens its own draft; a fresh New Task never reuses one.
+ // Drafts hydrate from disk and projects arrive with the shell snapshot, so
+ // on a cold launch the draft or its project can be missing for a moment;
+ // wait for hydration and retry while projects load. Attempt each id once
+ // after that so a draft discarded mid-session does not keep bouncing to
+ // the picker.
+ const attemptedDraftIdRef = useRef(null);
+ useEffect(() => {
+ if (!props.draftId || props.pendingTaskId) {
+ return;
+ }
+ const draftId = props.draftId;
+ if (attemptedDraftIdRef.current === draftId) {
+ return;
+ }
+ let cancelled = false;
+ void waitForComposerDraftsLoaded().then(() => {
+ if (cancelled || attemptedDraftIdRef.current === draftId) {
+ return;
+ }
+ if (openDraft(draftId)) {
+ attemptedDraftIdRef.current = draftId;
+ return;
+ }
+ if (getComposerDraftSnapshot(draftId).project !== undefined && projects.length === 0) {
+ // The draft exists; its project has not arrived yet. Retry on the
+ // next projects change instead of giving up.
+ return;
+ }
+ attemptedDraftIdRef.current = draftId;
+ navigation.dispatch(StackActions.replace("NewTask"));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [navigation, openDraft, projects, props.draftId, props.pendingTaskId]);
+
const attemptedPendingTaskIdRef = useRef(null);
useEffect(() => {
if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) {
@@ -457,9 +497,10 @@ export function NewTaskDraftScreen(props: {
const lastInitialProjectRefRef = useRef(props.initialProjectRef);
useEffect(() => {
- // Pending-task editing owns project selection (and must not fall through
- // to the replace("NewTask") fallback while its hydration is in flight).
- if (props.pendingTaskId) {
+ // Pending-task editing and draft resumption own project selection (and
+ // must not fall through to the replace("NewTask") fallback while their
+ // hydration is in flight).
+ if (props.pendingTaskId || props.draftId) {
return;
}
if (lastInitialProjectRefRef.current !== props.initialProjectRef) {
@@ -518,6 +559,7 @@ export function NewTaskDraftScreen(props: {
props.initialProjectRef,
props.incomingShareId,
props.pendingTaskId,
+ props.draftId,
navigation,
selectedProject,
selectedProjectKey,
diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
index 4020c1de9417..5df507ea671b 100644
--- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx
+++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
@@ -43,11 +43,14 @@ import { useEnvironmentQuery } from "../../state/query";
import {
appendComposerDraftAttachments,
clearComposerDraft,
- copyComposerDraftContentIfEmpty,
+ composerDraftsAtom,
+ createNewTaskDraft,
getComposerDraftSnapshot,
isComposerDraftEmpty,
+ isNewTaskDraftKey,
removeComposerDraftAttachment,
replaceComposerDraftAttachments,
+ retargetNewTaskDraft,
scheduleUnusedComposerAttachmentCleanup,
setComposerDraftText,
setStickyComposerModelSelection,
@@ -169,6 +172,12 @@ type NewTaskFlowContextValue = {
readonly filteredBranches: ReadonlyArray;
readonly reset: () => void;
readonly setProject: (project: EnvironmentProject) => void;
+ /**
+ * Binds the composer to an existing new-task draft (a row in the thread
+ * list). Returns false when the draft is gone, so the caller can fall back
+ * to a fresh one.
+ */
+ readonly openDraft: (draftKey: string) => boolean;
readonly selectEnvironment: (environmentId: EnvironmentId) => void;
readonly setSelectedModelKey: (
key: string | null,
@@ -232,6 +241,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
? selectedEnvironmentIdOverride
: (projects[0]?.environmentId ?? null);
const [selectedProjectKey, setSelectedProjectKey] = useState(null);
+ // The new-task draft the composer is bound to. Null until a project is
+ // chosen; each New Task entry mints its own, so a project can hold several.
+ const [activeDraftKey, setActiveDraftKey] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [branchQuery, setBranchQuery] = useState("");
const [expandedProvider, setExpandedProvider] = useState(null);
@@ -247,6 +259,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const reset = useCallback(() => {
setSelectedEnvironmentId(null);
setSelectedProjectKey(null);
+ setActiveDraftKey(null);
setSubmitting(false);
setBranchQuery("");
setExpandedProvider(null);
@@ -367,12 +380,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedProject?.environmentId ?? null,
);
// While a queued pending task is being edited its draft lives under a key
- // scoped to the queued message, so per-project new-task drafts stay intact.
+ // scoped to the queued message, so new-task drafts stay intact.
const selectedProjectDraftKey = editingPendingTask
? pendingTaskDraftKey(editingPendingTask.messageId)
: selectedProject
- ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`
+ ? activeDraftKey
: null;
+ // selectedProject can resolve without setProject ever running (the
+ // environment's first project is the fallback, and the draft screen skips
+ // setProject when the route's project already matches it). The composer
+ // still needs a draft to write into, so bind one the moment a project is
+ // in view and nothing else owns the key.
+ useEffect(() => {
+ if (activeDraftKey !== null || editingPendingTask !== null || selectedProject === null) {
+ return;
+ }
+ setActiveDraftKey(
+ createNewTaskDraft({
+ environmentId: selectedProject.environmentId,
+ projectId: selectedProject.id,
+ }),
+ );
+ }, [activeDraftKey, editingPendingTask, selectedProject]);
const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
const prompt = selectedProjectDraft.text;
const attachments = selectedProjectDraft.attachments;
@@ -625,20 +654,19 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
);
}, [availableBranches, branchQuery]);
- // New-task drafts are keyed per (environment, project), so retargeting the
- // composer would otherwise show the target's empty draft and strand what the
- // user typed under the old key.
+ // The composer's draft follows the project it will be sent to: switching
+ // mid-compose keeps the same draft and moves it, so typed text follows the
+ // user. A pending-task edit owns its own key and is untouched here.
const carryDraftContentTo = useCallback(
(project: EnvironmentProject) => {
- const nextDraftKey = `new-task:${scopedProjectKey(project.environmentId, project.id)}`;
- if (
- selectedProjectDraftKey?.startsWith("new-task:") &&
- selectedProjectDraftKey !== nextDraftKey
- ) {
- void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey);
+ const target = { environmentId: project.environmentId, projectId: project.id };
+ if (activeDraftKey !== null && isNewTaskDraftKey(activeDraftKey)) {
+ retargetNewTaskDraft(activeDraftKey, target);
+ } else if (!editingPendingTaskRef.current) {
+ setActiveDraftKey(createNewTaskDraft(target));
}
},
- [selectedProjectDraftKey],
+ [activeDraftKey],
);
const setProject = useCallback(
@@ -650,6 +678,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
[carryDraftContentTo],
);
+ const openDraft = useCallback(
+ (draftKey: string): boolean => {
+ const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey];
+ const stamp = draft?.project;
+ if (!isNewTaskDraftKey(draftKey) || !stamp) {
+ return false;
+ }
+ // The stamped project must be loaded: selectedProject falls back to
+ // the environment's first project otherwise, and the draft would be
+ // sent somewhere the user never chose.
+ const projectLoaded = projects.some(
+ (project) =>
+ project.environmentId === stamp.environmentId && project.id === stamp.projectId,
+ );
+ if (!projectLoaded) {
+ return false;
+ }
+ setActiveDraftKey(draftKey);
+ setSelectedEnvironmentId(stamp.environmentId);
+ setSelectedProjectKey(scopedProjectKey(stamp.environmentId, stamp.projectId));
+ return true;
+ },
+ [projects],
+ );
+
const selectEnvironment = useCallback(
(environmentId: EnvironmentId) => {
const match = resolveEnvironmentProjectMatch(
@@ -1085,6 +1138,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
filteredBranches,
reset,
setProject,
+ openDraft,
selectEnvironment,
setSelectedModelKey,
setWorkspaceMode,
@@ -1148,6 +1202,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedProjectKey,
selectedWorktreePath,
setProject,
+ openDraft,
selectBranch,
selectEnvironment,
setInteractionMode,
diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts
index 6b040b698e3d..2ebacc740891 100644
--- a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts
+++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts
@@ -230,6 +230,24 @@ describe("draft upload scope and offline submission", () => {
);
});
+ it("reads an id-keyed new-task draft's environment from its project stamp", () => {
+ const stamped = {
+ project: {
+ environmentId,
+ projectId: "project" as never,
+ createdAt: "2026-09-05T00:00:00.000Z",
+ },
+ };
+ expect(composerDraftEnvironmentId("new-task:abc123-def456", [], stamped)).toBe(environmentId);
+ // An id-keyed draft that lost its stamp belongs to nobody: uploads must
+ // not start and sign-out must not sweep it into some other environment.
+ expect(composerDraftEnvironmentId("new-task:abc123-def456", [])).toBeNull();
+ // The stamp wins over a legacy-looking key when both are present.
+ expect(composerDraftEnvironmentId("new-task:environment-2:project", [], stamped)).toBe(
+ environmentId,
+ );
+ });
+
it("allows offline queuing while a connected composer waits for upload or retry", () => {
const key = composerAttachmentUploadKey(environmentId, "file");
const input = {
diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.ts
index 071afefa4c7d..538b343abeb0 100644
--- a/apps/mobile/src/lib/composerAttachmentUploadQueue.ts
+++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.ts
@@ -1,6 +1,7 @@
import { EnvironmentId, type ServerConfig } from "@t3tools/contracts";
import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
+import { parseLegacyNewTaskDraftKey } from "../state/new-task-draft-key";
import type { DraftComposerAttachment } from "./composerImages";
export interface ComposerAttachmentUploadRequest {
@@ -20,12 +21,19 @@ export function composerAttachmentUploadKey(
return `${environmentId}:${attachmentId}`;
}
+/**
+ * Which environment a composer draft belongs to. Thread drafts carry it in
+ * the key; pending-task editor drafts borrow it from the queued message;
+ * new-task drafts carry it in their project stamp (legacy project-keyed
+ * new-task drafts still parse from the key until they are migrated on load).
+ */
export function composerDraftEnvironmentId(
draftKey: string,
queuedMessages: ReadonlyArray<{
readonly messageId: string;
readonly environmentId: EnvironmentId;
}>,
+ draft?: { readonly project?: { readonly environmentId: EnvironmentId } },
): EnvironmentId | null {
if (draftKey.startsWith("pending-task:")) {
return (
@@ -33,9 +41,15 @@ export function composerDraftEnvironmentId(
?.environmentId ?? null
);
}
- const scope = draftKey.startsWith("new-task:") ? draftKey.slice("new-task:".length) : draftKey;
- const separator = scope.lastIndexOf(":");
- return separator > 0 ? EnvironmentId.make(scope.slice(0, separator)) : null;
+ if (draftKey.startsWith("new-task:")) {
+ if (draft?.project) {
+ return draft.project.environmentId;
+ }
+ const legacy = parseLegacyNewTaskDraftKey(draftKey);
+ return legacy === null ? null : EnvironmentId.make(legacy.environmentId);
+ }
+ const separator = draftKey.lastIndexOf(":");
+ return separator > 0 ? EnvironmentId.make(draftKey.slice(0, separator)) : null;
}
type UploadServerConfig = {
diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts
index ad38551d5915..d454133f1a31 100644
--- a/apps/mobile/src/state/composer-attachment-uploads.ts
+++ b/apps/mobile/src/state/composer-attachment-uploads.ts
@@ -79,7 +79,7 @@ export function useComposerAttachmentUploadWorker() {
let retained = false;
for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) {
if (
- composerDraftEnvironmentId(key, queued) === environmentId &&
+ composerDraftEnvironmentId(key, queued, draft) === environmentId &&
draft.attachments.some((candidate) => candidate.id === attachment.id)
) {
retained = setComposerDraftAttachmentUpload(key, uploaded) || retained;
@@ -113,7 +113,7 @@ export function useComposerAttachmentUploadWorker() {
.map((environment) => environment.environmentId),
);
const requests = Object.entries(drafts).flatMap(([key, draft]) => {
- const environmentId = composerDraftEnvironmentId(key, queued);
+ const environmentId = composerDraftEnvironmentId(key, queued, draft);
if (environmentId === null || !connected.has(environmentId)) return [];
return draft.attachments
.filter((attachment) =>
diff --git a/apps/mobile/src/state/new-task-draft-key.ts b/apps/mobile/src/state/new-task-draft-key.ts
new file mode 100644
index 000000000000..942380dbaa3d
--- /dev/null
+++ b/apps/mobile/src/state/new-task-draft-key.ts
@@ -0,0 +1,30 @@
+const NEW_TASK_DRAFT_PREFIX = "new-task:";
+
+/** Every new-task draft key: `new-task:`. */
+export function newTaskDraftKey(draftId: string): string {
+ return `${NEW_TASK_DRAFT_PREFIX}${draftId}`;
+}
+
+export function isNewTaskDraftKey(draftKey: string): boolean {
+ return draftKey.startsWith(NEW_TASK_DRAFT_PREFIX);
+}
+
+/**
+ * Builds before drafts were id-keyed used `new-task::`,
+ * one slot per project. Ids are UUIDs and never contain a colon, so a colon
+ * after the prefix marks the legacy shape. Returns the split scope, or null
+ * when the key is not legacy.
+ */
+export function parseLegacyNewTaskDraftKey(
+ draftKey: string,
+): { readonly environmentId: string; readonly projectId: string } | null {
+ if (!isNewTaskDraftKey(draftKey)) {
+ return null;
+ }
+ const scope = draftKey.slice(NEW_TASK_DRAFT_PREFIX.length);
+ const separator = scope.lastIndexOf(":");
+ if (separator <= 0 || separator === scope.length - 1) {
+ return null;
+ }
+ return { environmentId: scope.slice(0, separator), projectId: scope.slice(separator + 1) };
+}
diff --git a/apps/mobile/src/state/pending-new-tasks-model.test.ts b/apps/mobile/src/state/pending-new-tasks-model.test.ts
index 6cedca3814a8..0bb61ed2c429 100644
--- a/apps/mobile/src/state/pending-new-tasks-model.test.ts
+++ b/apps/mobile/src/state/pending-new-tasks-model.test.ts
@@ -3,11 +3,10 @@ import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3too
import type { QueuedThreadMessage } from "./thread-outbox-model";
import type { ComposerDraft } from "./use-composer-drafts";
-import { buildPendingNewTasks, parseNewTaskDraftKey } from "./pending-new-tasks-model";
+import { buildPendingNewTasks } from "./pending-new-tasks-model";
const environmentId = EnvironmentId.make("env-1");
const projectId = ProjectId.make("project-1");
-const NOW = "2026-09-05T12:00:00.000Z";
function queuedCreation(id: string, createdAt: string): QueuedThreadMessage {
return {
@@ -27,62 +26,57 @@ function queuedCreation(id: string, createdAt: string): QueuedThreadMessage {
};
}
-function draft(text: string, overrides: Partial = {}): ComposerDraft {
- return { text, attachments: [], ...overrides };
+function draft(
+ text: string,
+ createdAt: string,
+ overrides: Partial = {},
+): ComposerDraft {
+ return {
+ text,
+ attachments: [],
+ project: { environmentId, projectId, createdAt },
+ ...overrides,
+ };
}
-describe("parseNewTaskDraftKey", () => {
- it("splits the environment and project ids", () => {
- expect(parseNewTaskDraftKey(`new-task:${environmentId}:${projectId}`)).toEqual({
- environmentId,
- projectId,
- });
- });
-
- it("ignores thread drafts and pending-task editor drafts", () => {
- expect(parseNewTaskDraftKey(`${environmentId}:thread-1`)).toBeNull();
- expect(parseNewTaskDraftKey("pending-task:message-1")).toBeNull();
- expect(parseNewTaskDraftKey("new-task:")).toBeNull();
- expect(parseNewTaskDraftKey("new-task:env-only")).toBeNull();
- });
-});
-
describe("buildPendingNewTasks", () => {
- it("surfaces new-task drafts with content alongside queued creations", () => {
+ it("surfaces every new-task draft with content alongside queued creations", () => {
const tasks = buildPendingNewTasks({
queuedMessages: [queuedCreation("a", "2026-09-05T10:00:00.000Z")],
drafts: {
- [`new-task:${environmentId}:${projectId}`]: draft("fix the offline outbox", {
+ "new-task:draft-old": draft("first idea", "2026-09-05T09:00:00.000Z", {
workspaceSelection: { mode: "worktree", branch: "main", worktreePath: null },
}),
+ "new-task:draft-new": draft("second idea", "2026-09-05T11:00:00.000Z"),
},
- now: NOW,
});
expect(tasks.map((task) => [task.kind, task.title, task.branch])).toEqual([
- ["draft", "fix the offline outbox", "main"],
+ ["draft", "second idea", null],
+ ["draft", "first idea", "main"],
["pending", "queued a", "main"],
]);
- expect(tasks[0]).toMatchObject({
- key: `draft-task:new-task:${environmentId}:${projectId}`,
+ expect(tasks[1]).toMatchObject({
+ key: "draft-task:new-task:draft-old",
environmentId,
projectId,
- draftKey: `new-task:${environmentId}:${projectId}`,
+ draftKey: "new-task:draft-old",
+ createdAt: "2026-09-05T09:00:00.000Z",
});
});
- it("hides settings-only drafts and drafts for other surfaces", () => {
+ it("hides settings-only drafts, unstamped drafts, and drafts for other surfaces", () => {
const tasks = buildPendingNewTasks({
queuedMessages: [],
drafts: {
- [`new-task:${environmentId}:${projectId}`]: draft("", {
+ "new-task:settings-only": draft("", "2026-09-05T09:00:00.000Z", {
modelSelection: { instanceId: "codex" as never, model: "gpt" },
}),
- [`new-task:${environmentId}:${projectId}-2`]: draft(" "),
- [`${environmentId}:thread-1`]: draft("thread composer text"),
- "pending-task:message-1": draft("editor copy of a queued task"),
+ "new-task:blank": draft(" ", "2026-09-05T09:00:00.000Z"),
+ "new-task:unstamped": { text: "no project", attachments: [] },
+ [`${environmentId}:thread-1`]: { text: "thread composer text", attachments: [] },
+ "pending-task:message-1": { text: "editor copy of a queued task", attachments: [] },
},
- now: NOW,
});
expect(tasks).toEqual([]);
@@ -102,9 +96,10 @@ describe("buildPendingNewTasks", () => {
const tasks = buildPendingNewTasks({
queuedMessages: [],
drafts: {
- [`new-task:${environmentId}:${projectId}`]: draft("", { attachments: [attachment] }),
+ "new-task:with-image": draft("", "2026-09-05T09:00:00.000Z", {
+ attachments: [attachment],
+ }),
},
- now: NOW,
});
expect(tasks.map((task) => task.title)).toEqual(["1 attachment"]);
@@ -118,7 +113,6 @@ describe("buildPendingNewTasks", () => {
queuedCreation("new", "2026-09-05T10:00:00.000Z"),
],
drafts: {},
- now: NOW,
});
expect(tasks.map((task) => task.title)).toEqual(["queued new", "queued old"]);
diff --git a/apps/mobile/src/state/pending-new-tasks-model.ts b/apps/mobile/src/state/pending-new-tasks-model.ts
index 3dafea540456..b66c5273b443 100644
--- a/apps/mobile/src/state/pending-new-tasks-model.ts
+++ b/apps/mobile/src/state/pending-new-tasks-model.ts
@@ -1,15 +1,16 @@
-import { EnvironmentId, ProjectId } from "@t3tools/contracts";
+import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
import type { QueuedThreadCreation, QueuedThreadMessage } from "./thread-outbox-model";
+import { isNewTaskDraftKey } from "./new-task-draft-key";
import type { ComposerDraft } from "./use-composer-drafts";
/**
* Unsent work that will become a thread, shaped for thread-list presentation.
* A `pending` task sits in the outbox and sends itself when its environment
- * reconnects; a `draft` is the project's new-task composer content, which
- * only sends when the user submits it. Both share the list slot so the user
- * can find everything they have written but not yet started in one place.
+ * reconnects; a `draft` is new-task composer content, which only sends when
+ * the user submits it. Both share the list slot so the user can find
+ * everything they have written but not yet started in one place.
*/
export type PendingNewTask = PendingQueuedTask | PendingDraftTask;
@@ -36,32 +37,11 @@ export interface PendingDraftTask {
readonly projectCwd: undefined;
readonly branch: string | null;
readonly title: string;
- /** Drafts have no creation timestamp; they sort as current work. */
readonly createdAt: string;
readonly draftKey: string;
readonly draft: ComposerDraft;
}
-const NEW_TASK_DRAFT_PREFIX = "new-task:";
-
-/** Parses a `new-task::` composer draft key. */
-export function parseNewTaskDraftKey(
- draftKey: string,
-): { readonly environmentId: EnvironmentId; readonly projectId: ProjectId } | null {
- if (!draftKey.startsWith(NEW_TASK_DRAFT_PREFIX)) {
- return null;
- }
- const scope = draftKey.slice(NEW_TASK_DRAFT_PREFIX.length);
- const separator = scope.lastIndexOf(":");
- if (separator <= 0 || separator === scope.length - 1) {
- return null;
- }
- return {
- environmentId: EnvironmentId.make(scope.slice(0, separator)),
- projectId: ProjectId.make(scope.slice(separator + 1)),
- };
-}
-
/**
* Settings-only drafts (a model pick with no text) are not work the user
* would look for in the list; only text or attachments make a draft visible.
@@ -81,8 +61,6 @@ function draftTitle(draft: ComposerDraft): string {
export function buildPendingNewTasks(input: {
readonly queuedMessages: ReadonlyArray;
readonly drafts: Readonly>;
- /** ISO timestamp drafts sort by; they carry no creation time of their own. */
- readonly now: string;
}): ReadonlyArray {
const tasks: PendingNewTask[] = [];
for (const message of input.queuedMessages) {
@@ -104,26 +82,25 @@ export function buildPendingNewTasks(input: {
});
}
for (const [draftKey, draft] of Object.entries(input.drafts)) {
- const ref = parseNewTaskDraftKey(draftKey);
- if (ref === null || !composerDraftHasUserContent(draft)) {
+ if (!isNewTaskDraftKey(draftKey) || !draft.project || !composerDraftHasUserContent(draft)) {
continue;
}
tasks.push({
kind: "draft",
key: `draft-task:${draftKey}`,
- environmentId: ref.environmentId,
- projectId: ref.projectId,
+ environmentId: draft.project.environmentId,
+ projectId: draft.project.projectId,
projectTitle: undefined,
projectCwd: undefined,
branch: draft.workspaceSelection?.branch ?? null,
title: draftTitle(draft),
- createdAt: input.now,
+ createdAt: draft.project.createdAt,
draftKey,
draft,
});
}
- // Drafts are what the user is writing now, so they lead; queued tasks
- // follow newest-first.
+ // Drafts are what the user is writing now, so they lead; within each kind,
+ // newest first.
tasks.sort((left, right) => {
if (left.kind !== right.kind) {
return left.kind === "draft" ? -1 : 1;
diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts
index 57c0ac91d147..c055b515448a 100644
--- a/apps/mobile/src/state/use-composer-drafts.test.ts
+++ b/apps/mobile/src/state/use-composer-drafts.test.ts
@@ -3,6 +3,7 @@ import {
CommandId,
EnvironmentId,
MessageId,
+ ProjectId,
ProviderInstanceId,
ThreadId,
} from "@t3tools/contracts";
@@ -157,20 +158,22 @@ import {
ComposerDraftPersistenceError,
composerDraftsAtom,
composerCloudDraftsAtom,
- copyComposerDraftContentIfEmpty,
- copyComposerDraftContentState,
+ createNewTaskDraft,
decodePersistedComposerState,
ensureComposerDraftsLoaded,
type ComposerDraft,
+ findNewTaskDraftKeys,
flushComposerDrafts,
getComposerDraftSnapshot,
mergeComposerDraftContentState,
+ migrateLegacyNewTaskDraft,
releaseUnusedComposerAttachmentFiles,
removeComposerDraftsForEnvironment,
resetComposerDraftsLoadState,
retainComposerAttachmentFileForPreview,
restoreComposerDraftSnapshotState,
restoreCloudComposerDrafts,
+ retargetNewTaskDraft,
setComposerDraftText,
setComposerDraftAttachmentUpload,
waitForComposerDraftsLoaded,
@@ -210,37 +213,6 @@ afterEach(() => {
describe("mobile composer drafts", () => {
// Hydration is one-shot per module instance and the attachment sweep now
// triggers it too, so this test must observe it before any sweep test runs.
- it("waits for persisted drafts before copying content between projects", async () => {
- const sourceKey = "new-task:environment-1:project-1";
- const targetKey = "new-task:environment-1:project-2";
- const unrelatedKey = "environment-1:thread-1";
- const source = { text: "Current task", attachments: [] } satisfies ComposerDraft;
- const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft;
- const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft;
-
- composerDraftFileMocks.setDocument({
- schemaVersion: 1,
- drafts: {
- [targetKey]: target,
- [unrelatedKey]: unrelated,
- },
- });
- composerDraftFileMocks.blockRead();
- appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source });
-
- const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey);
- expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source });
-
- composerDraftFileMocks.releaseRead();
- await copy;
-
- expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({
- [sourceKey]: source,
- [targetKey]: target,
- [unrelatedKey]: unrelated,
- });
- });
-
it("hydrates generic file attachments from their saved local paths", () => {
const file = {
id: "file-1",
@@ -1009,7 +981,7 @@ describe("mobile composer drafts", () => {
});
it("hydrates selector state even when the message content is empty", () => {
- expect(
+ const hydrated = Object.entries(
decodePersistedComposerState({
schemaVersion: 1,
drafts: {
@@ -1031,26 +1003,33 @@ describe("mobile composer drafts", () => {
},
},
}).drafts,
- ).toEqual({
- "new-task:environment-1:project-1": {
- text: "",
- attachments: [],
- modelSelection: {
- instanceId: "codex",
- model: "gpt-5.4",
- options: [{ id: "reasoningEffort", value: "xhigh" }],
- },
- runtimeMode: "approval-required",
- interactionMode: "plan",
- workspaceSelection: {
- mode: "worktree",
- branch: "main",
- worktreePath: null,
- },
+ );
+ expect(hydrated).toHaveLength(1);
+ const [key, draft] = hydrated[0]!;
+ // Legacy project keys are rewritten to id keys on load.
+ expect(key).toMatch(/^new-task:[0-9a-z-]+$/);
+ expect(draft).toEqual({
+ text: "",
+ attachments: [],
+ modelSelection: {
+ instanceId: "codex",
+ model: "gpt-5.4",
+ options: [{ id: "reasoningEffort", value: "xhigh" }],
+ },
+ runtimeMode: "approval-required",
+ interactionMode: "plan",
+ workspaceSelection: {
+ mode: "worktree",
+ branch: "main",
+ worktreePath: null,
+ },
+ project: {
+ environmentId: "environment-1",
+ projectId: "project-1",
+ createdAt: expect.any(String),
},
});
});
-
it("keeps legacy content-only drafts and rejects invalid selector state", () => {
expect(
decodePersistedComposerState({
@@ -1085,7 +1064,7 @@ describe("mobile composer drafts", () => {
// The stale-model strip must not touch receipt-bearing drafts, and the
// empty filter must keep them — or the same share would re-import after
// restart.
- expect(
+ const stripped = Object.values(
decodePersistedComposerState({
schemaVersion: 1,
drafts: {
@@ -1098,20 +1077,146 @@ describe("mobile composer drafts", () => {
},
},
}).drafts,
- ).toEqual({
- "new-task:environment-1:project-1": {
- text: "",
- attachments: [],
- importedShareIds: ["share-1"],
- },
+ );
+ expect(stripped).toHaveLength(1);
+ expect(stripped[0]).toMatchObject({
+ text: "",
+ attachments: [],
+ importedShareIds: ["share-1"],
+ project: { environmentId: "environment-1", projectId: "project-1" },
});
+ expect(stripped[0]?.modelSelection).toBeUndefined();
- expect(
+ const kept = Object.values(
decodePersistedComposerState({
schemaVersion: 1,
drafts: { "new-task:environment-1:project-1": receiptDraft },
}).drafts,
- ).toEqual({ "new-task:environment-1:project-1": receiptDraft });
+ );
+ expect(kept).toHaveLength(1);
+ expect(kept[0]).toMatchObject(receiptDraft);
+ });
+
+ it("migrates archived signed-out new-task drafts the same way as live ones", () => {
+ const decoded = decodePersistedComposerState({
+ schemaVersion: 1,
+ drafts: {},
+ cloudAccountId: "account-1",
+ signedOutDrafts: {
+ "account-1": {
+ drafts: { "new-task:environment-1:project-1": { text: "archived", attachments: [] } },
+ queuedMessages: [],
+ },
+ },
+ });
+ const archived = Object.entries(decoded.cloudDrafts.signedOut["account-1"]?.drafts ?? {});
+ expect(archived).toHaveLength(1);
+ expect(archived[0]?.[0]).toMatch(/^new-task:[0-9a-z]+-[0-9a-z]+$/);
+ expect(archived[0]?.[1]).toMatchObject({
+ text: "archived",
+ project: { environmentId: "environment-1", projectId: "project-1" },
+ });
+ });
+
+ it("migrates project-keyed new-task drafts to id keys with the project stamped in", () => {
+ const now = "2026-09-05T12:00:00.000Z";
+ const [key, draft] = migrateLegacyNewTaskDraft(
+ "new-task:environment-1:project-1",
+ { text: "keep me", attachments: [] },
+ now,
+ );
+ // The new key has no colon after the prefix, so it can never be
+ // mistaken for the legacy shape on the next load.
+ expect(key).toMatch(/^new-task:[0-9a-z-]+$/);
+ expect(draft).toEqual({
+ text: "keep me",
+ attachments: [],
+ project: {
+ environmentId: EnvironmentId.make("environment-1"),
+ projectId: ProjectId.make("project-1"),
+ createdAt: now,
+ },
+ });
+
+ // Already-migrated, thread, and pending-task keys pass through untouched.
+ const stamped: ComposerDraft = {
+ text: "x",
+ attachments: [],
+ project: {
+ environmentId: EnvironmentId.make("environment-1"),
+ projectId: ProjectId.make("project-1"),
+ createdAt: now,
+ },
+ };
+ expect(migrateLegacyNewTaskDraft("new-task:some-id", stamped, now)).toEqual([
+ "new-task:some-id",
+ stamped,
+ ]);
+ expect(migrateLegacyNewTaskDraft("environment-1:thread-1", DRAFT, now)).toEqual([
+ "environment-1:thread-1",
+ DRAFT,
+ ]);
+ expect(migrateLegacyNewTaskDraft("pending-task:message-1", DRAFT, now)).toEqual([
+ "pending-task:message-1",
+ DRAFT,
+ ]);
+ });
+
+ it("keeps a freshly minted new-task draft bound until content arrives, then lists it per project", () => {
+ const project = {
+ environmentId: EnvironmentId.make("environment-1"),
+ projectId: ProjectId.make("project-1"),
+ };
+ const first = createNewTaskDraft(project);
+ const second = createNewTaskDraft(project);
+ expect(first).not.toBe(second);
+ // Empty stamped drafts stay in memory so the composer has a key to write
+ // to, but the persisted document leaves them out.
+ expect(appAtomRegistry.get(composerDraftsAtom)[first]?.project).toMatchObject(project);
+
+ setComposerDraftText(first, "first idea");
+ setComposerDraftText(second, "second idea");
+ expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), project)).toEqual(
+ expect.arrayContaining([first, second]),
+ );
+
+ // Clearing content on the way out drops the stamp with it.
+ clearComposerDraftContent(first, { clearModelSelection: true, clearWorkspaceSelection: true });
+ expect(appAtomRegistry.get(composerDraftsAtom)[first]).toBeUndefined();
+ expect(getComposerDraftSnapshot(second).text).toBe("second idea");
+ });
+
+ it("retargets a new-task draft to another project without losing its text", () => {
+ const from = {
+ environmentId: EnvironmentId.make("environment-1"),
+ projectId: ProjectId.make("project-1"),
+ };
+ const to = {
+ environmentId: EnvironmentId.make("environment-2"),
+ projectId: ProjectId.make("project-2"),
+ };
+ const key = createNewTaskDraft(from);
+ setComposerDraftText(key, "moving house");
+ appAtomRegistry.set(composerDraftsAtom, {
+ ...appAtomRegistry.get(composerDraftsAtom),
+ [key]: {
+ ...getComposerDraftSnapshot(key),
+ runtimeMode: "approval-required",
+ workspaceSelection: { mode: "worktree", branch: "feature/a", worktreePath: null },
+ },
+ });
+ const createdAt = getComposerDraftSnapshot(key).project?.createdAt;
+
+ retargetNewTaskDraft(key, to);
+
+ const moved = getComposerDraftSnapshot(key);
+ expect(moved.text).toBe("moving house");
+ expect(moved.runtimeMode).toBe("approval-required");
+ // Branch and worktree belong to the old repo.
+ expect(moved.workspaceSelection).toBeUndefined();
+ expect(moved.project).toEqual({ ...to, createdAt });
+ expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), from)).toEqual([]);
+ expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), to)).toEqual([key]);
});
it("hydrates the global sticky model selection", () => {
@@ -1387,56 +1492,7 @@ describe("mobile composer drafts", () => {
expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft);
});
- it("carries unfinished content to a newly selected project without overwriting its settings", () => {
- const sourceKey = "new-task:environment-1:project-1";
- const targetKey = "new-task:environment-1:project-2";
- const source: ComposerDraft = {
- text: "Keep this task",
- attachments: [],
- importedShareIds: ["share-1"],
- workspaceSelection: {
- mode: "worktree",
- branch: "feature/source",
- worktreePath: null,
- },
- };
- const target: ComposerDraft = {
- text: "",
- attachments: [],
- runtimeMode: "approval-required",
- };
-
- expect(
- copyComposerDraftContentState(
- { [sourceKey]: source, [targetKey]: target },
- sourceKey,
- targetKey,
- ),
- ).toEqual({
- [sourceKey]: source,
- [targetKey]: {
- ...target,
- text: source.text,
- attachments: source.attachments,
- importedShareIds: source.importedShareIds,
- },
- });
- });
-
- it("does not overwrite unfinished content already stored for the selected project", () => {
- const sourceKey = "new-task:environment-1:project-1";
- const targetKey = "new-task:environment-1:project-2";
- const drafts: Record = {
- [sourceKey]: { text: "Source task", attachments: [] },
- [targetKey]: { text: "Target task", attachments: [] },
- };
-
- expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts);
- });
-
- it("drops another environment's upload stamp when carrying attachments across machines", () => {
- const sourceKey = "new-task:environment-1:project-1";
- const targetKey = "new-task:environment-2:project-2";
+ it("drops another environment's upload stamp when a draft moves across machines", () => {
const uploadedElsewhere: DraftComposerAttachment = {
id: "image-1",
type: "image",
@@ -1454,14 +1510,25 @@ describe("mobile composer drafts", () => {
uploadedAttachmentId: "upload-2",
uploadEnvironmentId: EnvironmentId.make("environment-2"),
};
+ const key = createNewTaskDraft({
+ environmentId: EnvironmentId.make("environment-1"),
+ projectId: ProjectId.make("project-1"),
+ });
+ appAtomRegistry.set(composerDraftsAtom, {
+ ...appAtomRegistry.get(composerDraftsAtom),
+ [key]: {
+ ...getComposerDraftSnapshot(key),
+ text: "Ship it",
+ attachments: [uploadedElsewhere, uploadedOnTarget],
+ },
+ });
- const next = copyComposerDraftContentState(
- { [sourceKey]: { text: "Ship it", attachments: [uploadedElsewhere, uploadedOnTarget] } },
- sourceKey,
- targetKey,
- );
+ retargetNewTaskDraft(key, {
+ environmentId: EnvironmentId.make("environment-2"),
+ projectId: ProjectId.make("project-2"),
+ });
- expect(next[targetKey]?.attachments).toEqual([
+ expect(getComposerDraftSnapshot(key).attachments).toEqual([
{
id: "image-1",
type: "image",
@@ -1473,7 +1540,6 @@ describe("mobile composer drafts", () => {
},
uploadedOnTarget,
]);
- expect(next[sourceKey]?.attachments).toEqual([uploadedElsewhere, uploadedOnTarget]);
});
it("merges shared content into a project draft without duplicating retries", () => {
@@ -1564,19 +1630,36 @@ describe("mobile composer drafts", () => {
const environmentId = EnvironmentId.make("environment-cloud");
const retainedEnvironmentId = EnvironmentId.make("environment-local");
+ const cloudDraft: ComposerDraft = {
+ ...DRAFT,
+ project: {
+ environmentId,
+ projectId: ProjectId.make("project-cloud"),
+ createdAt: "2026-09-05T00:00:00.000Z",
+ },
+ };
+ const localDraft: ComposerDraft = {
+ ...DRAFT,
+ project: {
+ environmentId: retainedEnvironmentId,
+ projectId: ProjectId.make("project-local"),
+ createdAt: "2026-09-05T00:00:00.000Z",
+ },
+ };
+
expect(
removeComposerDraftsForEnvironment(
{
[`${environmentId}:thread-cloud`]: DRAFT,
- [`new-task:${environmentId}:project-cloud`]: DRAFT,
+ "new-task:cloud-draft": cloudDraft,
[`${retainedEnvironmentId}:thread-local`]: DRAFT,
- [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,
+ "new-task:local-draft": localDraft,
},
environmentId,
),
).toEqual({
[`${retainedEnvironmentId}:thread-local`]: DRAFT,
- [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,
+ "new-task:local-draft": localDraft,
});
});
diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts
index bf25866b14ce..0c129ad07508 100644
--- a/apps/mobile/src/state/use-composer-drafts.ts
+++ b/apps/mobile/src/state/use-composer-drafts.ts
@@ -1,11 +1,14 @@
import { useAtomValue } from "@effect/atom-react";
import {
+ EnvironmentId as EnvironmentIdSchema,
ModelSelection as ModelSelectionSchema,
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
+ ProjectId as ProjectIdSchema,
ProviderInteractionMode as ProviderInteractionModeSchema,
RuntimeMode as RuntimeModeSchema,
type EnvironmentId,
type ModelSelection,
+ type ProjectId,
type ProviderInteractionMode,
type RuntimeMode,
} from "@t3tools/contracts";
@@ -23,6 +26,11 @@ import {
import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages";
import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
import { appAtomRegistry } from "./atom-registry";
+import {
+ isNewTaskDraftKey,
+ newTaskDraftKey,
+ parseLegacyNewTaskDraftKey,
+} from "./new-task-draft-key";
import {
decodeQueuedThreadMessage,
encodeQueuedThreadMessage,
@@ -59,6 +67,18 @@ export interface ComposerDraft {
readonly runtimeMode?: RuntimeMode;
readonly interactionMode?: ProviderInteractionMode;
readonly workspaceSelection?: ComposerDraftWorkspaceSelection;
+ /**
+ * Set on new-task drafts only. The project is stored here rather than in
+ * the key so a project can hold any number of drafts and a draft can be
+ * retargeted to another project without changing identity.
+ */
+ readonly project?: ComposerDraftProject;
+}
+
+export interface ComposerDraftProject {
+ readonly environmentId: EnvironmentId;
+ readonly projectId: ProjectId;
+ readonly createdAt: string;
}
export interface ComposerDraftContent {
@@ -76,7 +96,7 @@ export interface ComposerDraftWorkspaceSelection {
export type ComposerDraftSettingsUpdate = Pick<
ComposerDraft,
- "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection"
+ "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" | "project"
>;
const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
@@ -86,6 +106,12 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
startFromOrigin: Schema.optional(Schema.Boolean),
});
+const ComposerDraftProjectSchema = Schema.Struct({
+ environmentId: EnvironmentIdSchema,
+ projectId: ProjectIdSchema,
+ createdAt: Schema.String,
+});
+
const ComposerDraftSchema = Schema.Struct({
text: Schema.String,
attachments: Schema.Array(DraftComposerAttachmentSchema),
@@ -94,6 +120,7 @@ const ComposerDraftSchema = Schema.Struct({
runtimeMode: Schema.optional(RuntimeModeSchema),
interactionMode: Schema.optional(ProviderInteractionModeSchema),
workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema),
+ project: Schema.optional(ComposerDraftProjectSchema),
});
const PersistedComposerDraftsSchema = Schema.Struct({
@@ -176,6 +203,8 @@ export function isComposerDraftEmpty(draft: ComposerDraft): boolean {
return isEmptyDraft(draft);
}
+// The project stamp is identity, not content: a new-task draft with nothing
+// else in it is still empty and gets dropped like any other.
function isEmptyDraft(draft: ComposerDraft): boolean {
return (
draft.text.length === 0 &&
@@ -187,35 +216,90 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
);
}
+/**
+ * Writes a draft back, dropping it once empty. A new-task draft keeps its
+ * entry while the composer is bound to it (the project stamp is what the
+ * composer binds to); the persist sweep still leaves empty ones off disk.
+ */
+function withComposerDraft(
+ current: Record,
+ draftKey: string,
+ draft: ComposerDraft,
+): Record {
+ if (isEmptyDraft(draft) && draft.project === undefined) {
+ const next = { ...current };
+ delete next[draftKey];
+ return next;
+ }
+ return { ...current, [draftKey]: draft };
+}
+
+export { isNewTaskDraftKey, newTaskDraftKey } from "./new-task-draft-key";
+
+// Draft ids only need to be unique within this device's draft file. Deriving
+// them from time plus randomness keeps this module free of native imports,
+// which the persistence tests rely on.
+function newDraftId(): string {
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
+}
+
+/**
+ * Project-keyed new-task drafts from earlier builds are rewritten on load into
+ * id-keyed drafts with the project stamped in, so existing drafts survive the
+ * switch to many-per-project.
+ */
+export function migrateLegacyNewTaskDraft(
+ key: string,
+ draft: ComposerDraft,
+ now: string,
+): readonly [key: string, draft: ComposerDraft] {
+ const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null;
+ if (legacy === null) {
+ return [key, draft];
+ }
+ return [
+ newTaskDraftKey(newDraftId()),
+ {
+ ...draft,
+ project: {
+ environmentId: EnvironmentIdSchema.make(legacy.environmentId),
+ projectId: ProjectIdSchema.make(legacy.projectId),
+ createdAt: now,
+ },
+ },
+ ];
+}
+
export function decodePersistedComposerState(value: unknown): {
readonly drafts: Record;
readonly stickyModelSelection: ModelSelection | null;
readonly cloudDrafts: ComposerCloudDraftState;
} {
const parsed = decodePersistedComposerDraftsDocument(value);
+ const now = new Date().toISOString();
return {
drafts: Object.fromEntries(
Object.entries(parsed.drafts)
- .map(
- ([key, draft]) =>
- [
- key,
- // Stale new-task drafts left on disk by builds before the
- // model-precedence fix carry a bare modelSelection with no
- // other selector settings. Strip it so the next compose pass
- // re-resolves project → sticky → provider defaults. Drafts
- // with runtime/interaction/workspace settings or actual text /
- // attachments were deliberately configured and are left alone.
- key.startsWith("new-task:") &&
+ .map(([key, draft]) =>
+ migrateLegacyNewTaskDraft(
+ key,
+ // Stale new-task drafts left on disk by builds before the
+ // model-precedence fix carry a bare modelSelection with no
+ // other selector settings. Strip it so the next compose pass
+ // re-resolves project → sticky → provider defaults. Drafts
+ // with runtime/interaction/workspace settings or actual text /
+ // attachments were deliberately configured and are left alone.
+ isNewTaskDraftKey(key) &&
draft.modelSelection &&
draft.text.length === 0 &&
draft.attachments.length === 0 &&
draft.runtimeMode === undefined &&
draft.interactionMode === undefined &&
draft.workspaceSelection === undefined
- ? { ...draft, modelSelection: undefined }
- : draft,
- ] as const,
+ ? { ...draft, modelSelection: undefined }
+ : draft,
+ now,
+ ),
)
// importedShareIds are share-import receipts: a contentless draft
// carrying one is not empty, or the same native share would be
@@ -229,7 +313,13 @@ export function decodePersistedComposerState(value: unknown): {
Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [
id,
{
- drafts: saved.drafts,
+ // Archived drafts come back through restoreCloudComposerDrafts
+ // without another decode, so they get the same key migration.
+ drafts: Object.fromEntries(
+ Object.entries(saved.drafts).map(([key, draft]) =>
+ migrateLegacyNewTaskDraft(key, draft, now),
+ ),
+ ),
queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage),
},
]),
@@ -622,7 +712,7 @@ export async function archiveCloudComposerDrafts(
const remaining = { ...current };
const savedDrafts = { ...cloud.signedOut[owner]?.drafts };
for (const [key, draft] of Object.entries(current)) {
- const environmentId = composerDraftEnvironmentId(key, queued);
+ const environmentId = composerDraftEnvironmentId(key, queued, draft);
if (environmentId !== null && environmentIds.has(environmentId)) {
savedDrafts[key] = draft;
delete remaining[key];
@@ -808,15 +898,7 @@ export function setComposerDraftText(draftKey: string, value: string): void {
...normalizeDraft(current[draftKey]),
text: value,
};
- if (isEmptyDraft(draft)) {
- const next = { ...current };
- delete next[draftKey];
- return next;
- }
- return {
- ...current,
- [draftKey]: draft,
- };
+ return withComposerDraft(current, draftKey, draft);
});
}
@@ -881,15 +963,7 @@ export function replaceComposerDraftAttachments(
...normalizeDraft(current[draftKey]),
attachments,
};
- if (isEmptyDraft(draft)) {
- const next = { ...current };
- delete next[draftKey];
- return next;
- }
- return {
- ...current,
- [draftKey]: draft,
- };
+ return withComposerDraft(current, draftKey, draft);
});
const retainedIds = new Set(attachments.map((attachment) => attachment.id));
scheduleUnusedComposerAttachmentCleanup(
@@ -905,15 +979,7 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string)
...existing,
attachments: existing.attachments.filter((image) => image.id !== imageId),
};
- if (isEmptyDraft(draft)) {
- const next = { ...current };
- delete next[draftKey];
- return next;
- }
- return {
- ...current,
- [draftKey]: draft,
- };
+ return withComposerDraft(current, draftKey, draft);
});
scheduleUnusedComposerAttachmentCleanup(
previousAttachments.filter((attachment) => attachment.id === imageId),
@@ -964,15 +1030,7 @@ export function updateComposerDraftSettings(
...normalizeDraft(current[draftKey]),
...settings,
};
- if (isEmptyDraft(draft)) {
- const next = { ...current };
- delete next[draftKey];
- return next;
- }
- return {
- ...current,
- [draftKey]: draft,
- };
+ return withComposerDraft(current, draftKey, draft);
});
}
@@ -988,10 +1046,14 @@ export function clearComposerDraftContentState(
if (!existing) {
return current;
}
+ // Clearing content is the "this draft is done" moment (sent, queued, or
+ // discarded), so the project stamp goes too and an otherwise-empty new-task
+ // draft leaves the store rather than lingering as a blank row.
const {
importedShareIds: _importedShareIds,
modelSelection,
workspaceSelection,
+ project: _project,
...retained
} = existing;
const draft = {
@@ -1028,49 +1090,6 @@ export function restoreComposerDraftSnapshotState(
return next;
}
-export function copyComposerDraftContentState(
- current: Record,
- sourceDraftKey: string,
- targetDraftKey: string,
-): Record {
- if (sourceDraftKey === targetDraftKey) {
- return current;
- }
- const source = normalizeDraft(current[sourceDraftKey]);
- const target = normalizeDraft(current[targetDraftKey]);
- const sourceHasContent =
- source.text.length > 0 ||
- source.attachments.length > 0 ||
- (source.importedShareIds?.length ?? 0) > 0;
- const targetHasContent =
- target.text.length > 0 ||
- target.attachments.length > 0 ||
- (target.importedShareIds?.length ?? 0) > 0;
- if (!sourceHasContent || targetHasContent) {
- return current;
- }
- // Pending uploads live on one server. Crossing environments keeps the local
- // bytes (the upload worker re-sends them to the new key's environment) but
- // drops the old stamp, so it cannot pin the source environment's pending
- // upload alive from the copy.
- const targetEnvironmentId = composerDraftEnvironmentId(targetDraftKey, []);
- const attachments = source.attachments.map((attachment) =>
- attachment.uploadEnvironmentId !== undefined &&
- attachment.uploadEnvironmentId !== targetEnvironmentId
- ? stripAttachmentUploadReference(attachment)
- : attachment,
- );
- return {
- ...current,
- [targetDraftKey]: {
- ...target,
- text: source.text,
- attachments,
- ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}),
- },
- };
-}
-
function stripAttachmentUploadReference(
attachment: DraftComposerAttachment,
): DraftComposerAttachment {
@@ -1078,19 +1097,6 @@ function stripAttachmentUploadReference(
return rest;
}
-export async function copyComposerDraftContentIfEmpty(
- sourceDraftKey: string,
- targetDraftKey: string,
-): Promise {
- ensureComposerDraftsLoaded();
- if (loadPromise !== null) {
- await loadPromise;
- }
- updateComposerDrafts((current) =>
- copyComposerDraftContentState(current, sourceDraftKey, targetDraftKey),
- );
-}
-
function mergeComposerDraftText(existing: string, incoming: string): string {
if (incoming.length === 0) {
return existing;
@@ -1275,15 +1281,7 @@ export function undoComposerDraftMergeState(
interactionMode: undoSetting("interactionMode"),
workspaceSelection: undoSetting("workspaceSelection"),
};
- if (isEmptyDraft(draft)) {
- const next = { ...current };
- delete next[draftKey];
- return next;
- }
- return {
- ...current,
- [draftKey]: draft,
- };
+ return withComposerDraft(current, draftKey, draft);
}
/** Applies undoComposerDraftMergeState and lands it durably. */
@@ -1355,15 +1353,100 @@ export function removeComposerDraftsForEnvironment(
environmentId: EnvironmentId,
): Record {
const environmentPrefix = `${environmentId}:`;
- const newTaskPrefix = `new-task:${environmentId}:`;
return Object.fromEntries(
Object.entries(drafts).filter(
- ([draftKey]) =>
- !draftKey.startsWith(environmentPrefix) && !draftKey.startsWith(newTaskPrefix),
+ ([draftKey, draft]) =>
+ !draftKey.startsWith(environmentPrefix) && draft.project?.environmentId !== environmentId,
),
);
}
+/**
+ * Mints a new-task draft for a project. The entry is published immediately so
+ * the composer can bind to its key before the user types; it stays out of the
+ * list until it has content, and the empty-draft sweep drops it on persist if
+ * nothing is ever written.
+ */
+export function createNewTaskDraft(project: {
+ readonly environmentId: EnvironmentId;
+ readonly projectId: ProjectId;
+}): string {
+ const draftKey = newTaskDraftKey(newDraftId());
+ const stamp: ComposerDraftProject = {
+ environmentId: project.environmentId,
+ projectId: project.projectId,
+ createdAt: new Date().toISOString(),
+ };
+ updateComposerDrafts((current) => ({
+ ...current,
+ [draftKey]: { ...EMPTY_DRAFT, project: stamp },
+ }));
+ return draftKey;
+}
+
+/**
+ * Points an existing new-task draft at a different project, keeping its
+ * content and identity. Workspace selection is project-specific (branch,
+ * worktree), so it is cleared; model and mode choices carry over.
+ */
+export function retargetNewTaskDraft(
+ draftKey: string,
+ project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId },
+): void {
+ updateComposerDrafts((current) => {
+ const existing = current[draftKey];
+ const stamp = existing?.project;
+ if (
+ stamp !== undefined &&
+ stamp.environmentId === project.environmentId &&
+ stamp.projectId === project.projectId
+ ) {
+ return current;
+ }
+ const { workspaceSelection: _workspaceSelection, ...retained } = normalizeDraft(existing);
+ // Pending uploads live on one server. Crossing environments keeps the
+ // local bytes (the upload worker re-sends them to the new environment)
+ // but drops the old stamp, so it cannot pin the source environment's
+ // pending upload alive from the moved draft.
+ const attachments = retained.attachments.map((attachment) =>
+ attachment.uploadEnvironmentId !== undefined &&
+ attachment.uploadEnvironmentId !== project.environmentId
+ ? stripAttachmentUploadReference(attachment)
+ : attachment,
+ );
+ return {
+ ...current,
+ [draftKey]: {
+ ...retained,
+ attachments,
+ project: {
+ environmentId: project.environmentId,
+ projectId: project.projectId,
+ createdAt: stamp?.createdAt ?? new Date().toISOString(),
+ },
+ },
+ };
+ });
+}
+
+/** New-task drafts for a project, newest first. */
+export function findNewTaskDraftKeys(
+ drafts: Readonly>,
+ project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId },
+): ReadonlyArray {
+ return Object.entries(drafts)
+ .filter(
+ ([key, draft]) =>
+ isNewTaskDraftKey(key) &&
+ draft.project?.environmentId === project.environmentId &&
+ draft.project.projectId === project.projectId,
+ )
+ .sort(([, left], [, right]) =>
+ (right.project?.createdAt ?? "").localeCompare(left.project?.createdAt ?? ""),
+ )
+ .map(([key]) => key);
+}
+
export async function clearComposerDraftsEnvironment(environmentId: EnvironmentId): Promise {
ensureComposerDraftsLoaded();
if (loadPromise !== null) {
diff --git a/apps/mobile/src/state/use-pending-new-tasks.ts b/apps/mobile/src/state/use-pending-new-tasks.ts
index d4d4d5c7c9ea..bf5f0191bf35 100644
--- a/apps/mobile/src/state/use-pending-new-tasks.ts
+++ b/apps/mobile/src/state/use-pending-new-tasks.ts
@@ -20,9 +20,6 @@ export function usePendingNewTasks(): ReadonlyArray {
buildPendingNewTasks({
queuedMessages: flattenQueuedThreadMessages(queuedMessagesByThreadKey),
drafts,
- // Stamped when the inputs change, not per render, so a draft keeps one
- // sort position while the user is not typing in it.
- now: new Date().toISOString(),
}),
[queuedMessagesByThreadKey, drafts],
);
diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts
index 9d8f6f02793f..bc295054038b 100644
--- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts
+++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts
@@ -582,7 +582,7 @@ describe("thread outbox delivered creation recovery", () => {
});
describe("thread outbox recovery rollback", () => {
- it("restores a rejected new task into its durable project draft", async () => {
+ it("restores a rejected new task as its own draft for the project", async () => {
const message: QueuedThreadMessage = {
...queuedMessage({ messageId: "message-creation-restore", text: "new task text" }),
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
@@ -599,14 +599,19 @@ describe("thread outbox recovery rollback", () => {
"restored",
);
+ // The draft is keyed by the message so a retry lands on the same one, and
+ // stamped with the project so it shows up as a Draft row for that project.
expect(
- composerDrafts.getComposerDraftSnapshot(
- `new-task:${message.environmentId}:${message.creation!.projectId}`,
- ),
+ composerDrafts.getComposerDraftSnapshot(`new-task:restored-${message.messageId}`),
).toMatchObject({
text: message.text,
attachments: message.attachments,
modelSelection: message.modelSelection,
+ project: {
+ environmentId: message.environmentId,
+ projectId: message.creation!.projectId,
+ createdAt: message.createdAt,
+ },
});
expect(remainingMessages()).toEqual([]);
expect(harness.setPendingConnectionError).toHaveBeenCalledWith("rejected by server");
diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts
index 487aa4da4c2f..9f1b6422ba53 100644
--- a/apps/mobile/src/state/use-thread-outbox-drain.ts
+++ b/apps/mobile/src/state/use-thread-outbox-drain.ts
@@ -17,7 +17,7 @@ import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert } from "react-native";
-import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities";
+import { scopedThreadKey } from "../lib/scopedEntities";
import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";
import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload";
import { randomHex } from "../lib/uuid";
@@ -53,6 +53,7 @@ import {
type ComposerDraft,
getComposerDraftSnapshot,
mergeComposerDraftContent,
+ newTaskDraftKey,
replaceComposerDraftAttachments,
removeDeliveredCloudQueuedMessage,
undoComposerDraftMerge,
@@ -369,6 +370,7 @@ export async function restoreRejectedQueuedMessage(
let mergedDraft: ComposerDraft;
try {
+ stampRecoveryDraftProject(queuedMessage, draftKey);
await mergeComposerDraftContent(draftKey, {
text: queuedMessage.text,
attachments: queuedMessage.attachments,
@@ -451,12 +453,31 @@ export async function restoreRejectedQueuedMessage(
}
}
+/**
+ * A rejected creation becomes its own new-task draft rather than merging into
+ * whatever the user is typing for that project. The key derives from the
+ * message id so a retry after a mid-recovery failure lands on the same draft
+ * instead of minting another.
+ */
function recoveryDraftKey(queuedMessage: QueuedThreadMessage): string {
return queuedMessage.creation
- ? `new-task:${scopedProjectKey(queuedMessage.environmentId, queuedMessage.creation.projectId)}`
+ ? newTaskDraftKey(`restored-${queuedMessage.messageId}`)
: scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId);
}
+function stampRecoveryDraftProject(queuedMessage: QueuedThreadMessage, draftKey: string): void {
+ if (!queuedMessage.creation) {
+ return;
+ }
+ updateComposerDraftSettings(draftKey, {
+ project: {
+ environmentId: queuedMessage.environmentId,
+ projectId: queuedMessage.creation.projectId,
+ createdAt: queuedMessage.createdAt,
+ },
+ });
+}
+
async function preserveUploadedAttachmentsForEditor(
originalMessage: QueuedThreadMessage,
uploadedMessage: QueuedThreadMessage,
From 98469159dd9e162a9c2f5cc4bbb2fbe89b3c4f67 Mon Sep 17 00:00:00 2001
From: Wout Stiens <71498452+StiensWout@users.noreply.github.com>
Date: Sun, 6 Sep 2026 21:31:29 +0200
Subject: [PATCH 10/71] fix(mobile): slide settled threads out before
collapsing (#10345)
---
apps/mobile/src/features/home/HomeScreen.tsx | 7 +-
.../features/home/thread-swipe-actions.tsx | 303 +++++++++++++-----
.../features/threads/thread-list-v2-items.tsx | 5 +-
.../react-native-gesture-handler@2.32.0.patch | 121 ++++++-
pnpm-lock.yaml | 6 +-
5 files changed, 339 insertions(+), 103 deletions(-)
diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index 43640f9c070b..d68630b67302 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -490,12 +490,7 @@ export function HomeScreen(props: HomeScreenProps) {
// Settled threads stay in the live shell stream (settled ≠ archived), so
// the partition works directly off live shells — no snapshot merging or
// optimistic holds.
- const handleSettleThread = useCallback(
- (thread: EnvironmentThreadShell) => {
- void props.onSettleThread(thread);
- },
- [props.onSettleThread],
- );
+ const handleSettleThread = props.onSettleThread;
const handleSnoozeThread = useCallback(
(thread: EnvironmentThreadShell, snoozedUntil: string) => {
void props.onSnoozeThread(thread, snoozedUntil);
diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx
index c5f6768c55d0..93085fec6c22 100644
--- a/apps/mobile/src/features/home/thread-swipe-actions.tsx
+++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx
@@ -7,6 +7,7 @@ import {
use,
useCallback,
useEffect,
+ useLayoutEffect,
useRef,
useState,
type ComponentProps,
@@ -19,17 +20,23 @@ import type {
StyleProp,
ViewStyle,
} from "react-native";
-import { Pressable, View } from "react-native";
+import { Alert, Pressable, View } from "react-native";
import ReanimatedSwipeable, {
type SwipeableMethods,
} from "react-native-gesture-handler/ReanimatedSwipeable";
import Animated, {
+ cancelAnimation,
+ Easing,
Extrapolation,
+ ReduceMotion,
interpolate,
runOnJS,
+ runOnUI,
type SharedValue,
useAnimatedReaction,
useAnimatedStyle,
+ useSharedValue,
+ withTiming,
} from "react-native-reanimated";
import { AppText as Text } from "../../components/AppText";
@@ -61,6 +68,13 @@ interface ThreadSwipeAction {
readonly onPress: () => void;
}
+/** Dismiss before committing; false restores the row, success changes its resetKey or removes it. */
+type ThreadSwipePrimaryAction = Omit &
+ (
+ | { readonly dismissOnPress: true; readonly onPress: () => Promise }
+ | { readonly dismissOnPress?: false; readonly onPress: () => void }
+ );
+
interface ThreadSwipeSecondaryAction extends ThreadSwipeAction {
readonly tone: "primary" | "secondary" | "danger";
}
@@ -218,7 +232,7 @@ export function useSwipeableScrollGate(options?: {
};
}
-export function ThreadSwipeable(props: {
+interface ThreadSwipeableProps {
readonly backgroundColor: ColorValue;
readonly children: (close: () => void) => ReactNode;
/** Uses action visuals that fit inside compact 44pt rows. The press target
@@ -238,7 +252,7 @@ export function ThreadSwipeable(props: {
readonly onDelete: () => void;
readonly onSwipeableClose?: (methods: SwipeableMethods) => void;
readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void;
- readonly primaryAction: ThreadSwipeAction;
+ readonly primaryAction: ThreadSwipePrimaryAction;
/**
* Omitted keeps the v1 destructive Delete action. Explicit null opts out of
* a secondary action entirely so a gated Snooze can never fall back to an
@@ -255,7 +269,15 @@ export function ThreadSwipeable(props: {
typeof ReanimatedSwipeable
>["simultaneousWithExternalGesture"];
readonly threadTitle: string;
-}) {
+}
+
+export function ThreadSwipeable(props: ThreadSwipeableProps) {
+ // Recycled content gets fresh native and animation state. Late callbacks
+ // from the previous row retain its action, never the replacement's action.
+ return ;
+}
+
+function ThreadSwipeableRow(props: ThreadSwipeableProps) {
const swipeableRef = useRef(null);
const fullSwipeArmedRef = useRef(false);
const hasSecondaryAction = props.secondaryAction !== null;
@@ -265,14 +287,119 @@ export function ThreadSwipeable(props: {
props.fullSwipeAction ?? (props.secondaryAction === undefined ? "delete" : "primary");
const close = useCallback(() => swipeableRef.current?.close(), []);
const gateEnabled = use(SwipeableScrollGateContext);
- const resetKey = props.resetKey;
- useEffect(() => {
- if (resetKey === undefined) {
- return;
+ const mountedRef = useRef(true);
+ const pendingDismissRef = useRef<(() => Promise) | null>(null);
+ const activeTranslationRef = useRef | null>(null);
+ const [isDismissing, setIsDismissing] = useState(false);
+ const dismissing = useSharedValue(false);
+ const rowHeight = useSharedValue(0);
+ const rowWidth = useSharedValue(props.fullSwipeWidth);
+ const collapse = useSharedValue(0);
+ const actionOpacity = useSharedValue(1);
+ const primaryAction = props.primaryAction;
+ const onSwipeableClose = props.onSwipeableClose;
+
+ const restoreRow = useCallback(() => {
+ swipeableRef.current?.close();
+ collapse.set(0);
+ actionOpacity.set(1);
+ dismissing.set(false);
+ setIsDismissing(false);
+ }, [actionOpacity, collapse, dismissing]);
+
+ const finishDismiss = useCallback(async () => {
+ const action = pendingDismissRef.current;
+ if (!action) return;
+ pendingDismissRef.current = null;
+ try {
+ const succeeded = await action();
+ if (!succeeded && mountedRef.current) restoreRow();
+ } catch (error) {
+ if (mountedRef.current) restoreRow();
+ Alert.alert(
+ "Could not settle thread",
+ error instanceof Error ? error.message : "The thread could not be settled.",
+ );
}
- fullSwipeArmedRef.current = false;
- swipeableRef.current?.reset();
- }, [resetKey]);
+ }, [restoreRow]);
+
+ useLayoutEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ cancelAnimation(collapse);
+ cancelAnimation(actionOpacity);
+ if (activeTranslationRef.current) cancelAnimation(activeTranslationRef.current);
+ // Scrolling a committed row out of the recycled list must still settle it.
+ void finishDismiss();
+ };
+ }, [actionOpacity, collapse, finishDismiss]);
+
+ const beginDismiss = useCallback(
+ (translation: SharedValue) => {
+ if (!primaryAction.dismissOnPress) return;
+ pendingDismissRef.current = primaryAction.onPress;
+ activeTranslationRef.current = translation;
+ fullSwipeArmedRef.current = false;
+ if (!mountedRef.current) {
+ void finishDismiss();
+ return;
+ }
+ setIsDismissing(true);
+ if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current);
+ },
+ [finishDismiss, primaryAction, onSwipeableClose],
+ );
+
+ const dismiss = useCallback(
+ (translation: SharedValue) => {
+ "worklet";
+ if (dismissing.value) return;
+ dismissing.set(true);
+ runOnJS(beginDismiss)(translation);
+ const timing = {
+ duration: 220,
+ easing: Easing.out(Easing.cubic),
+ reduceMotion: ReduceMotion.System,
+ };
+ actionOpacity.set(withTiming(0, timing));
+ // Never reverse a swipe that already carried the row beyond its width.
+ translation.set(
+ withTiming(Math.min(translation.value, -rowWidth.value), timing, (finished) => {
+ if (!finished) return;
+ collapse.set(
+ withTiming(1, { ...timing, duration: 180 }, (collapsed) => {
+ if (collapsed) runOnJS(finishDismiss)();
+ }),
+ );
+ }),
+ );
+ },
+ [actionOpacity, beginDismiss, collapse, dismissing, finishDismiss, rowWidth],
+ );
+ const dismissStyle = useAnimatedStyle(() => ({
+ height: dismissing.value ? rowHeight.value * (1 - collapse.value) : undefined,
+ pointerEvents: dismissing.value ? "none" : "auto",
+ overflow: "hidden",
+ }));
+ const actionStyle = useAnimatedStyle(() => ({ opacity: actionOpacity.value, height: "100%" }));
+ const dismissOnPress = primaryAction.dismissOnPress === true;
+ const handleRelease = useCallback(
+ (translation: SharedValue) => {
+ "worklet";
+ if (dismissing.value) return true;
+ if (
+ dismissOnPress &&
+ fullSwipeAction === "primary" &&
+ -translation.value >= fullSwipeThreshold
+ ) {
+ dismiss(translation);
+ return true;
+ }
+ return false;
+ },
+ [dismiss, dismissing, dismissOnPress, fullSwipeAction, fullSwipeThreshold],
+ );
const handleFullSwipeArmedChange = useCallback((armed: boolean) => {
if (armed && !fullSwipeArmedRef.current) {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
@@ -281,78 +408,94 @@ export function ThreadSwipeable(props: {
}, []);
return (
- {
- fullSwipeArmedRef.current = false;
- if (swipeableRef.current) {
- props.onSwipeableClose?.(swipeableRef.current);
- }
- }}
- onSwipeableOpenStartDrag={() => {
- if (swipeableRef.current) {
- props.onSwipeableWillOpen?.(swipeableRef.current);
- }
- }}
- onSwipeableWillOpen={() => {
- const methods = swipeableRef.current;
- if (!methods) {
- return;
- }
+
+ {
+ rowHeight.set(layout.height);
+ rowWidth.set(layout.width);
+ }}
+ >
+ {
+ fullSwipeArmedRef.current = false;
+ if (swipeableRef.current) {
+ props.onSwipeableClose?.(swipeableRef.current);
+ }
+ }}
+ onSwipeableRelease={handleRelease}
+ onSwipeableOpenStartDrag={() => {
+ if (swipeableRef.current) {
+ props.onSwipeableWillOpen?.(swipeableRef.current);
+ }
+ }}
+ onSwipeableWillOpen={() => {
+ const methods = swipeableRef.current;
+ if (!methods) {
+ return;
+ }
- props.onSwipeableWillOpen?.(methods);
- if (fullSwipeArmedRef.current) {
- fullSwipeArmedRef.current = false;
- methods.close();
- if (fullSwipeAction === "primary") {
- props.primaryAction.onPress();
- } else {
- props.onDelete();
- }
- }
- }}
- overshootFriction={1}
- overshootRight
- renderRightActions={(_progress, translation, methods) => (
- {
+ props.onSwipeableWillOpen?.(methods);
+ if (fullSwipeArmedRef.current && !(dismissOnPress && fullSwipeAction === "primary")) {
+ fullSwipeArmedRef.current = false;
methods.close();
- props.primaryAction.onPress();
- },
+ if (fullSwipeAction === "primary") {
+ props.primaryAction.onPress();
+ } else {
+ props.onDelete();
+ }
+ }
}}
- secondaryAction={resolveSecondaryAction({
- close: () => methods.close(),
- onDelete: props.onDelete,
- secondaryAction: props.secondaryAction,
- threadTitle: props.threadTitle,
- })}
- translation={translation}
- />
- )}
- rightThreshold={actionsWidth * 0.42}
- simultaneousWithExternalGesture={props.simultaneousWithExternalGesture}
- >
- {props.children(close)}
-
+ overshootFriction={1}
+ overshootRight
+ renderRightActions={(_progress, translation, methods) => (
+
+ {
+ if (primaryAction.dismissOnPress) {
+ runOnUI(dismiss)(translation);
+ } else {
+ methods.close();
+ primaryAction.onPress();
+ }
+ },
+ }}
+ secondaryAction={resolveSecondaryAction({
+ close: () => methods.close(),
+ onDelete: props.onDelete,
+ secondaryAction: props.secondaryAction,
+ threadTitle: props.threadTitle,
+ })}
+ translation={translation}
+ />
+
+ )}
+ rightThreshold={actionsWidth * 0.42}
+ simultaneousWithExternalGesture={props.simultaneousWithExternalGesture}
+ >
+ {props.children(close)}
+
+
+
);
}
diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
index fc77f40c5afc..1cd723126c39 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -371,7 +371,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
- readonly onSettleThread: (thread: EnvironmentThreadShell) => void;
+ readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise;
readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void;
readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void;
readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
@@ -653,6 +653,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
accessibilityLabel: `Settle ${thread.title}`,
icon: "checkmark" as const,
label: "Settle",
+ dismissOnPress: true as const,
onPress: handleSettle,
};
}, [
@@ -963,7 +964,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
onSwipeableWillOpen={props.onSwipeableWillOpen}
primaryAction={primaryAction}
secondaryAction={secondaryAction}
- resetKey={`${thread.environmentId}:${thread.id}`}
+ resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}`}
simultaneousWithExternalGesture={props.simultaneousSwipeGesture}
threadTitle={thread.title}
>
diff --git a/patches/react-native-gesture-handler@2.32.0.patch b/patches/react-native-gesture-handler@2.32.0.patch
index a4f345e9e332..b35b5f64e02f 100644
--- a/patches/react-native-gesture-handler@2.32.0.patch
+++ b/patches/react-native-gesture-handler@2.32.0.patch
@@ -1,8 +1,8 @@
diff --git a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js
-index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6770f4a24 100644
+index 551ab92bf58db0b79d428dcd2f2df898ef686493..13db26d88bff975bc5d46bb6432cbf9fda3d489a 100644
--- a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js
+++ b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js
-@@ -34,6 +34,7 @@ const Swipeable = props => {
+@@ -34,11 +34,13 @@ const Swipeable = props => {
enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE,
dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET,
dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET,
@@ -10,7 +10,28 @@ index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6
friction = DEFAULT_FRICTION,
overshootFriction = DEFAULT_OVERSHOOT_FRICTION,
onSwipeableOpenStartDrag,
-@@ -285,11 +286,14 @@ const Swipeable = props => {
+ onSwipeableCloseStartDrag,
+ onSwipeableWillOpen,
++ onSwipeableRelease,
+ onSwipeableWillClose,
+ onSwipeableOpen,
+ onSwipeableClose,
+@@ -248,8 +250,13 @@ const Swipeable = props => {
+ toValue = -rightWidth.value;
+ }
+ }
++ // Let a UI-thread full-swipe commit take over before the return spring.
++ if (onSwipeableRelease?.(appliedTranslation)) {
++ return;
++ }
++
+ animateRow(toValue, velocityX / friction);
+- }, [animateRow, friction, leftThreshold, leftWidth, rightThreshold, rightWidth, rowState, userDrag]);
++ }, [animateRow, friction, leftThreshold, leftWidth, rightThreshold, rightWidth, rowState, userDrag, onSwipeableRelease, appliedTranslation]);
+ const close = (0, _react.useCallback)(() => {
+ 'worklet';
+
+@@ -285,11 +292,14 @@ const Swipeable = props => {
}).onFinalize(() => {
dragStarted.value = false;
});
@@ -27,10 +48,10 @@ index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6
const animatedStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => ({
transform: [{
diff --git a/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js
-index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb830493c22 100644
+index a2835d5416ffd5cf9a04e98774516b9e6569691e..055afce410d6eb655532e7a09371dfc22eae5e9c 100644
--- a/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js
+++ b/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js
-@@ -29,6 +29,7 @@ const Swipeable = props => {
+@@ -29,11 +29,13 @@ const Swipeable = props => {
enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE,
dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET,
dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET,
@@ -38,7 +59,28 @@ index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb8
friction = DEFAULT_FRICTION,
overshootFriction = DEFAULT_OVERSHOOT_FRICTION,
onSwipeableOpenStartDrag,
-@@ -280,11 +281,14 @@ const Swipeable = props => {
+ onSwipeableCloseStartDrag,
+ onSwipeableWillOpen,
++ onSwipeableRelease,
+ onSwipeableWillClose,
+ onSwipeableOpen,
+ onSwipeableClose,
+@@ -243,8 +245,13 @@ const Swipeable = props => {
+ toValue = -rightWidth.value;
+ }
+ }
++ // Let a UI-thread full-swipe commit take over before the return spring.
++ if (onSwipeableRelease?.(appliedTranslation)) {
++ return;
++ }
++
+ animateRow(toValue, velocityX / friction);
+- }, [animateRow, friction, leftThreshold, leftWidth, rightThreshold, rightWidth, rowState, userDrag]);
++ }, [animateRow, friction, leftThreshold, leftWidth, rightThreshold, rightWidth, rowState, userDrag, onSwipeableRelease, appliedTranslation]);
+ const close = useCallback(() => {
+ 'worklet';
+
+@@ -280,11 +287,14 @@ const Swipeable = props => {
}).onFinalize(() => {
dragStarted.value = false;
});
@@ -55,7 +97,7 @@ index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb8
const animatedStyle = useAnimatedStyle(() => ({
transform: [{
diff --git a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
-index ac8b76830d468edfbc29052b452a36221323c3de..589e329d2aa706ddfff0d1037df0d85a050edbb0 100644
+index ac8b76830d468edfbc29052b452a36221323c3de..6985d54d9d359e825a5a0e6078bb113204e2807b 100644
--- a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
+++ b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts
@@ -64,6 +64,13 @@ export interface SwipeableProps {
@@ -72,11 +114,25 @@ index ac8b76830d468edfbc29052b452a36221323c3de..589e329d2aa706ddfff0d1037df0d85a
/**
* Value indicating if the swipeable panel can be pulled further than the left
* actions panel's width. It is set to true by default as long as the left
+@@ -90,6 +97,13 @@ export interface SwipeableProps {
+ * Called when action panel is closed.
+ */
+ onSwipeableClose?: (direction: SwipeDirection.LEFT | SwipeDirection.RIGHT) => void;
++ /**
++ * UI-thread worklet called on release before the built-in spring starts.
++ * Return true to consume the release and take ownership of translation.
++ * The caller must reset or remove the row after its custom animation.
++ */
++ onSwipeableRelease?: (translation: SharedValue) => boolean;
++
+ /**
+ * Called when action panel starts animating on open (either right or left).
+ */
diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx
-index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e092ea997 100644
+index b6134c908624adc590ebd264e90e558b88e235d5..fa4b1c1749bd0e86cbf5f5546f295d8099e1a481 100644
--- a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx
+++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx
-@@ -58,6 +58,7 @@ const Swipeable = (props: SwipeableProps) => {
+@@ -58,11 +58,13 @@ const Swipeable = (props: SwipeableProps) => {
enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE,
dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET,
dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET,
@@ -84,7 +140,34 @@ index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e
friction = DEFAULT_FRICTION,
overshootFriction = DEFAULT_OVERSHOOT_FRICTION,
onSwipeableOpenStartDrag,
-@@ -537,6 +538,10 @@ const Swipeable = (props: SwipeableProps) => {
+ onSwipeableCloseStartDrag,
+ onSwipeableWillOpen,
++ onSwipeableRelease,
+ onSwipeableWillClose,
+ onSwipeableOpen,
+ onSwipeableClose,
+@@ -457,6 +459,11 @@ const Swipeable = (props: SwipeableProps) => {
+ }
+ }
+
++ // Let a UI-thread full-swipe commit take over before the return spring.
++ if (onSwipeableRelease?.(appliedTranslation)) {
++ return;
++ }
++
+ animateRow(toValue, velocityX / friction);
+ },
+ [
+@@ -468,6 +475,8 @@ const Swipeable = (props: SwipeableProps) => {
+ rightWidth,
+ rowState,
+ userDrag,
++ onSwipeableRelease,
++ appliedTranslation,
+ ]
+ );
+
+@@ -537,6 +546,10 @@ const Swipeable = (props: SwipeableProps) => {
dragStarted.value = false;
});
@@ -95,7 +178,7 @@ index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e
Object.entries(relationProps).forEach(([relationName, relation]) => {
applyRelationProp(
pan,
-@@ -552,6 +557,7 @@ const Swipeable = (props: SwipeableProps) => {
+@@ -552,6 +565,7 @@ const Swipeable = (props: SwipeableProps) => {
enableTrackpadTwoFingerGesture,
dragOffsetFromRightEdge,
dragOffsetFromLeftEdge,
@@ -104,7 +187,7 @@ index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e
relationProps,
userDrag,
diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts b/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts
-index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..d4b6ff508077728c8be83762923d866dc95b144c 100644
+index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..3ab14d240da346b1927792e480f5b1e902b03bf8 100644
--- a/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts
+++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts
@@ -77,6 +77,14 @@ export interface SwipeableProps {
@@ -122,3 +205,17 @@ index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..d4b6ff508077728c8be83762923d866d
/**
* Value indicating if the swipeable panel can be pulled further than the left
* actions panel's width. It is set to true by default as long as the left
+@@ -112,6 +120,13 @@ export interface SwipeableProps {
+ direction: SwipeDirection.LEFT | SwipeDirection.RIGHT
+ ) => void;
+
++ /**
++ * UI-thread worklet called on release before the built-in spring starts.
++ * Return true to consume the release and take ownership of translation.
++ * The caller must reset or remove the row after its custom animation.
++ */
++ onSwipeableRelease?: (translation: SharedValue) => boolean;
++
+ /**
+ * Called when action panel starts animating on open (either right or left).
+ */
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e9987c20f28f..1b1598d0dcea 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -99,7 +99,7 @@ patchedDependencies:
effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6
expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a
expo-sharing@57.0.17: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45
- react-native-gesture-handler@2.32.0: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3
+ react-native-gesture-handler@2.32.0: 96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398
react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787
react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
react-native-screens@4.26.2: 8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d
@@ -410,7 +410,7 @@ importers:
version: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
react-native-gesture-handler:
specifier: ~2.32.0
- version: 2.32.0(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
+ version: 2.32.0(patch_hash=96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
react-native-image-viewing:
specifier: ^0.2.2
version: 0.2.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
@@ -20045,7 +20045,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- react-native-gesture-handler@2.32.0(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
+ react-native-gesture-handler@2.32.0(patch_hash=96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
dependencies:
'@egjs/hammerjs': 2.0.17
'@types/react-test-renderer': 19.1.0
From 95d99373b74edfb3bfd092c61a15999a95727423 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sun, 6 Sep 2026 12:44:41 -0700
Subject: [PATCH 11/71] fix(clients): show feedback results in composer banners
(#10398)
---
.../src/features/threads/ComposerFeedback.tsx | 63 ++++++++++++++++
.../features/threads/ThreadDetailScreen.tsx | 15 +++-
.../features/threads/ThreadRouteScreen.tsx | 2 +
apps/mobile/src/lib/threadActivity.test.ts | 67 -----------------
.../src/state/use-thread-composer-state.ts | 61 ++++++----------
apps/web/src/components/ChatView.tsx | 71 ++++++-------------
.../src/components/chat/ComposerFeedback.tsx | 49 +++++++++++++
.../components/chat/MessagesTimeline.test.tsx | 56 ---------------
.../src/state/threadFeedback.test.ts | 18 +++--
.../src/state/threadFeedback.ts | 42 ++++-------
10 files changed, 195 insertions(+), 249 deletions(-)
create mode 100644 apps/mobile/src/features/threads/ComposerFeedback.tsx
create mode 100644 apps/web/src/components/chat/ComposerFeedback.tsx
diff --git a/apps/mobile/src/features/threads/ComposerFeedback.tsx b/apps/mobile/src/features/threads/ComposerFeedback.tsx
new file mode 100644
index 000000000000..dbbdc166d198
--- /dev/null
+++ b/apps/mobile/src/features/threads/ComposerFeedback.tsx
@@ -0,0 +1,63 @@
+import {
+ codexFeedbackNotice,
+ type CodexFeedbackSubmission,
+} from "@t3tools/client-runtime/state/threads";
+import { Pressable, View } from "react-native";
+
+import { AppText as Text } from "../../components/AppText";
+import { SymbolView } from "../../components/AppSymbol";
+import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
+
+export function ComposerFeedback({
+ submission,
+ onDismiss,
+}: {
+ readonly submission: CodexFeedbackSubmission;
+ readonly onDismiss: () => void;
+}) {
+ const notice = codexFeedbackNotice(submission);
+ if (!notice) return null;
+ return (
+
+
+
+
+ {notice.title}
+
+ {submission.status !== "uploading" ? (
+
+
+
+ ) : null}
+
+ {notice.description ? (
+
+ {notice.description}
+
+ ) : null}
+ {submission.status === "sent" ? (
+
+ copyTextWithHaptic(submission.feedbackId, { target: "Codex feedback thread ID" })
+ }
+ className="self-start py-1 active:opacity-60"
+ >
+ Copy ID
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index 52ab91a5f496..3392b534b634 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -3,7 +3,10 @@ import {
appendCodexArtifactTemplateUsePrompt,
type CodexArtifactTemplate,
} from "@t3tools/client-runtime/codex-artifact-templates";
-import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
+import type {
+ CodexFeedbackSubmission,
+ EnvironmentThreadStatus,
+} from "@t3tools/client-runtime/state/threads";
import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
import type { LegendListRef } from "@legendapp/list/react-native";
@@ -72,6 +75,7 @@ import type {
ThreadFeedEntry,
} from "../../lib/threadActivity";
import { PendingApprovalCard } from "./PendingApprovalCard";
+import { ComposerFeedback } from "./ComposerFeedback";
import { ComposerUsageLimits } from "./ComposerUsageLimits";
import { PendingUserInputCard } from "./PendingUserInputCard";
import {
@@ -101,6 +105,8 @@ export interface ThreadDetailScreenProps {
readonly screenTone: StatusTone;
readonly connectionError: string | null;
readonly environmentLabel: string | null;
+ readonly feedbackSubmissions: ReadonlyArray;
+ readonly onDismissFeedback: (id: MessageId) => void;
readonly selectedThreadFeed: ReadonlyArray;
readonly activeWorkStartedAt: string | null;
readonly isCompacting: boolean;
@@ -847,6 +853,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onScrollToEnd={handleScrollToEnd}
/>
+ {props.feedbackSubmissions.map((submission) => (
+ props.onDismissFeedback(submission.id)}
+ />
+ ))}
{usageLimitsReport && activeUserInputRequestId === null ? (
{
- it("keeps pending and completed feedback messages in the mobile thread body", () => {
- const pending = {
- id: MessageId.make("feedback-command"),
- command: "/feedback The agent stopped early.",
- createdAt: "2026-08-23T00:00:00.000Z",
- status: "uploading" as const,
- };
- const entries = [codexFeedbackMessage(pending), codexFeedbackMessage(pending, "assistant")].map(
- (message) => ({
- type: "message" as const,
- id: message.id,
- createdAt: message.createdAt,
- message,
- }),
- );
-
- expect(deriveThreadFeedPresentation(entries, null, new Set())).toEqual(entries);
- expect(entries[1]?.message.text).toBe("Sending feedback to OpenAI...");
-
- const completed = codexFeedbackMessage(
- { ...pending, status: "sent", feedbackId: "codex-thread-1" },
- "assistant",
- );
- expect(completed.text).toContain("codex-thread-1");
- });
-});
-
const singleSelectQuestion = {
id: "runtime",
header: "Runtime",
@@ -879,44 +850,6 @@ describe("buildThreadFeed", () => {
},
);
- it("keeps older local feedback before newer messages returned by the server", () => {
- const submission = {
- id: MessageId.make("feedback-command-ordering"),
- command: "/feedback The agent stopped early.",
- createdAt: "2026-08-23T00:00:01.000Z",
- status: "sent" as const,
- feedbackId: "codex-thread-1",
- };
- const laterMessage = {
- id: MessageId.make("later-server-message"),
- role: "assistant" as const,
- text: "Newer server response",
- turnId: null,
- createdAt: "2026-08-23T00:00:02.000Z",
- updatedAt: "2026-08-23T00:00:02.000Z",
- streaming: false,
- };
- const thread = makeThread({
- id: ThreadId.make("thread-feedback-ordering"),
- projectId: ProjectId.make("project-1"),
- title: "Feedback ordering",
- messages: [laterMessage],
- });
-
- const feed = buildThreadFeed(thread, {
- localMessages: [
- codexFeedbackMessage(submission),
- codexFeedbackMessage(submission, "assistant"),
- ],
- });
-
- expect(feed.map((entry) => entry.id)).toEqual([
- "feedback-command-ordering",
- "feedback-command-ordering:feedback",
- "later-server-message",
- ]);
- });
-
it("keeps historic work entries attributed to their turns", () => {
const thread = makeThread({
id: ThreadId.make("thread-1"),
diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts
index 64a7ddeb7882..b9362ba8f172 100644
--- a/apps/mobile/src/state/use-thread-composer-state.ts
+++ b/apps/mobile/src/state/use-thread-composer-state.ts
@@ -1,7 +1,6 @@
import { useAtomValue } from "@effect/atom-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert } from "react-native";
-import * as Cause from "effect/Cause";
import {
CommandId,
@@ -16,12 +15,10 @@ import {
} from "@t3tools/contracts";
import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
import {
- codexFeedbackMessage,
parseCodexFeedbackCommand,
submitCodexFeedback,
type CodexFeedbackSubmission,
} from "@t3tools/client-runtime/state/threads";
-import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";
import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
@@ -35,7 +32,6 @@ import {
} from "../lib/composerImages";
import type { DraftComposerImageAttachment } from "../lib/composerImages";
import { scopedThreadKey } from "../lib/scopedEntities";
-import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
import { buildThreadFeed } from "../lib/threadActivity";
import { appAtomRegistry } from "../state/atom-registry";
import {
@@ -126,27 +122,31 @@ export function useThreadComposerState() {
() => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),
[queuedMessagesByThreadKey, selectedThreadKey],
);
- const localFeedbackMessages = useMemo(() => {
- const submissions = selectedThreadKey
- ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? [])
- : [];
- return submissions.flatMap((submission) =>
- submission.status === "interrupted"
- ? []
- : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")],
- );
- }, [feedbackSubmissionsByThreadKey, selectedThreadKey]);
+ const feedbackSubmissions = useMemo(
+ () => (selectedThreadKey ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? []) : []),
+ [feedbackSubmissionsByThreadKey, selectedThreadKey],
+ );
+ const dismissFeedback = useCallback(
+ (id: MessageId) => {
+ if (!selectedThreadKey) return;
+ setFeedbackSubmissionsByThreadKey((current) => ({
+ ...current,
+ [selectedThreadKey]: (current[selectedThreadKey] ?? []).filter((entry) => entry.id !== id),
+ }));
+ },
+ [selectedThreadKey],
+ );
const selectedThreadMessages = selectedThreadDetail?.messages;
const selectedThreadActivities = selectedThreadDetail?.activities;
const selectedThreadFeed = useMemo(
() =>
selectedThreadMessages && selectedThreadActivities
- ? buildThreadFeed(
- { messages: selectedThreadMessages, activities: selectedThreadActivities },
- { localMessages: localFeedbackMessages },
- )
+ ? buildThreadFeed({
+ messages: selectedThreadMessages,
+ activities: selectedThreadActivities,
+ })
: [],
- [localFeedbackMessages, selectedThreadActivities, selectedThreadMessages],
+ [selectedThreadActivities, selectedThreadMessages],
);
const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;
@@ -294,7 +294,7 @@ export function useThreadComposerState() {
return null;
}
const metadata = makeQueuedMessageMetadata();
- const result = await submitCodexFeedback({
+ await submitCodexFeedback({
submission: {
id: MessageId.make(metadata.messageId),
command: text,
@@ -322,25 +322,6 @@ export function useThreadComposerState() {
},
}),
});
- if (result._tag === "Failure") {
- if (isAtomCommandInterrupted(result)) {
- return null;
- }
- const error = Cause.squash(result.cause);
- Alert.alert(
- "Could not send feedback to OpenAI",
- error instanceof Error ? error.message : "An error occurred.",
- );
- return null;
- }
- const feedbackId = result.value.feedbackId;
- Alert.alert("Feedback sent to OpenAI", `Thread ID: ${feedbackId}`, [
- { text: "OK", style: "cancel" },
- {
- text: "Copy ID",
- onPress: () => copyTextWithHaptic(feedbackId, { target: "Codex feedback thread ID" }),
- },
- ]);
return null;
}
@@ -573,6 +554,8 @@ export function useThreadComposerState() {
);
return {
+ feedbackSubmissions,
+ dismissFeedback,
selectedThreadFeed,
selectedThreadQueueCount,
activeWorkStartedAt,
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index faeab7e75ac6..904a6acea80e 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -5,6 +5,7 @@ import {
hasProviderUsageLimits,
isUsageLimitsCommand,
} from "@t3tools/shared/usageLimits";
+import { feedbackBannerItem } from "./chat/ComposerFeedback";
import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";
import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
import {
@@ -40,7 +41,6 @@ import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
import {
- codexFeedbackMessage,
parseCodexFeedbackCommand,
submitCodexFeedback,
type CodexFeedbackSubmission,
@@ -3027,14 +3027,7 @@ export default function ChatView(props: ChatViewProps) {
});
});
- const localMessages = [
- ...optimisticUserMessages,
- ...feedbackSubmissions.flatMap((submission) =>
- submission.status === "interrupted"
- ? []
- : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")],
- ),
- ];
+ const localMessages = optimisticUserMessages;
if (localMessages.length === 0) {
return serverMessagesWithPreviewHandoff;
}
@@ -3047,7 +3040,6 @@ export default function ChatView(props: ChatViewProps) {
}, [
attachmentPreviewHandoffByMessageId,
displayServerMessages,
- feedbackSubmissions,
optimisticUserMessages,
projectHandoffMessagePreviews,
]);
@@ -5624,6 +5616,21 @@ export default function ChatView(props: ChatViewProps) {
}
void handleSwitchCheckoutToThread();
}, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);
+ const feedbackBannerItems = useMemo(
+ () =>
+ feedbackSubmissions.flatMap((submission) => {
+ const item = feedbackBannerItem(submission, () => {
+ setFeedbackSubmissionsByThreadKey((current) => ({
+ ...current,
+ [routeThreadKey]: (current[routeThreadKey] ?? []).filter(
+ (entry) => entry.id !== submission.id,
+ ),
+ }));
+ });
+ return item ? [item] : [];
+ }),
+ [feedbackSubmissions, routeThreadKey],
+ );
const composerBannerItems = useMemo(() => {
const backgroundLivenessItems =
backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
@@ -5635,6 +5642,7 @@ export default function ChatView(props: ChatViewProps) {
const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];
if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
return [
+ ...feedbackBannerItems,
...usageLimitsItems,
...systemComposerBannerItems,
...backgroundLivenessItems,
@@ -5644,6 +5652,7 @@ export default function ChatView(props: ChatViewProps) {
];
}
return [
+ ...feedbackBannerItems,
...usageLimitsItems,
...systemComposerBannerItems,
...backgroundLivenessItems,
@@ -5692,6 +5701,7 @@ export default function ChatView(props: ChatViewProps) {
}, [
activeBranchMismatchKey,
backgroundLivenessBannerItem,
+ feedbackBannerItems,
handleRestoreThreadBranch,
isRestoringThreadBranch,
localCheckoutBranchMismatch,
@@ -6276,7 +6286,7 @@ export default function ChatView(props: ChatViewProps) {
return;
}
feedbackUploadsInFlightRef.current.add(routeThreadKey);
- const result = await submitCodexFeedback({
+ await submitCodexFeedback({
submission: {
id: newMessageId(),
command: trimmed,
@@ -6286,7 +6296,6 @@ export default function ChatView(props: ChatViewProps) {
promptRef.current = "";
clearComposerDraftContent(composerDraftTarget);
composerRef.current?.resetCursorState();
- scrollToEnd();
},
onUpdate: (submission) => {
setFeedbackSubmissionsByThreadKey((current) => {
@@ -6311,43 +6320,7 @@ export default function ChatView(props: ChatViewProps) {
}).finally(() => {
feedbackUploadsInFlightRef.current.delete(routeThreadKey);
});
- if (result._tag === "Failure") {
- if (!isAtomCommandInterrupted(result)) {
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Could not send feedback to OpenAI",
- description: chatActionErrorMessage(squashAtomCommandFailure(result)),
- }),
- );
- }
- return;
- }
- const feedbackId = result.value.feedbackId;
- toastManager.add(
- stackedThreadToast({
- type: "success",
- title: "Feedback sent to OpenAI",
- description: `Thread ID: ${feedbackId}`,
- timeout: 0,
- actionProps: {
- children: "Copy ID",
- onClick: () => {
- void writeTextToClipboard(feedbackId, "Codex feedback thread ID").catch(
- (error: unknown) => {
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Could not copy thread ID",
- description: chatActionErrorMessage(error),
- }),
- );
- },
- );
- },
- },
- }),
- );
+
return;
}
if (
diff --git a/apps/web/src/components/chat/ComposerFeedback.tsx b/apps/web/src/components/chat/ComposerFeedback.tsx
new file mode 100644
index 000000000000..8322b1a99462
--- /dev/null
+++ b/apps/web/src/components/chat/ComposerFeedback.tsx
@@ -0,0 +1,49 @@
+import {
+ codexFeedbackNotice,
+ type CodexFeedbackSubmission,
+} from "@t3tools/client-runtime/state/threads";
+import { MessageSquareIcon } from "lucide-react";
+
+import { writeTextToClipboard } from "../../hooks/useCopyToClipboard";
+import { Button } from "../ui/button";
+import { toastManager } from "../ui/toast";
+import type { ComposerBannerStackItem } from "./ComposerBannerStack";
+
+export function feedbackBannerItem(
+ submission: CodexFeedbackSubmission,
+ onDismiss: () => void,
+): ComposerBannerStackItem | null {
+ const notice = codexFeedbackNotice(submission);
+ if (!notice) return null;
+ return {
+ id: `feedback:${submission.id}`,
+ variant:
+ submission.status === "failed" ? "error" : submission.status === "sent" ? "success" : "info",
+ priority: submission.status === "uploading" ? "activity" : "notice",
+ icon: ,
+ ...notice,
+ actions:
+ submission.status === "sent" ? (
+ {
+ void writeTextToClipboard(submission.feedbackId, "Codex feedback thread ID").catch(
+ (error: unknown) => {
+ toastManager.add({
+ type: "error",
+ title: "Could not copy thread ID",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ });
+ },
+ );
+ }}
+ >
+ Copy ID
+
+ ) : undefined,
+ ...(submission.status !== "uploading"
+ ? { dismissLabel: "Dismiss feedback notice", onDismiss }
+ : {}),
+ };
+}
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx
index fe3e9ab4417c..115f4f739254 100644
--- a/apps/web/src/components/chat/MessagesTimeline.test.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx
@@ -1,5 +1,4 @@
import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";
-import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { create, type ReactTestRenderer } from "react-test-renderer";
@@ -328,61 +327,6 @@ describe("MessagesTimeline", () => {
},
);
- it("renders a feedback command and its pending response as normal thread messages", () => {
- const submission = {
- id: MessageId.make("feedback-command"),
- command: "/feedback The agent stopped early.",
- createdAt: MESSAGE_CREATED_AT,
- status: "uploading" as const,
- };
- const messages = [
- codexFeedbackMessage(submission),
- codexFeedbackMessage(submission, "assistant"),
- ];
- const markup = renderToStaticMarkup(
- ({
- id: message.id,
- kind: "message" as const,
- createdAt: message.createdAt,
- message,
- }))}
- />,
- );
-
- expect(markup).toContain("/feedback The agent stopped early.");
- expect(markup).toContain("Sending feedback to OpenAI...");
- });
-
- it("renders the returned Codex thread ID in the feedback response", () => {
- const submission = {
- id: MessageId.make("feedback-command"),
- command: "/feedback The agent stopped early.",
- createdAt: MESSAGE_CREATED_AT,
- status: "sent" as const,
- feedbackId: "codex-thread-1",
- };
- const messages = [
- codexFeedbackMessage(submission),
- codexFeedbackMessage(submission, "assistant"),
- ];
- const markup = renderToStaticMarkup(
- ({
- id: message.id,
- kind: "message" as const,
- createdAt: message.createdAt,
- message,
- }))}
- />,
- );
-
- expect(markup).toContain("Feedback sent to OpenAI.");
- expect(markup).toContain("codex-thread-1");
- });
-
it("renders elapsed time for a completed turn", () => {
const turnId = TurnId.make("turn-with-fold");
const assistantEntry = buildAssistantTimelineEntry("Done.");
diff --git a/packages/client-runtime/src/state/threadFeedback.test.ts b/packages/client-runtime/src/state/threadFeedback.test.ts
index 14ce5185f4ad..cd66961a597f 100644
--- a/packages/client-runtime/src/state/threadFeedback.test.ts
+++ b/packages/client-runtime/src/state/threadFeedback.test.ts
@@ -4,7 +4,7 @@ import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";
import {
- codexFeedbackMessage,
+ codexFeedbackNotice,
parseCodexFeedbackCommand,
submitCodexFeedback,
type CodexFeedbackSubmission,
@@ -40,7 +40,7 @@ describe("submitCodexFeedback", () => {
createdAt: "2026-08-23T00:00:00.000Z",
} as const;
- it("shows the command and clears the draft before the upload finishes", async () => {
+ it("reports upload progress and clears the draft before the upload finishes", async () => {
let draft: string = submission.command;
let finishUpload:
| ((result: ReturnType>) => void)
@@ -66,14 +66,10 @@ describe("submitCodexFeedback", () => {
expect(draft).toBe("");
expect(states).toEqual([{ ...submission, status: "uploading" }]);
- expect(codexFeedbackMessage(states[0]!)).toMatchObject({
- id: submission.id,
- role: "user",
- text: submission.command,
+ expect(codexFeedbackNotice(states[0]!)).toEqual({
+ title: "Sending feedback to OpenAI...",
+ description: undefined,
});
- expect(codexFeedbackMessage(states[0]!, "assistant").text).toBe(
- "Sending feedback to OpenAI...",
- );
draft = "Keep this newer message.";
finishUpload?.(AsyncResult.success({ feedbackId: "codex-thread-1" }));
@@ -85,7 +81,7 @@ describe("submitCodexFeedback", () => {
status: "sent",
feedbackId: "codex-thread-1",
});
- expect(codexFeedbackMessage(states.at(-1)!, "assistant").text).toContain("codex-thread-1");
+ expect(codexFeedbackNotice(states.at(-1)!)?.description).toContain("codex-thread-1");
});
it("records a failed upload without losing its user-facing error", async () => {
@@ -105,6 +101,7 @@ describe("submitCodexFeedback", () => {
status: "failed",
errorMessage: "Upload rejected.",
});
+ expect(codexFeedbackNotice(states.at(-1)!)?.description).toBe("Upload rejected.");
});
it("marks interruptions without reporting them as upload failures", async () => {
@@ -119,6 +116,7 @@ describe("submitCodexFeedback", () => {
});
expect(states.at(-1)).toEqual({ ...submission, status: "interrupted" });
+ expect(codexFeedbackNotice(states.at(-1)!)).toBeNull();
});
it("lets another feedback submission finish while the first remains in flight", async () => {
diff --git a/packages/client-runtime/src/state/threadFeedback.ts b/packages/client-runtime/src/state/threadFeedback.ts
index 29abb2689310..1b02a8982a16 100644
--- a/packages/client-runtime/src/state/threadFeedback.ts
+++ b/packages/client-runtime/src/state/threadFeedback.ts
@@ -1,8 +1,4 @@
-import {
- MessageId,
- type OrchestrationMessage,
- type ProviderUploadFeedbackResult,
-} from "@t3tools/contracts";
+import type { MessageId, ProviderUploadFeedbackResult } from "@t3tools/contracts";
import {
isAtomCommandInterrupted,
@@ -32,28 +28,20 @@ export function parseCodexFeedbackCommand(text: string): { readonly reason?: str
return reason ? { reason } : {};
}
-export function codexFeedbackMessage(
- submission: CodexFeedbackSubmission,
- role: "user" | "assistant" = "user",
-): OrchestrationMessage {
- const text =
- role === "user"
- ? submission.command
- : submission.status === "sent"
- ? `Feedback sent to OpenAI.\n\nThread ID: \`${submission.feedbackId}\``
- : submission.status === "failed"
- ? `Could not send feedback to OpenAI.\n\n${submission.errorMessage}`
- : "Sending feedback to OpenAI...";
-
- return {
- id: role === "user" ? submission.id : MessageId.make(`${submission.id}:feedback`),
- role,
- text,
- turnId: null,
- streaming: false,
- createdAt: submission.createdAt,
- updatedAt: submission.createdAt,
- };
+export function codexFeedbackNotice(submission: CodexFeedbackSubmission) {
+ switch (submission.status) {
+ case "interrupted":
+ return null;
+ case "uploading":
+ return { title: "Sending feedback to OpenAI...", description: undefined };
+ case "sent":
+ return {
+ title: "Feedback sent to OpenAI",
+ description: `Thread ID: ${submission.feedbackId}`,
+ };
+ case "failed":
+ return { title: "Could not send feedback to OpenAI", description: submission.errorMessage };
+ }
}
export async function submitCodexFeedback(input: {
From ea646c0834a3394ecb0be4a30c5d367e5a9002bd Mon Sep 17 00:00:00 2001
From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com>
Date: Mon, 7 Sep 2026 01:17:38 +0530
Subject: [PATCH 12/71] fix(server): stop Windows terminal polling from spiking
CPU (#9476)
---
.../diagnostics/ProcessDiagnostics.test.ts | 2 +-
.../src/resourceTelemetry/Model.test.ts | 2 +-
.../NativeTelemetryClient.ts | 93 +++++++++++-
.../ResourceTelemetry.test.ts | 4 +-
.../ResourceTelemetryHistory.test.ts | 2 +-
apps/server/src/server.ts | 1 +
apps/server/src/terminal/Manager.test.ts | 96 +++++++++++++
apps/server/src/terminal/Manager.ts | 136 ++++++++++++++----
native/resource-monitor/src/main.rs | 82 ++++++++++-
packages/contracts/src/resourceTelemetry.ts | 26 +++-
10 files changed, 404 insertions(+), 40 deletions(-)
diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts
index 2efa3375d275..5bcc74206893 100644
--- a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts
+++ b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts
@@ -19,7 +19,7 @@ function makeNativeSnapshot(
processes: ResourceMonitorSnapshotEvent["processes"],
): ResourceMonitorSnapshotEvent {
return {
- version: 2,
+ version: 3,
type: "snapshot",
sequence: 1,
sampledAtUnixMs: DateTime.toEpochMillis(DateTime.makeUnsafe("2026-05-05T10:00:00.000Z")),
diff --git a/apps/server/src/resourceTelemetry/Model.test.ts b/apps/server/src/resourceTelemetry/Model.test.ts
index 94690e3967bc..6f759ac9744f 100644
--- a/apps/server/src/resourceTelemetry/Model.test.ts
+++ b/apps/server/src/resourceTelemetry/Model.test.ts
@@ -39,7 +39,7 @@ function nativeSnapshot(
sequence = 1,
): ResourceMonitorSnapshotEvent {
return {
- version: 2,
+ version: 3,
type: "snapshot",
sequence,
sampledAtUnixMs,
diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts
index 4af8f1b762d5..9ddc72d61ebd 100644
--- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts
+++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts
@@ -5,6 +5,7 @@ import type {
ResourceMonitorEvent,
ResourceMonitorExternalProcess,
ResourceMonitorHelloEvent,
+ ResourceMonitorProcessTableEntry,
ResourceMonitorSnapshotEvent,
ResourceTelemetrySourceStatus,
} from "@t3tools/contracts";
@@ -44,6 +45,7 @@ const BATTERY_SAMPLE_INTERVAL_MS = 5_000;
const CONSTRAINED_SAMPLE_INTERVAL_MS = 15_000;
const HANDSHAKE_TIMEOUT = Duration.seconds(5);
const SAMPLE_REQUEST_TIMEOUT = Duration.seconds(5);
+const PROCESS_TABLE_REQUEST_TIMEOUT = Duration.seconds(5);
const HISTORY_REQUEST_TIMEOUT = Duration.seconds(15);
const INITIAL_RESTART_DELAY = Duration.millis(500);
const MAX_RESTART_DELAY = Duration.seconds(10);
@@ -76,7 +78,7 @@ export class NativeTelemetryHandshakeTimedOut extends Schema.TaggedErrorClass()(
"NativeTelemetryRequestTimedOut",
{
- operation: Schema.Literals(["readHistory", "sampleNow"]),
+ operation: Schema.Literals(["processTable", "readHistory", "sampleNow"]),
timeoutMs: Schema.Number,
},
) {
@@ -192,6 +194,10 @@ export class NativeTelemetryClient extends Context.Service<
snapshot: HostPowerSnapshot,
) => Effect.Effect;
readonly sampleNow: Effect.Effect;
+ readonly processTable: Effect.Effect<
+ ReadonlyArray,
+ NativeTelemetryClientError
+ >;
readonly retry: Effect.Effect;
readonly health: Effect.Effect;
readonly subscribeHealth: Effect.Effect<
@@ -382,6 +388,12 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu
const pendingSamples = yield* Ref.make(
new Map>(),
);
+ const pendingProcessTables = yield* Ref.make(
+ new Map<
+ string,
+ Deferred.Deferred, NativeTelemetryClientError>
+ >(),
+ );
const pendingHistories = yield* Ref.make(new Map());
const snapshots = yield* PubSub.sliding(8);
const healthChanges = yield* PubSub.sliding(4);
@@ -399,10 +411,14 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu
const failPending = (error: NativeTelemetryClientError) =>
Effect.gen(function* () {
const samples = yield* Ref.getAndSet(pendingSamples, new Map());
+ const processTables = yield* Ref.getAndSet(pendingProcessTables, new Map());
const histories = yield* Ref.getAndSet(pendingHistories, new Map());
yield* Effect.forEach(samples.values(), (deferred) => Deferred.fail(deferred, error), {
discard: true,
});
+ yield* Effect.forEach(processTables.values(), (deferred) => Deferred.fail(deferred, error), {
+ discard: true,
+ });
yield* Effect.forEach(
histories.values(),
(request) => Deferred.fail(request.deferred, error),
@@ -481,6 +497,21 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu
}
}
});
+ case "processTable":
+ return Ref.modify(pendingProcessTables, (pending) => {
+ const next = new Map(pending);
+ const deferred = next.get(event.requestId);
+ next.delete(event.requestId);
+ return [Option.fromUndefinedOr(deferred), next] as const;
+ }).pipe(
+ Effect.flatMap(
+ Option.match({
+ onNone: () => Effect.void,
+ onSome: (deferred) => Deferred.succeed(deferred, event.processes),
+ }),
+ ),
+ Effect.asVoid,
+ );
case "historyChunk":
return Effect.gen(function* () {
const latestSnapshot = event.snapshots.at(-1);
@@ -936,6 +967,60 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu
);
});
+ const processTable: NativeTelemetryClient["Service"]["processTable"] = Effect.gen(function* () {
+ const current = yield* Ref.get(state);
+ if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) {
+ return yield* new NativeTelemetryUnavailable({
+ reason: Option.getOrElse(current.lastError, () => "sidecar is not running"),
+ });
+ }
+
+ const requestId = yield* crypto.randomUUIDv4.pipe(
+ Effect.mapError(
+ (cause) => new NativeTelemetryCommandFailed({ operation: "createRequestId", cause }),
+ ),
+ );
+ const deferred = yield* Deferred.make<
+ ReadonlyArray,
+ NativeTelemetryClientError
+ >();
+ yield* Ref.update(pendingProcessTables, (pending) => {
+ const next = new Map(pending);
+ next.set(requestId, deferred);
+ return next;
+ });
+ return yield* writeCommand(Option.getOrThrow(current.handle), {
+ version: RESOURCE_MONITOR_PROTOCOL_VERSION,
+ type: "processTable",
+ requestId,
+ }).pipe(
+ Effect.andThen(
+ Deferred.await(deferred).pipe(
+ Effect.timeoutOption(PROCESS_TABLE_REQUEST_TIMEOUT),
+ Effect.flatMap(
+ Option.match({
+ onNone: () =>
+ Effect.fail(
+ new NativeTelemetryRequestTimedOut({
+ operation: "processTable",
+ timeoutMs: Duration.toMillis(PROCESS_TABLE_REQUEST_TIMEOUT),
+ }),
+ ),
+ onSome: Effect.succeed,
+ }),
+ ),
+ ),
+ ),
+ Effect.ensuring(
+ Ref.update(pendingProcessTables, (pending) => {
+ const next = new Map(pending);
+ next.delete(requestId);
+ return next;
+ }),
+ ),
+ );
+ });
+
const health = currentHealth;
return NativeTelemetryClient.of({
@@ -957,6 +1042,7 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu
setExternalProcesses,
setHostPowerState,
sampleNow,
+ processTable,
retry: Ref.get(state).pipe(
Effect.flatMap((current) =>
!canRequestNativeTelemetryRetry(current.status, Option.isSome(current.handle))
@@ -1010,6 +1096,11 @@ export const layerTest = (
reason: "No resource monitor sample was configured for this test.",
}),
),
+ processTable: Effect.fail(
+ new NativeTelemetryUnavailable({
+ reason: "No resource monitor process table was configured for this test.",
+ }),
+ ),
retry: Effect.succeed(false),
health,
subscribeHealth:
diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts b/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts
index a96423607baf..9c371078332d 100644
--- a/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts
+++ b/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts
@@ -82,7 +82,7 @@ function nativeSnapshot(input: {
}),
];
return {
- version: 2,
+ version: 3,
type: "snapshot",
sequence: input.sequence,
sampledAtUnixMs: input.sampledAtUnixMs,
@@ -497,7 +497,7 @@ describe("ResourceTelemetry", () => {
const nativeHealth = yield* Ref.make({
status: "healthy",
hello: Option.some({
- version: 2,
+ version: 3,
type: "hello",
sidecarVersion: "0.1.0",
sidecarPid: 9_000,
diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts b/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts
index 879c83d86dee..fff4588c8468 100644
--- a/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts
+++ b/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts
@@ -64,7 +64,7 @@ function snapshot(
}),
];
return {
- version: 2,
+ version: 3,
type: "snapshot",
sequence,
sampledAtUnixMs,
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 696210b36b86..349644966f26 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -374,6 +374,7 @@ const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.
const TerminalLayerLive = TerminalManager.layer.pipe(
Layer.provide(PtyAdapterLive),
Layer.provide(PortScannerLayerLive),
+ Layer.provide(NativeTelemetryLayerLive),
);
const PreviewLayerLive = Layer.empty.pipe(
diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts
index e480e11588b0..f631992e7ae3 100644
--- a/apps/server/src/terminal/Manager.test.ts
+++ b/apps/server/src/terminal/Manager.test.ts
@@ -14,6 +14,7 @@ import {
} from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Data from "effect/Data";
+import * as Clock from "effect/Clock";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
@@ -220,6 +221,10 @@ interface CreateManagerOptions {
readonly childCommand: string | null;
readonly processIds: ReadonlyArray;
}>;
+ processTable?: Effect.Effect<
+ ReadonlyArray<{ readonly pid: number; readonly ppid: number; readonly name: string }>,
+ never
+ >;
subprocessPollIntervalMs?: number;
processKillGraceMs?: number;
maxRetainedInactiveSessions?: number;
@@ -265,6 +270,7 @@ const createManager = (
...(options.subprocessInspector !== undefined
? { subprocessInspector: options.subprocessInspector }
: {}),
+ ...(options.processTable !== undefined ? { processTable: options.processTable } : {}),
...(options.subprocessPollIntervalMs !== undefined
? { subprocessPollIntervalMs: options.subprocessPollIntervalMs }
: {}),
@@ -1192,6 +1198,96 @@ it.layer(
}),
);
+ it("calculates snapshot failure backoff and success reset delays", () => {
+ assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 0), 1_000);
+ assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 1), 2_000);
+ assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 2), 4_000);
+ assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 30), 60_000);
+ });
+
+ it.effect("uses process snapshots from the resource monitor", () =>
+ Effect.gen(function* () {
+ let snapshotCalls = 0;
+ const { manager, getEvents } = yield* createManager(5, {
+ subprocessPollIntervalMs: 20,
+ processTable: Effect.sync(() => {
+ snapshotCalls += 1;
+ return [{ pid: 100, ppid: 9000, name: "ping.exe" }];
+ }),
+ }).pipe(Effect.provide(withHostPlatform("win32")));
+
+ yield* manager.open(openInput());
+ yield* waitFor(
+ Effect.map(getEvents, (events) =>
+ events.some(
+ (event) =>
+ event.type === "activity" && event.hasRunningSubprocess && event.label === "ping",
+ ),
+ ),
+ "1200 millis",
+ );
+ expect(snapshotCalls).toBeGreaterThan(0);
+ }),
+ );
+
+ it.effect("backs off the spawned fallback when the resource monitor snapshot fails", () =>
+ Effect.gen(function* () {
+ const fallbackCalls: Array = [];
+ const processRunner: ProcessRunner.ProcessRunner["Service"] = {
+ run: () =>
+ Clock.currentTimeMillis.pipe(
+ Effect.map((now) => {
+ fallbackCalls.push(now);
+ return {
+ stdout: " 100 9000 vim",
+ stderr: "",
+ code: ChildProcessSpawner.ExitCode(0),
+ timedOut: false,
+ stdoutTruncated: false,
+ stderrInvalidUtf8: false,
+ stdoutInvalidUtf8: false,
+ stderrTruncated: false,
+ };
+ }),
+ ),
+ };
+
+ const { manager, getEvents } = yield* createManager(5, {
+ subprocessPollIntervalMs: 20,
+ processTable: Effect.fail("sidecar unavailable").pipe(
+ Effect.mapError((cause) => cause as never),
+ ),
+ }).pipe(
+ Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
+ Effect.provide(withHostPlatform("linux")),
+ );
+
+ yield* manager.open(openInput());
+ // The fallback data is still applied while the sidecar is down.
+ yield* waitFor(
+ Effect.map(getEvents, (events) =>
+ events.some(
+ (event) =>
+ event.type === "activity" &&
+ event.hasRunningSubprocess === true &&
+ event.label === "vim",
+ ),
+ ),
+ "1200 millis",
+ );
+
+ yield* waitFor(
+ Effect.sync(() => fallbackCalls.length >= 4),
+ "2000 millis",
+ );
+ // Four snapshots at the 20 ms base cadence would span ~60 ms. Backoff
+ // (40 + 80 + 160 ms) stretches the same four snapshots past 150 ms, so
+ // a stalled sidecar no longer hot-loops the spawned fallback.
+ const spanMs = fallbackCalls[3]! - fallbackCalls[0]!;
+ expect(spanMs).toBeGreaterThan(150);
+ }),
+ );
+
it.effect("caps persisted history to configured line limit", () =>
Effect.gen(function* () {
const { manager, ptyAdapter } = yield* createManager(3);
diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts
index d9bdc6bcd92a..10143b7be20b 100644
--- a/apps/server/src/terminal/Manager.ts
+++ b/apps/server/src/terminal/Manager.ts
@@ -28,6 +28,7 @@ import {
type TerminalMetadataStreamEvent,
type TerminalOpenInput,
type TerminalResizeInput,
+ type ResourceMonitorProcessTableEntry,
type TerminalRestartInput,
type TerminalSessionSnapshot,
type TerminalSessionStatus,
@@ -70,6 +71,7 @@ import {
import { expandHomePath } from "../pathExpansion.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as PortScanner from "../preview/PortScanner.ts";
+import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts";
import * as PtyAdapter from "./PtyAdapter.ts";
export {
@@ -92,6 +94,7 @@ const DEFAULT_HISTORY_BYTE_LIMIT = 8 * 1024 * 1024;
const MAX_HISTORY_CHUNK_LENGTH = 16 * 1024;
const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;
+const MAX_SUBPROCESS_POLL_INTERVAL_MS = 60_000;
const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000;
const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128;
const DEFAULT_OPEN_COLS = 120;
@@ -106,7 +109,7 @@ class TerminalSubprocessCheckError extends Schema.TaggedErrorClass;
}
+export function subprocessSnapshotPollDelayMs(
+ pollIntervalMs: number,
+ failureCount: number,
+): number {
+ return Math.min(pollIntervalMs * 2 ** failureCount, MAX_SUBPROCESS_POLL_INTERVAL_MS);
+}
+
function parsePosixProcessTable(stdout: string): TerminalProcessTableSnapshot {
const childrenByParent = new Map();
const commandById = new Map();
@@ -660,15 +670,15 @@ function parsePosixProcessTable(stdout: string): TerminalProcessTableSnapshot {
return { childrenByParent, commandById };
}
-function parseWindowsProcessTable(stdout: string): TerminalProcessTableSnapshot {
+function processTableSnapshotFromProcesses(
+ processes: ReadonlyArray,
+): TerminalProcessTableSnapshot {
const childrenByParent = new Map();
const commandById = new Map();
- for (const line of stdout.split(/\r?\n/g)) {
- const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3);
- const pid = Number(pidRaw);
- const parentPid = Number(parentPidRaw);
+ for (const process of processes) {
+ const { pid, ppid: parentPid, name } = process;
if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue;
- commandById.set(pid, nameRaw?.trim() ?? "");
+ commandById.set(pid, name.trim());
const children = childrenByParent.get(parentPid) ?? [];
children.push(pid);
childrenByParent.set(parentPid, children);
@@ -763,14 +773,11 @@ const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnaps
TerminalSubprocessCheckError,
ProcessRunner.ProcessRunner
> {
+ const processRunner = yield* ProcessRunner.ProcessRunner;
const command =
'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }';
- const processRunner = yield* ProcessRunner.ProcessRunner;
const result = yield* processRunner
.run({
- // powershell.exe is a real executable — never spawn it through cmd.exe
- // shell mode, which would re-tokenize the `-Command` payload (pipes,
- // semicolons) before PowerShell ever sees it.
command: "powershell.exe",
args: ["-NoProfile", "-NonInteractive", "-Command", command],
timeout: "1500 millis",
@@ -780,16 +787,10 @@ const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnaps
})
.pipe(
Effect.mapError(
- (cause) =>
- new TerminalSubprocessCheckError({
- cause,
- command: "powershell",
- }),
+ (cause) => new TerminalSubprocessCheckError({ cause, command: "powershell" }),
),
);
if (result.code !== 0 || result.timedOut || result.stdoutTruncated) {
- // Not authoritative: an empty or partial table would mark every terminal
- // idle and clear its registered process ids. Failing skips the tick.
return yield* new TerminalSubprocessCheckError({
command: "powershell",
exitCode: result.code,
@@ -797,7 +798,15 @@ const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnaps
stdoutTruncated: result.stdoutTruncated,
});
}
- return parseWindowsProcessTable(result.stdout);
+ const processes = result.stdout.split(/\r?\n/g).flatMap((line) => {
+ const [pidRaw, ppidRaw, name = ""] = line.trim().split("|", 3);
+ const pid = Number(pidRaw);
+ const ppid = Number(ppidRaw);
+ return Number.isInteger(pid) && pid > 0 && Number.isInteger(ppid)
+ ? [{ pid, ppid, name }]
+ : [];
+ });
+ return processTableSnapshotFromProcesses(processes);
},
);
@@ -1310,6 +1319,10 @@ interface TerminalManagerOptions {
shellResolver?: () => string;
env?: NodeJS.ProcessEnv;
subprocessInspector?: TerminalSubprocessInspector;
+ processTable?: Effect.Effect<
+ ReadonlyArray,
+ TerminalSubprocessCheckError
+ >;
subprocessPollIntervalMs?: number;
processKillGraceMs?: number;
maxRetainedInactiveSessions?: number;
@@ -1376,6 +1389,7 @@ export const make = Effect.fn("TerminalManager.make")(function* () {
const { terminalLogsDir } = yield* ServerConfig.ServerConfig;
const ptyAdapter = yield* PtyAdapter.PtyAdapter;
const portDiscovery = yield* PortScanner.PortDiscovery;
+ const nativeTelemetry = yield* NativeTelemetryClient.NativeTelemetryClient;
const serverSettings = yield* ServerSettings.ServerSettingsService;
const path = yield* Path.Path;
const resolveProviderInstanceEnvironment = Effect.fn(
@@ -1391,6 +1405,11 @@ export const make = Effect.fn("TerminalManager.make")(function* () {
return yield* makeWithOptions({
logsDir: terminalLogsDir,
ptyAdapter,
+ processTable: nativeTelemetry.processTable.pipe(
+ Effect.mapError(
+ (cause) => new TerminalSubprocessCheckError({ cause, command: "resource-monitor" }),
+ ),
+ ),
registerTerminalProcesses: portDiscovery.registerTerminalProcesses,
unregisterTerminal: portDiscovery.unregisterTerminal,
resolveProviderInstanceEnvironment,
@@ -1437,23 +1456,60 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
// One process-table snapshot per poll tick, shared across every terminal.
// Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and
// can exhaust the PID space on hosts with many sessions (#6332).
- const fetchProcessTableSnapshot = (
+ const fallbackProcessTableSnapshot = (
platform === "win32"
? windowsProcessTableSnapshot()
: posixProcessTableSnapshot(yield* resolvePosixPsCommand())
).pipe(Effect.provideService(ProcessRunner.ProcessRunner, processRunner));
+ const fetchProcessTableSnapshot: Effect.Effect<
+ {
+ readonly snapshot: TerminalProcessTableSnapshot;
+ /**
+ * False when the sidecar snapshot failed and this table came from the
+ * spawned fallback. The data is still applied, but the tick counts as
+ * a failure so polling backs off instead of hot-looping the fallback.
+ */
+ readonly snapshotSucceeded: boolean;
+ },
+ TerminalSubprocessCheckError
+ > = options.processTable
+ ? options.processTable.pipe(
+ Effect.map((entries) => ({
+ snapshot: processTableSnapshotFromProcesses(entries),
+ snapshotSucceeded: true,
+ })),
+ Effect.catch(() =>
+ fallbackProcessTableSnapshot.pipe(
+ Effect.map((snapshot) => ({ snapshot, snapshotSucceeded: false })),
+ ),
+ ),
+ )
+ : fallbackProcessTableSnapshot.pipe(
+ Effect.map((snapshot) => ({ snapshot, snapshotSucceeded: true })),
+ );
const customSubprocessInspector = options.subprocessInspector;
const acquireSubprocessInspector: Effect.Effect<
- TerminalSubprocessInspector,
+ {
+ readonly inspector: TerminalSubprocessInspector;
+ readonly snapshotSucceeded: boolean;
+ },
TerminalSubprocessCheckError
> =
customSubprocessInspector !== undefined
- ? Effect.succeed(customSubprocessInspector)
+ ? Effect.succeed({ inspector: customSubprocessInspector, snapshotSucceeded: true })
: Effect.map(
fetchProcessTableSnapshot,
- (snapshot): TerminalSubprocessInspector =>
- (terminalPid) =>
+ ({
+ snapshot,
+ snapshotSucceeded,
+ }): {
+ readonly inspector: TerminalSubprocessInspector;
+ readonly snapshotSucceeded: boolean;
+ } => ({
+ inspector: (terminalPid) =>
Effect.succeed(deriveSubprocessInspectResult(snapshot, terminalPid, platform)),
+ snapshotSucceeded,
+ }),
);
const subprocessPollIntervalMs =
options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS;
@@ -2305,7 +2361,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
);
if (runningSessions.length === 0) {
- return;
+ return true;
}
const inspectorOption = yield* acquireSubprocessInspector.pipe(
@@ -2313,15 +2369,22 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
Effect.catch((reason) =>
Effect.logWarning("failed to snapshot processes for terminal subprocess polling", {
reason,
- }).pipe(Effect.as(Option.none())),
+ }).pipe(
+ Effect.as(
+ Option.none<{
+ readonly inspector: TerminalSubprocessInspector;
+ readonly snapshotSucceeded: boolean;
+ }>(),
+ ),
+ ),
),
);
if (Option.isNone(inspectorOption)) {
- return;
+ return false;
}
- const subprocessInspector = inspectorOption.value;
+ const { inspector: subprocessInspector, snapshotSucceeded } = inspectorOption.value;
const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* (
session: TerminalSessionState & { pid: number },
@@ -2390,6 +2453,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
concurrency: "unbounded",
discard: true,
});
+ return snapshotSucceeded;
});
const hasRunningSessions = readManagerState.pipe(
@@ -2398,14 +2462,26 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
),
);
+ let subprocessSnapshotFailureCount = 0;
yield* Effect.forever(
hasRunningSessions.pipe(
Effect.flatMap((active) =>
active
? pollSubprocessActivity().pipe(
- Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs)),
+ Effect.flatMap((snapshotSucceeded) => {
+ subprocessSnapshotFailureCount = snapshotSucceeded
+ ? 0
+ : Math.min(subprocessSnapshotFailureCount + 1, 30);
+ const delayMs = subprocessSnapshotPollDelayMs(
+ subprocessPollIntervalMs,
+ subprocessSnapshotFailureCount,
+ );
+ return Effect.sleep(delayMs);
+ }),
)
- : Effect.sleep(subprocessPollIntervalMs),
+ : Effect.sync(() => {
+ subprocessSnapshotFailureCount = 0;
+ }).pipe(Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs))),
),
),
).pipe(Effect.forkIn(workerScope));
diff --git a/native/resource-monitor/src/main.rs b/native/resource-monitor/src/main.rs
index 78ba5a271cd3..6137544ea0e2 100644
--- a/native/resource-monitor/src/main.rs
+++ b/native/resource-monitor/src/main.rs
@@ -8,7 +8,7 @@ use sysinfo::{
MINIMUM_CPU_UPDATE_INTERVAL, Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind,
};
-const PROTOCOL_VERSION: u32 = 2;
+const PROTOCOL_VERSION: u32 = 3;
const MIN_SAMPLE_INTERVAL_MS: u64 = 250;
const MAX_SAMPLE_INTERVAL_MS: u64 = 60_000;
const PROCESS_START_TIME_PRECISION_MS: u64 = 1_000;
@@ -66,6 +66,10 @@ enum Command {
version: u32,
request_id: String,
},
+ ProcessTable {
+ version: u32,
+ request_id: String,
+ },
ReadHistory {
version: u32,
request_id: String,
@@ -84,6 +88,7 @@ impl Command {
| Self::SetSampleInterval { version, .. }
| Self::SetStreaming { version, .. }
| Self::SampleNow { version, .. }
+ | Self::ProcessTable { version, .. }
| Self::ReadHistory { version, .. }
| Self::Shutdown { version } => *version,
}
@@ -146,6 +151,24 @@ struct ProcessSample {
io_semantics: IoSemantics,
}
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ProcessTableEntry {
+ pid: u32,
+ ppid: u32,
+ name: String,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ProcessTableEvent<'a> {
+ version: u32,
+ #[serde(rename = "type")]
+ event_type: &'static str,
+ request_id: &'a str,
+ processes: Vec,
+}
+
impl ProcessSample {
fn estimated_history_bytes(&self) -> usize {
std::mem::size_of::()
@@ -351,6 +374,41 @@ impl Collector {
self.cpu_baseline_refreshed_at = Some(Instant::now());
}
+ fn process_table(&self) -> Vec {
+ // Use a dedicated System so this refresh cannot reset the CPU
+ // baseline tracked by self.system for snapshots.
+ let mut process_table_system = System::new();
+ process_table_system.refresh_processes_specifics(
+ ProcessesToUpdate::All,
+ true,
+ ProcessRefreshKind::nothing().without_tasks(),
+ );
+ let mut processes = process_table_system
+ .processes()
+ .iter()
+ .filter_map(|(pid, process)| {
+ let pid = pid.as_u32();
+ // Pid 0 is the kernel idle process on some platforms. The
+ // processTable contract requires positive pids, and one zero
+ // would fail the whole event decode on the server, so drop it
+ // here. It can never be a terminal descendant.
+ if pid == 0 {
+ return None;
+ }
+ Some(ProcessTableEntry {
+ pid,
+ ppid: process.parent().map(Pid::as_u32).unwrap_or(0),
+ name: truncate_utf8(
+ process.name().to_string_lossy().into_owned(),
+ MAX_PROCESS_NAME_BYTES,
+ ),
+ })
+ })
+ .collect::>();
+ processes.sort_by_key(|process| process.pid);
+ processes
+ }
+
fn sample(&mut self, config: &CollectorConfig, request_id: Option) -> SnapshotEvent {
if let Some(delay) =
remaining_cpu_measurement_delay(self.cpu_baseline_refreshed_at.take(), Instant::now())
@@ -871,6 +929,15 @@ fn main() -> io::Result<()> {
)?;
}
}
+ Command::ProcessTable { request_id, .. } => {
+ let event = ProcessTableEvent {
+ version: PROTOCOL_VERSION,
+ event_type: "processTable",
+ request_id: &request_id,
+ processes: collector.process_table(),
+ };
+ write_event(&mut writer, &event)?;
+ }
Command::ReadHistory {
request_id,
window_ms,
@@ -981,7 +1048,7 @@ mod tests {
#[test]
fn decodes_protocol_commands() {
let configure = serde_json::from_str::(
- r#"{"version":2,"type":"configure","rootPid":42,"sampleIntervalMs":1000,"externalProcesses":[{"pid":7}]}"#,
+ r#"{"version":3,"type":"configure","rootPid":42,"sampleIntervalMs":1000,"externalProcesses":[{"pid":7}]}"#,
)
.expect("configure command");
@@ -1001,7 +1068,7 @@ mod tests {
}
let read_history = serde_json::from_str::(
- r#"{"version":2,"type":"readHistory","requestId":"history-1","windowMs":60000}"#,
+ r#"{"version":3,"type":"readHistory","requestId":"history-1","windowMs":60000}"#,
)
.expect("read history command");
assert!(matches!(
@@ -1012,6 +1079,15 @@ mod tests {
..
} if request_id == "history-1"
));
+
+ let process_table = serde_json::from_str::(
+ r#"{"version":3,"type":"processTable","requestId":"processes-1"}"#,
+ )
+ .expect("process table command");
+ assert!(matches!(
+ process_table,
+ Command::ProcessTable { request_id, .. } if request_id == "processes-1"
+ ));
}
#[test]
diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts
index 87b52993a56b..ee9d2b3ac258 100644
--- a/packages/contracts/src/resourceTelemetry.ts
+++ b/packages/contracts/src/resourceTelemetry.ts
@@ -4,7 +4,7 @@ import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchema
import { HostPowerSnapshot } from "./background.ts";
import { DesktopUpdateStateSchema } from "./ipc.ts";
-export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const;
+export const RESOURCE_MONITOR_PROTOCOL_VERSION = 3 as const;
/** Whole-host capacity, independent of T3's process diagnostics. */
export const HostResourcesSnapshot = Schema.Struct({
@@ -112,6 +112,13 @@ export const ResourceMonitorSampleNowCommand = Schema.Struct({
});
export type ResourceMonitorSampleNowCommand = typeof ResourceMonitorSampleNowCommand.Type;
+export const ResourceMonitorProcessTableCommand = Schema.Struct({
+ version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
+ type: Schema.Literal("processTable"),
+ requestId: TrimmedNonEmptyString,
+});
+export type ResourceMonitorProcessTableCommand = typeof ResourceMonitorProcessTableCommand.Type;
+
export const ResourceMonitorSetSampleIntervalCommand = Schema.Struct({
version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
type: Schema.Literal("setSampleInterval"),
@@ -147,6 +154,7 @@ export const ResourceMonitorCommand = Schema.Union([
ResourceMonitorSetSampleIntervalCommand,
ResourceMonitorSetStreamingCommand,
ResourceMonitorSampleNowCommand,
+ ResourceMonitorProcessTableCommand,
ResourceMonitorReadHistoryCommand,
ResourceMonitorShutdownCommand,
]);
@@ -178,6 +186,21 @@ export const ResourceMonitorSnapshotEvent = Schema.Struct({
});
export type ResourceMonitorSnapshotEvent = typeof ResourceMonitorSnapshotEvent.Type;
+export const ResourceMonitorProcessTableEntry = Schema.Struct({
+ pid: PositiveInt,
+ ppid: NonNegativeInt,
+ name: Schema.String,
+});
+export type ResourceMonitorProcessTableEntry = typeof ResourceMonitorProcessTableEntry.Type;
+
+export const ResourceMonitorProcessTableEvent = Schema.Struct({
+ version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
+ type: Schema.Literal("processTable"),
+ requestId: TrimmedNonEmptyString,
+ processes: Schema.Array(ResourceMonitorProcessTableEntry),
+});
+export type ResourceMonitorProcessTableEvent = typeof ResourceMonitorProcessTableEvent.Type;
+
export const ResourceMonitorHistoryChunkEvent = Schema.Struct({
version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
type: Schema.Literal("historyChunk"),
@@ -199,6 +222,7 @@ export type ResourceMonitorErrorEvent = typeof ResourceMonitorErrorEvent.Type;
export const ResourceMonitorEvent = Schema.Union([
ResourceMonitorHelloEvent,
ResourceMonitorSnapshotEvent,
+ ResourceMonitorProcessTableEvent,
ResourceMonitorHistoryChunkEvent,
ResourceMonitorErrorEvent,
]);
From c2c4185e175daea86f8fd6336fd8839a81cc616e Mon Sep 17 00:00:00 2001
From: Theo Browne
Date: Sun, 6 Sep 2026 14:59:11 -0700
Subject: [PATCH 13/71] fix(web): onboarding installs agents without needing
Node or npm (#10402)
Co-authored-by: Claude Fable 5.1
---
.../src/provider/providerMaintenance.test.ts | 37 +++++++++++++++++++
.../components/onboarding/WelcomeWizard.tsx | 20 +++++-----
.../providerReadiness.logic.test.ts | 21 +++++++++++
.../src/onboarding/providerReadiness.logic.ts | 30 +++++++++++++++
docs/user/welcome-wizard.md | 4 +-
5 files changed, 101 insertions(+), 11 deletions(-)
diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts
index 2ceaf21996bf..3e0810f51b7a 100644
--- a/apps/server/src/provider/providerMaintenance.test.ts
+++ b/apps/server/src/provider/providerMaintenance.test.ts
@@ -25,6 +25,7 @@ import {
parseHomebrewLatestVersion,
ProviderVersionCache,
resolveLatestProviderVersion,
+ resolvePackageManagedProviderMaintenance,
resolveProviderMaintenanceCapabilitiesEffect,
type ProviderMaintenanceCapabilities,
} from "./providerMaintenance.ts";
@@ -310,6 +311,42 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
).toBeNull();
});
+ // The Codex Windows installer exposes `%LOCALAPPDATA%\\Programs\\OpenAI\\Codex\\bin`
+ // as a junction into `%CODEX_HOME%\\packages\\standalone\\current\\bin`. Node's
+ // realpath follows junctions, so the real path carries the standalone marker
+ // even though the visible path does not.
+ it.effect("recognizes a Windows standalone install through its junctioned bin dir", () =>
+ Effect.gen(function* () {
+ const visiblePath =
+ "C:\\Users\\Theo\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin\\codex.exe";
+ const realPath =
+ "C:\\Users\\Theo\\.codex\\packages\\standalone\\releases\\0.120.0-x86_64\\bin\\codex.exe";
+ const capabilities = yield* resolvePackageManagedProviderMaintenance(
+ {
+ provider: driver("codex"),
+ npmPackageName: "@openai/codex",
+ nativeUpdate: {
+ args: ["update"],
+ isCommandPath: isNativeTestCommandPath("/packages/standalone/"),
+ },
+ },
+ {
+ binaryPath: "codex",
+ resolvedCommandPath: visiblePath,
+ realCommandPath: realPath,
+ env: {},
+ platform: "win32",
+ },
+ ).pipe(Effect.provideService(HostProcessPlatform, "win32"));
+
+ expect(capabilities.update).toMatchObject({
+ executable: visiblePath,
+ args: ["update"],
+ lockKey: "codex-native",
+ });
+ }),
+ );
+
it.effect("proves Windows npm ownership from the package manifest beside the shim", () =>
Effect.gen(function* () {
const tempDir = yield* makeTempDir("t3-npm-windows-capabilities");
diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx
index 62665aa86ebf..9de7d00c60ca 100644
--- a/apps/web/src/components/onboarding/WelcomeWizard.tsx
+++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx
@@ -42,6 +42,7 @@ import {
} from "../../onboarding/projectImport.logic";
import {
getOnboardingProviderState,
+ resolveOnboardingProviderInstallCommand,
resolveOnboardingProviderLoginCommand,
selectOnboardingProvidersByDriver,
} from "../../onboarding/providerReadiness.logic";
@@ -641,11 +642,6 @@ function PairDirectStep({
const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex"] as const;
type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number];
-const AGENT_INSTALL_COMMANDS: Record = {
- claudeAgent: "npm install -g @anthropic-ai/claude-code",
- codex: "npm install -g @openai/codex",
-};
-
/** Setup values stay fixed while provider probes refresh the surrounding cards. */
interface AgentTerminalSession {
readonly environmentId: EnvironmentId;
@@ -657,10 +653,11 @@ interface AgentTerminalSession {
}
/**
- * Claude Code and Codex use live probe status. Install opens the built-in terminal inline
- * with the command pre-typed — the update RPC can't install a binary that
- * isn't there yet (it infers the package manager from the installed binary's
- * path), and the terminal also handles the interactive login that follows.
+ * Claude Code and Codex use live probe status. Install opens the built-in
+ * terminal inline with the vendor's standalone installer pre-typed. The update
+ * RPC can't install a binary that isn't there yet (it infers the installer from
+ * the installed binary's path), and the terminal also handles the interactive
+ * login that follows.
*/
function AgentsStep({
mode,
@@ -761,7 +758,10 @@ function ConnectedAgentsStep({
serverConfig.settings,
serverConfig.environment.platform.os,
)
- : AGENT_INSTALL_COMMANDS[driver],
+ : resolveOnboardingProviderInstallCommand(
+ driver,
+ serverConfig.environment.platform.os,
+ ),
keybindings: serverConfig.keybindings,
});
}}
diff --git a/apps/web/src/onboarding/providerReadiness.logic.test.ts b/apps/web/src/onboarding/providerReadiness.logic.test.ts
index ab742ac51b44..b0b3a4d57515 100644
--- a/apps/web/src/onboarding/providerReadiness.logic.test.ts
+++ b/apps/web/src/onboarding/providerReadiness.logic.test.ts
@@ -8,6 +8,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
getOnboardingProviderState,
+ resolveOnboardingProviderInstallCommand,
resolveOnboardingProviderLoginCommand,
selectOnboardingProvidersByDriver,
} from "./providerReadiness.logic";
@@ -315,3 +316,23 @@ describe("resolveOnboardingProviderLoginCommand", () => {
).toBe("codex login");
});
});
+
+describe("resolveOnboardingProviderInstallCommand", () => {
+ it("uses the PowerShell installer on Windows environments", () => {
+ expect(resolveOnboardingProviderInstallCommand("codex", "windows")).toBe(
+ "irm https://chatgpt.com/codex/install.ps1 | iex",
+ );
+ expect(resolveOnboardingProviderInstallCommand("claudeAgent", "windows")).toBe(
+ "irm https://claude.ai/install.ps1 | iex",
+ );
+ });
+
+ it.each(["darwin", "linux", "unknown"] as const)("uses the shell installer on %s", (platform) => {
+ expect(resolveOnboardingProviderInstallCommand("codex", platform)).toBe(
+ "curl -fsSL https://chatgpt.com/codex/install.sh | sh",
+ );
+ expect(resolveOnboardingProviderInstallCommand("claudeAgent", platform)).toBe(
+ "curl -fsSL https://claude.ai/install.sh | bash",
+ );
+ });
+});
diff --git a/apps/web/src/onboarding/providerReadiness.logic.ts b/apps/web/src/onboarding/providerReadiness.logic.ts
index 939b4c64cd64..c9d0f910ef53 100644
--- a/apps/web/src/onboarding/providerReadiness.logic.ts
+++ b/apps/web/src/onboarding/providerReadiness.logic.ts
@@ -71,6 +71,36 @@ export function selectOnboardingProvidersByDriver(
return providersByDriver;
}
+/**
+ * Official standalone installers. Neither needs Node or npm, and both land in
+ * the paths the server's provider maintenance recognizes as native, so the
+ * one-click updater in Settings keeps working after install.
+ */
+const NATIVE_INSTALL_COMMANDS = {
+ claudeAgent: {
+ windows: "irm https://claude.ai/install.ps1 | iex",
+ posix: "curl -fsSL https://claude.ai/install.sh | bash",
+ },
+ codex: {
+ windows: "irm https://chatgpt.com/codex/install.ps1 | iex",
+ posix: "curl -fsSL https://chatgpt.com/codex/install.sh | sh",
+ },
+} as const;
+
+/**
+ * Install command for the setup terminal, keyed on the environment's platform
+ * (not the client's): a Windows desktop driving a WSL server gets the shell
+ * script. Unknown platforms get the shell script too, since the terminal there
+ * is a POSIX shell in practice.
+ */
+export function resolveOnboardingProviderInstallCommand(
+ driver: keyof typeof NATIVE_INSTALL_COMMANDS,
+ platform: ExecutionEnvironmentPlatformOs,
+): string {
+ const commands = NATIVE_INSTALL_COMMANDS[driver];
+ return platform === "windows" ? commands.windows : commands.posix;
+}
+
/** Use the selected provider instance's binary when the setup terminal opens its login flow. */
export function resolveOnboardingProviderLoginCommand(
provider: ServerProvider,
diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md
index 9a2aa116670d..2534c118eddb 100644
--- a/docs/user/welcome-wizard.md
+++ b/docs/user/welcome-wizard.md
@@ -26,7 +26,9 @@ unreadable settings with defaults.
T3 Code checks the selected computer for Claude Code and Codex. If an agent is
not installed or signed in, select its action to open a terminal with the
-correct command ready to run. Other providers can be enabled in Settings.
+correct command ready to run. Install uses the vendor's standalone installer,
+which does not need Node or npm and keeps **Update now** working in Settings.
+Other providers can be enabled in Settings.
The setup terminal uses the home directory and environment configured for the
selected provider instance. Sensitive values remain redacted in Settings and
From 7ac93e300ee17a4ee5e92192264fcfd438805b6f Mon Sep 17 00:00:00 2001
From: Theo Browne
Date: Sun, 6 Sep 2026 15:01:54 -0700
Subject: [PATCH 14/71] fix(server): allow settling threads with unanswered
async questions (#10400)
---
.../src/orchestration/decider.settled.test.ts | 106 ++++++++++++++++++
apps/server/src/orchestration/decider.ts | 57 +++++++---
docs/user/thread-sidebar.md | 2 +
3 files changed, 151 insertions(+), 14 deletions(-)
diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts
index ebca8434c34b..abc5cff37e3f 100644
--- a/apps/server/src/orchestration/decider.settled.test.ts
+++ b/apps/server/src/orchestration/decider.settled.test.ts
@@ -322,6 +322,112 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
}),
);
+ it.effect("manual settlement dismisses async questions without starting a turn", () =>
+ Effect.gen(function* () {
+ const question = (requestId: string): OrchestrationThread["activities"][number] => ({
+ id: EventId.make(requestId),
+ kind: "user-input.requested",
+ summary: "Question",
+ tone: "approval",
+ turnId: null,
+ createdAt: "1969-12-31T00:00:00.000Z",
+ payload: { requestId, responseMode: "message" },
+ });
+ const readModel = makeReadModel(null, null, makeSession("ready"), [
+ question("first"),
+ question("second"),
+ question("answered"),
+ {
+ ...question("answered"),
+ id: EventId.make("answer"),
+ createdAt: "1969-12-31T01:00:00.000Z",
+ kind: "user-input.resolved",
+ },
+ ]);
+ const command = {
+ type: "thread.settle" as const,
+ commandId: CommandId.make("settle-async"),
+ threadId: ThreadId.make("thread-1"),
+ };
+ const result = yield* decideOrchestrationCommand({ command, readModel });
+ const events = Array.isArray(result) ? result : [result];
+ expect(events.map((event) => event.type)).toEqual([
+ "thread.settled",
+ "thread.activity-appended",
+ "thread.activity-appended",
+ ]);
+ expect(events.slice(1).map((event) => event.payload)).toEqual(
+ ["first", "second"].map((requestId) => ({
+ threadId: command.threadId,
+ activity: expect.objectContaining({
+ kind: "user-input.resolved",
+ summary: "User input dismissed",
+ payload: { requestId, responseMode: "message" },
+ }),
+ })),
+ );
+ let projected = readModel;
+ for (const [index, event] of events.entries()) {
+ projected = yield* projectEvent(projected, { ...event, sequence: index + 1 });
+ }
+ expect(projected.threads[0]?.settledOverride).toBe("settled");
+ expect(projected.threads[0]?.messages).toEqual([]);
+ const repeated = yield* decideOrchestrationCommand({ command, readModel: projected });
+ expect(repeated).toMatchObject({ type: "thread.settled" });
+ }),
+ );
+
+ it.effect("async questions do not bypass automatic settlement or other blockers", () =>
+ Effect.gen(function* () {
+ const question: OrchestrationThread["activities"][number] = {
+ id: EventId.make("async-question"),
+ kind: "user-input.requested",
+ summary: "Question",
+ tone: "approval",
+ turnId: null,
+ createdAt: NOW,
+ payload: { requestId: "async-question", responseMode: "message" },
+ };
+ for (const blocker of ["auto", "running", "starting", "approval", "native"] as const) {
+ const error = yield* decideOrchestrationCommand({
+ command:
+ blocker === "auto"
+ ? {
+ type: "thread.auto-settle",
+ commandId: CommandId.make(`settle-${blocker}`),
+ threadId: ThreadId.make("thread-1"),
+ snapshotSequence: 0,
+ settledAt: NOW,
+ }
+ : {
+ type: "thread.settle",
+ commandId: CommandId.make(`settle-${blocker}`),
+ threadId: ThreadId.make("thread-1"),
+ },
+ readModel: makeReadModel(
+ null,
+ null,
+ makeSession(blocker === "running" || blocker === "starting" ? blocker : "ready"),
+ [
+ question,
+ ...(blocker === "approval" || blocker === "native"
+ ? [
+ {
+ ...question,
+ id: EventId.make("blocking-request"),
+ kind: blocker === "approval" ? "approval.requested" : "user-input.requested",
+ payload: { requestId: "blocking-request" },
+ },
+ ]
+ : []),
+ ],
+ ),
+ }).pipe(Effect.flip);
+ expect(error).toMatchObject({ _tag: "OrchestrationThreadSettleBlockedError" });
+ }
+ }),
+ );
+
it.effect("clears an open request when its respond failure marks it stale", () =>
Effect.gen(function* () {
const activity = (
diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts
index 9dc55e1194cc..9762d9bfd6da 100644
--- a/apps/server/src/orchestration/decider.ts
+++ b/apps/server/src/orchestration/decider.ts
@@ -68,10 +68,8 @@ function isStaleRequestFailureDetail(payload: Record | null): b
// Scans the read model's activities, which the projector caps at the most
// recent 500 plus pending async questions. Async questions remain actionable
// while the agent works, so they must not expire with the activity window.
-function hasOpenBlockingRequest(thread: {
- readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>;
-}): boolean {
- const openRequestIds = new Set();
+function openRequests(thread: Pick) {
+ const requests = new Map();
for (const activity of thread.activities) {
const payload =
typeof activity.payload === "object" && activity.payload !== null
@@ -80,18 +78,18 @@ function hasOpenBlockingRequest(thread: {
const requestId = typeof payload?.requestId === "string" ? payload.requestId : null;
if (requestId === null) continue;
if (activity.kind === "approval.requested" || activity.kind === "user-input.requested") {
- openRequestIds.add(requestId);
+ requests.set(requestId, activity);
} else if (activity.kind === "approval.resolved" || activity.kind === "user-input.resolved") {
- openRequestIds.delete(requestId);
+ requests.delete(requestId);
} else if (
(activity.kind === "provider.approval.respond.failed" ||
activity.kind === "provider.user-input.respond.failed") &&
isStaleRequestFailureDetail(payload)
) {
- openRequestIds.delete(requestId);
+ requests.delete(requestId);
}
}
- return openRequestIds.size > 0;
+ return requests;
}
/** Apply the shared shell-level rule to the detailed command read model. */
@@ -456,10 +454,18 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
if (thread.session?.status === "starting" || thread.session?.status === "running") {
return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId });
}
- // Pending approval / user-input requests are blocked-on-you work: a
- // raced or stale client must not park them behind a settled override
- // that would surface only after the request resolves.
- if (hasOpenBlockingRequest(thread)) {
+ const pendingRequests = openRequests(thread);
+ // Manual settlement dismisses async questions without answering them.
+ // Native callbacks and approvals still need a response or interruption.
+ if (
+ Array.from(pendingRequests.values()).some(
+ (activity) =>
+ command.type === "thread.auto-settle" ||
+ activity.kind !== "user-input.requested" ||
+ !Predicate.isObject(activity.payload) ||
+ activity.payload.responseMode !== "message",
+ )
+ ) {
return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId });
}
const occurredAt = yield* nowIso;
@@ -495,6 +501,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
// Settling is "I'm done with this": clear states that would keep the
// row pinned or snoozed instead of showing the new settled state.
const companionEvents: Array> = [];
+ for (const [requestId, request] of pendingRequests) {
+ companionEvents.push({
+ ...(yield* withEventBase({
+ aggregateKind: "thread",
+ aggregateId: command.threadId,
+ occurredAt,
+ commandId: command.commandId,
+ })),
+ type: "thread.activity-appended",
+ payload: {
+ threadId: command.threadId,
+ activity: {
+ id: EventId.make(`settle:${command.commandId}:${requestId}`),
+ kind: "user-input.resolved",
+ summary: "User input dismissed",
+ tone: "info",
+ turnId: request.turnId,
+ createdAt: occurredAt,
+ payload: { requestId, responseMode: "message" },
+ },
+ },
+ });
+ }
if (thread.pinnedAt != null) {
companionEvents.push({
...(yield* withEventBase({
@@ -581,7 +610,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
// user-input request is the agent waiting on the user, and hiding it
// defeats the request. (A running session IS snoozable — snooze only
// affects visibility, never the agent.)
- if (hasOpenBlockingRequest(thread)) {
+ if (openRequests(thread).size > 0) {
return yield* Effect.fail(
new OrchestrationCommandInvariantError({
commandType: command.type,
@@ -1453,7 +1482,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
thread.messages.length > 0 ||
thread.latestTurn !== null ||
thread.session !== null ||
- hasOpenBlockingRequest(thread)
+ openRequests(thread).size > 0
) {
return yield* new OrchestrationCommandInvariantError({
commandType: command.type,
diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md
index 4286ed654d81..f20d5acbb179 100644
--- a/docs/user/thread-sidebar.md
+++ b/docs/user/thread-sidebar.md
@@ -63,6 +63,8 @@ their default order until the server is updated.
Choose **Settle thread** from its menu to move finished work out of the active list
without deleting the conversation. **Un-settle thread** restores it to active work
and prevents automatic settlement until new activity resumes the usual rules.
+Manually settling an idle thread dismisses unanswered async questions without
+sending an answer or restarting the agent.
By default, environments settle inactive threads after three days and settle
threads whose pull request merged. A closed pull request can also settle an idle
From 001f06d543beb92cc66bb7e8d54d233a20d802b4 Mon Sep 17 00:00:00 2001
From: Theo Browne
Date: Sun, 6 Sep 2026 15:27:21 -0700
Subject: [PATCH 15/71] feat(ci): ship stable releases from the latest nightly
commit (#10410)
Co-authored-by: Claude Fable 5.1
---
.github/scripts/check-nightly-release.cjs | 43 +++++++---
.../scripts/check-nightly-release.test.cjs | 42 ++++++++++
.github/workflows/release.yml | 81 ++++++++++++-------
docs/operations/release.md | 31 ++++---
4 files changed, 147 insertions(+), 50 deletions(-)
diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs
index dc4b55bc6517..82ee40da1aff 100644
--- a/.github/scripts/check-nightly-release.cjs
+++ b/.github/scripts/check-nightly-release.cjs
@@ -1,19 +1,21 @@
const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000;
-// Runs after the workflow acquires the nightly concurrency lock.
-async function shouldReleaseNightly({ github, context, core, now = Date.now() }) {
+const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v");
+
+// Newest published nightly by publication time, or undefined when none exists.
+async function findLatestNightly({ github, context }) {
const releases = await github.paginate(github.rest.repos.listReleases, {
...context.repo,
per_page: 100,
});
- const lastNightly = releases
- .filter(
- (release) =>
- !release.draft &&
- release.published_at &&
- (/^v.*-nightly\./.test(release.tag_name) || release.tag_name.startsWith("nightly-v")),
- )
+ return releases
+ .filter((release) => !release.draft && release.published_at && isNightlyTag(release.tag_name))
.sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
+}
+
+// Runs after the workflow acquires the nightly concurrency lock.
+async function shouldReleaseNightly({ github, context, core, now = Date.now() }) {
+ const lastNightly = await findLatestNightly({ github, context });
if (!lastNightly) {
core.info("No published nightly found. Proceeding with release.");
@@ -41,4 +43,25 @@ async function shouldReleaseNightly({ github, context, core, now = Date.now() })
return true;
}
-module.exports = { shouldReleaseNightly };
+// Stable releases build the commit the latest nightly shipped, so the stable
+// build is one nightly users already ran. Returns the nightly tag, its commit,
+// and the stable version that nightly was a preview of.
+async function resolveLatestNightlyCommit({ github, context, core }) {
+ const lastNightly = await findLatestNightly({ github, context });
+ if (!lastNightly) {
+ throw new Error("No published nightly found. Stable releases build the latest nightly commit.");
+ }
+
+ const tag = lastNightly.tag_name;
+ // repos.getCommit dereferences annotated tags, so this is the commit either way.
+ const { data: commit } = await github.rest.repos.getCommit({ ...context.repo, ref: tag });
+ const version = /^(?:nightly-)?v(\d+\.\d+\.\d+)-nightly\./.exec(tag)?.[1];
+ if (!version) {
+ throw new Error(`Cannot derive a stable version from nightly tag ${tag}.`);
+ }
+
+ core.info(`Latest nightly ${tag} shipped ${commit.sha} as a preview of ${version}.`);
+ return { tag, sha: commit.sha, version };
+}
+
+module.exports = { shouldReleaseNightly, resolveLatestNightlyCommit };
diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs
index 476773bc4e5a..49ade68aeef7 100644
--- a/.github/scripts/check-nightly-release.test.cjs
+++ b/.github/scripts/check-nightly-release.test.cjs
@@ -99,3 +99,45 @@ for (const status of ["behind", "diverged"]) {
assert.equal(await shouldReleaseNightly(options), false);
});
}
+
+const { resolveLatestNightlyCommit } = require("./check-nightly-release.cjs");
+
+function nightlyCommitFixture({ releases, commitSha = "abc123" }) {
+ const refs = [];
+ const { options } = fixture({ releases });
+ options.github.rest.repos.getCommit = async ({ ref }) => {
+ refs.push(ref);
+ return { data: { sha: commitSha } };
+ };
+ return { options, refs };
+}
+
+test("stable releases resolve the commit of the newest published nightly", async () => {
+ const { options, refs } = nightlyCommitFixture({
+ releases: [
+ nightly(10, { tag_name: "v1.0.1-nightly.20260905.100" }),
+ nightly(1, { tag_name: "v1.0.1-nightly.20260905.123" }),
+ nightly(0, { tag_name: "v1.0.0" }),
+ nightly(0, { draft: true, tag_name: "v1.0.1-nightly.20260905.999" }),
+ ],
+ commitSha: "deadbeef",
+ });
+ assert.deepEqual(await resolveLatestNightlyCommit(options), {
+ tag: "v1.0.1-nightly.20260905.123",
+ sha: "deadbeef",
+ version: "1.0.1",
+ });
+ assert.deepEqual(refs, ["v1.0.1-nightly.20260905.123"]);
+});
+
+test("stable releases derive the version from legacy nightly tags", async () => {
+ const { options } = nightlyCommitFixture({
+ releases: [nightly(1, { tag_name: "nightly-v0.9.0-nightly.20260905.5" })],
+ });
+ assert.equal((await resolveLatestNightlyCommit(options)).version, "0.9.0");
+});
+
+test("stable releases fail without a published nightly", async () => {
+ const { options } = nightlyCommitFixture({ releases: [nightly(0, { tag_name: "v1.0.0" })] });
+ await assert.rejects(resolveLatestNightlyCommit(options), /No published nightly/);
+});
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 48cd451e3fea..882065118478 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,7 +19,7 @@ on:
- stable
- nightly
version:
- description: "Release version (for example 1.2.3 or v1.2.3)"
+ description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed."
required: false
type: string
@@ -39,33 +39,54 @@ permissions:
id-token: none
jobs:
- check_changes:
- name: Check automatic nightly release
- if: github.event_name == 'schedule'
+ # Picks the commit every later job builds. Nightlies and tag pushes build the
+ # triggering commit. Manual stable releases build the commit of the latest
+ # published nightly, so stable only ever ships a build that nightly users
+ # have already run. Scheduled runs also decide here whether a nightly is due.
+ resolve_commit:
+ name: Resolve release commit
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 5
outputs:
- has_changes: ${{ steps.check.outputs.result }}
+ ref: ${{ steps.resolve.outputs.ref }}
+ nightly_version: ${{ steps.resolve.outputs.nightly_version }}
+ has_changes: ${{ steps.resolve.outputs.has_changes }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
sparse-checkout: .github/scripts
- - id: check
- name: Check release gap and new commits
+ - id: resolve
+ name: Resolve release commit
uses: actions/github-script@v8
+ env:
+ DISPATCH_CHANNEL: ${{ inputs.channel }}
with:
script: |
- const { shouldReleaseNightly } = require('./.github/scripts/check-nightly-release.cjs');
- return await shouldReleaseNightly({ github, context, core });
+ const {
+ shouldReleaseNightly,
+ resolveLatestNightlyCommit,
+ } = require('./.github/scripts/check-nightly-release.cjs');
+
+ if (context.eventName === 'schedule') {
+ core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core }));
+ core.setOutput('ref', context.sha);
+ } else if (context.eventName === 'workflow_dispatch' && process.env.DISPATCH_CHANNEL !== 'nightly') {
+ const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core });
+ core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`);
+ core.setOutput('ref', sha);
+ core.setOutput('nightly_version', version);
+ } else {
+ core.setOutput('ref', context.sha);
+ }
preflight:
name: Preflight
- needs: [check_changes]
+ needs: [resolve_commit]
if: |
- !failure() && !cancelled() &&
- (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true')
+ needs.resolve_commit.result == 'success' &&
+ (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true')
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 10
outputs:
@@ -78,11 +99,12 @@ jobs:
cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }}
is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }}
make_latest: ${{ steps.release_meta.outputs.make_latest }}
- ref: ${{ github.sha }}
+ ref: ${{ needs.resolve_commit.outputs.ref }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
+ ref: ${{ needs.resolve_commit.outputs.ref }}
fetch-depth: 0
sparse-checkout: |
/*
@@ -104,8 +126,9 @@ jobs:
env:
DISPATCH_CHANNEL: ${{ github.event.inputs.channel }}
DISPATCH_VERSION: ${{ github.event.inputs.version }}
+ NIGHTLY_VERSION: ${{ needs.resolve_commit.outputs.nightly_version }}
NIGHTLY_DATE: ${{ github.run_started_at }}
- NIGHTLY_SHA: ${{ github.sha }}
+ NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }}
NIGHTLY_RUN_NUMBER: ${{ github.run_number }}
run: |
if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then
@@ -123,9 +146,9 @@ jobs:
echo "make_latest=false" >> "$GITHUB_OUTPUT"
else
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
- raw="${DISPATCH_VERSION}"
+ raw="${DISPATCH_VERSION:-$NIGHTLY_VERSION}"
if [[ -z "$raw" ]]; then
- echo "workflow_dispatch stable releases require the version input." >&2
+ echo "workflow_dispatch stable releases need a version input or a published nightly." >&2
exit 1
fi
else
@@ -210,14 +233,12 @@ jobs:
relay_public_config:
name: Resolve T3 Connect public config
- # Consumes only the commit SHA, not preflight's resolved version, so it runs
- # alongside preflight instead of after it. The condition mirrors preflight's:
- # check_changes is skipped on manual and tag releases (skipped is neither failure
- # nor success, so success() would be wrong here).
- needs: [check_changes]
+ # Consumes only the release commit, not preflight's resolved version, so it
+ # runs alongside preflight instead of after it. The condition mirrors preflight's.
+ needs: [resolve_commit]
if: |
- !failure() && !cancelled() &&
- (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true')
+ needs.resolve_commit.result == 'success' &&
+ (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true')
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 5
environment:
@@ -239,7 +260,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
- ref: ${{ github.sha }}
+ ref: ${{ needs.resolve_commit.outputs.ref }}
sparse-checkout: |
/*
!/.repos/
@@ -312,19 +333,19 @@ jobs:
# machine. node-pty is N-API, so one binary works across all WSL Node versions.
build_wsl_node_pty:
name: Build WSL node-pty (linux-x64)
- # Same gating as relay_public_config: only the commit SHA is needed, so this
- # runs alongside preflight. See the condition comment there.
- needs: [check_changes]
+ # Same gating as relay_public_config: only the release commit is needed, so
+ # this runs alongside preflight. See the condition comment there.
+ needs: [resolve_commit]
if: |
- !failure() && !cancelled() &&
- (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true')
+ needs.resolve_commit.result == 'success' &&
+ (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true')
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
with:
- ref: ${{ github.sha }}
+ ref: ${{ needs.resolve_commit.outputs.ref }}
sparse-checkout: |
/*
!/.repos/
diff --git a/docs/operations/release.md b/docs/operations/release.md
index 4217c76f1f1e..5097457ae0af 100644
--- a/docs/operations/release.md
+++ b/docs/operations/release.md
@@ -8,9 +8,18 @@ This document covers the unified release workflow for stable and nightly desktop
- Workflow: `.github/workflows/release.yml`
- Triggers:
- - push tag matching `v*.*.*` for stable releases
+ - manual `workflow_dispatch` with `channel=stable`, the normal way to ship stable
+ - push tag matching `v*.*.*` for a stable release of an explicit commit
- scheduled nightly check every 30 minutes
- - manual `workflow_dispatch` for either channel
+ - manual `workflow_dispatch` with `channel=nightly`
+- A manual stable release builds the commit of the latest published nightly, not `main` HEAD.
+ Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep
+ landing while you verify and never leak into the stable build.
+ - The version defaults to the one the nightly previewed (`0.0.39-nightly.*` ships as `0.0.39`).
+ Pass the `version` input to override it, for example for a minor bump.
+ - The stable tag is created on the nightly's commit when the GitHub Release is published.
+ - Pushing a `vX.Y.Z` tag by hand still works and builds exactly the tagged commit. Use it when
+ the commit to ship is not the latest nightly, such as a cherry-picked fix on a release branch.
- Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check.
- Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients.
- Builds four artifacts in parallel for both channels:
@@ -291,8 +300,8 @@ risk, manually dispatch `channel=nightly`; this still publishes a real nightly n
prerelease, desktop updater release, and hosted nightly alias, but it does not update stable aliases or
commit a version bump to `main`. Only run it when a real nightly release is acceptable.
-Manual `channel=stable` with a version input is also a real stable-channel release. Omitting signing
-secrets only makes platform artifacts unsigned; it does not prevent publication.
+Manual `channel=stable` is also a real stable-channel release. Omitting signing secrets only makes
+platform artifacts unsigned; it does not prevent publication.
## 2) Apple signing + notarization setup (macOS)
@@ -370,17 +379,19 @@ Checklist:
## 4) Ongoing release checklist
-1. Ensure `main` is green in CI.
-2. Bump app version as needed.
-3. Create release tag: `vX.Y.Z`.
-4. Push tag.
-5. Verify workflow steps:
+1. Pick the latest nightly and verify it: run the smoke test above against its artifacts and
+ check the nightly channel for regressions.
+2. Dispatch the Release workflow with `channel=stable`. Leave `version` empty unless the version
+ should differ from the one the nightly previewed.
+3. Confirm the `Resolve release commit` notice names the nightly tag and commit you verified. If a
+ newer nightly published in between, the run builds that one instead.
+4. Verify workflow steps:
- preflight passes
- release quality checks pass
- all matrix builds pass
- `publish_cli` publishes the exact release version before the release job
- release job uploads expected files
-6. Smoke test downloaded artifacts.
+5. Smoke test downloaded artifacts.
## 5) Troubleshooting
From 075a86e3b0152ea8f867ddd2c424739f5da08238 Mon Sep 17 00:00:00 2001
From: Theo Browne
Date: Sun, 6 Sep 2026 15:29:20 -0700
Subject: [PATCH 16/71] feat(marketing): add a nightly channel to the download
page (#10408)
Co-authored-by: Claude Fable 5.1
---
apps/marketing/public/nightly-sky.svg | 44 ++
apps/marketing/src/assets/icon-nightly.webp | Bin 0 -> 31614 bytes
apps/marketing/src/layouts/Layout.astro | 14 +
apps/marketing/src/lib/releases.ts | 37 +-
apps/marketing/src/pages/download.astro | 552 +++++++++++++++-----
5 files changed, 496 insertions(+), 151 deletions(-)
create mode 100644 apps/marketing/public/nightly-sky.svg
create mode 100644 apps/marketing/src/assets/icon-nightly.webp
diff --git a/apps/marketing/public/nightly-sky.svg b/apps/marketing/public/nightly-sky.svg
new file mode 100644
index 000000000000..3165b211b287
--- /dev/null
+++ b/apps/marketing/public/nightly-sky.svg
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/marketing/src/assets/icon-nightly.webp b/apps/marketing/src/assets/icon-nightly.webp
new file mode 100644
index 0000000000000000000000000000000000000000..8a00067e43b81b5be5b07672f302e24cbf8a9cb9
GIT binary patch
literal 31614
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