Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
import {
resolveThreadListV2ChangeRequestState,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
resolveThreadListV2Status,
Expand Down Expand Up @@ -411,11 +412,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const prUpdatedAt = pr?.updatedAt ?? null;
const threadKey = `${thread.environmentId}:${thread.id}`;
useEffect(() => {
onChangeRequestState?.(
threadKey,
prState === null ? null : { state: prState, updatedAt: prUpdatedAt },
);
}, [onChangeRequestState, prState, prUpdatedAt, threadKey]);
const changeRequest = resolveThreadListV2ChangeRequestState({
hasLinkedPullRequest: thread.linkedPullRequest != null,
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
state: prState,
updatedAt: prUpdatedAt,
});
if (changeRequest === undefined) return;
onChangeRequestState?.(threadKey, changeRequest);
}, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]);

const screenColor = useThemeColor("--color-screen");
const drawerColor = useThemeColor("--color-drawer");
Expand Down
33 changes: 33 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveThreadListV2ChangeRequestState,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand Down Expand Up @@ -54,6 +55,38 @@ function makeThread(

const NOW = "2026-06-02T00:00:00.000Z";

describe("resolveThreadListV2ChangeRequestState", () => {
it("preserves the previous state while a linked pull request reloads", () => {
expect(
resolveThreadListV2ChangeRequestState({
hasLinkedPullRequest: true,
state: null,
updatedAt: null,
}),
).toBeUndefined();
});

it("clears the previous state after a pull request is unlinked", () => {
expect(
resolveThreadListV2ChangeRequestState({
hasLinkedPullRequest: false,
state: null,
updatedAt: null,
}),
).toBeNull();
});

it("reports a loaded linked pull request", () => {
expect(
resolveThreadListV2ChangeRequestState({
hasLinkedPullRequest: true,
state: "merged",
updatedAt: "2026-06-02T00:00:00.000Z",
}),
).toEqual({ state: "merged", updatedAt: "2026-06-02T00:00:00.000Z" });
});
});

describe("resolveThreadListV2SnoozeMenuSelection", () => {
it("accepts a displayed evening preset while its wake time is still future", () => {
const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30);
Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ export { snoozeWakeLabel };
export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";
export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";

/** Keep the previous linked PR state while its detail query reloads. */
export function resolveThreadListV2ChangeRequestState(input: {
readonly hasLinkedPullRequest: boolean;
readonly state: ChangeRequestSettleSource["state"] | null;
readonly updatedAt: string | null;
}): ChangeRequestSettleSource | null | undefined {
if (input.state === null) return input.hasLinkedPullRequest ? undefined : null;
return { state: input.state, updatedAt: input.updatedAt };
}
Comment thread
t3dotgg marked this conversation as resolved.

export function resolveThreadListV2SnoozeMenuSelection(input: {
readonly event: string;
readonly displayedPresets: ReadonlyArray<SnoozePreset>;
Expand Down
32 changes: 31 additions & 1 deletion apps/mobile/src/state/use-thread-pr.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import {
createLinkedPullRequestDetailAtomFamily,
pullRequestDetailToVcsStatus,
} from "@t3tools/client-runtime/state/pull-requests";

import { connectionAtomRuntime } from "../connection/runtime";
import { useEnvironmentQuery } from "./query";
import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";
import { vcsEnvironment } from "./vcs";

const linkedPullRequestDetailAtom = createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime);

export {
presentThreadPr,
type ThreadPr,
Expand All @@ -22,13 +29,36 @@ export function useThreadPr(
): ThreadPrPresentation | null {
const cwd = thread.worktreePath ?? projectCwd;
const gitStatus = useEnvironmentQuery(
thread.branch !== null && cwd !== null
thread.linkedPullRequest == null && thread.branch !== null && cwd !== null
? vcsEnvironment.status({
environmentId: thread.environmentId,
input: { cwd },
})
: null,
);
const linkedPullRequest = useEnvironmentQuery(
thread.linkedPullRequest == null
? null
: linkedPullRequestDetailAtom({
environmentId: thread.environmentId,
input: {
projectId: thread.linkedPullRequest.projectId,
repository: thread.linkedPullRequest.repository,
number: thread.linkedPullRequest.number,
},
}),
);

if (thread.linkedPullRequest != null) {
const detail = linkedPullRequest.data;
return detail === null
? null
: presentThreadPr(pullRequestDetailToVcsStatus(detail), {
kind: detail.provider,
name: detail.provider,
baseUrl: "",
});
Comment thread
t3dotgg marked this conversation as resolved.
}

const status = gitStatus.data;
if (status === null || thread.branch === null || status.refName !== thread.branch) {
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
expect(second.capabilities.attachmentUploads).toBe(true);
expect(second.capabilities.pullRequests).toBe(true);
expect(second.capabilities.threadTitleRegeneration).toBe(true);
expect(second.capabilities.threadPullRequestLinking).toBe(true);
expect(second.capabilities.agentActivityPublishing).toBe(false);
}),
);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export const make = Effect.gen(function* () {
threadPinning: true,
threadPinReorder: true,
threadTitleRegeneration: true,
threadPullRequestLinking: true,
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),
},
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
interactionMode: event.payload.interactionMode,
branch: event.payload.branch,
worktreePath: event.payload.worktreePath,
linkedPullRequest: null,
latestTurnId: null,
createdAt: event.payload.createdAt,
updatedAt: event.payload.updatedAt,
Expand Down Expand Up @@ -822,6 +823,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
...(event.payload.worktreePath !== undefined
? { worktreePath: event.payload.worktreePath }
: {}),
...(event.payload.linkedPullRequest !== undefined
? { linkedPullRequest: event.payload.linkedPullRequest }
: {}),
updatedAt: event.payload.updatedAt,
});
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
interaction_mode,
branch,
worktree_path,
linked_pull_request_json,
latest_turn_id,
latest_user_message_at,
pending_approval_count,
Expand All @@ -102,6 +103,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
'default',
NULL,
NULL,
'{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}',
'turn-1',
'2026-02-24T00:00:04.000Z',
1,
Expand Down Expand Up @@ -304,6 +306,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
runtimeMode: "full-access",
branch: null,
worktreePath: null,
linkedPullRequest: {
projectId: asProjectId("project-1"),
repository: "pingdotgg/t3code",
number: 42,
url: "https://github.com/pingdotgg/t3code/pull/42",
},
latestTurn: {
turnId: asTurnId("turn-1"),
state: "completed",
Expand Down Expand Up @@ -423,6 +431,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
runtimeMode: "full-access",
branch: null,
worktreePath: null,
linkedPullRequest: {
projectId: asProjectId("project-1"),
repository: "pingdotgg/t3code",
number: 42,
url: "https://github.com/pingdotgg/t3code/pull/42",
},
latestTurn: {
turnId: asTurnId("turn-1"),
state: "completed",
Expand Down
24 changes: 24 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type OrchestrationThreadShell,
ModelSelection,
ProjectId,
ThreadLinkedPullRequest,
ThreadId,
} from "@t3tools/contracts";
import * as Arr from "effect/Array";
Expand Down Expand Up @@ -89,6 +90,7 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;
const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
Struct.assign({
modelSelection: Schema.fromJsonString(ModelSelection),
linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
}),
);
const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
Expand Down Expand Up @@ -422,6 +424,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
interaction_mode AS "interactionMode",
branch,
worktree_path AS "worktreePath",
linked_pull_request_json AS "linkedPullRequest",
latest_turn_id AS "latestTurnId",
created_at AS "createdAt",
updated_at AS "updatedAt",
Expand Down Expand Up @@ -458,6 +461,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
interaction_mode AS "interactionMode",
branch,
worktree_path AS "worktreePath",
linked_pull_request_json AS "linkedPullRequest",
latest_turn_id AS "latestTurnId",
created_at AS "createdAt",
updated_at AS "updatedAt",
Expand Down Expand Up @@ -496,6 +500,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
interaction_mode AS "interactionMode",
branch,
worktree_path AS "worktreePath",
linked_pull_request_json AS "linkedPullRequest",
latest_turn_id AS "latestTurnId",
created_at AS "createdAt",
updated_at AS "updatedAt",
Expand Down Expand Up @@ -938,6 +943,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
interaction_mode AS "interactionMode",
branch,
worktree_path AS "worktreePath",
linked_pull_request_json AS "linkedPullRequest",
latest_turn_id AS "latestTurnId",
created_at AS "createdAt",
updated_at AS "updatedAt",
Expand Down Expand Up @@ -1694,6 +1700,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
interactionMode: row.interactionMode,
branch: row.branch,
worktreePath: row.worktreePath,
...(row.linkedPullRequest === null
? {}
: { linkedPullRequest: row.linkedPullRequest }),
latestTurn: latestTurnByThread.get(row.threadId) ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
Expand Down Expand Up @@ -1901,6 +1910,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
interactionMode: row.interactionMode,
branch: row.branch,
worktreePath: row.worktreePath,
...(row.linkedPullRequest === null
? {}
: { linkedPullRequest: row.linkedPullRequest }),
latestTurn: latestTurnByThread.get(row.threadId) ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
Expand Down Expand Up @@ -2037,6 +2049,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
interactionMode: row.interactionMode,
branch: row.branch,
worktreePath: row.worktreePath,
...(row.linkedPullRequest === null
? {}
: { linkedPullRequest: row.linkedPullRequest }),
latestTurn: latestTurnByThread.get(row.threadId) ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
Expand Down Expand Up @@ -2182,6 +2197,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
interactionMode: row.interactionMode,
branch: row.branch,
worktreePath: row.worktreePath,
...(row.linkedPullRequest === null
? {}
: { linkedPullRequest: row.linkedPullRequest }),
latestTurn: latestTurnByThread.get(row.threadId) ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
Expand Down Expand Up @@ -2461,6 +2479,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
interactionMode: threadRow.value.interactionMode,
branch: threadRow.value.branch,
worktreePath: threadRow.value.worktreePath,
...(threadRow.value.linkedPullRequest === null
? {}
: { linkedPullRequest: threadRow.value.linkedPullRequest }),
latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,
createdAt: threadRow.value.createdAt,
updatedAt: threadRow.value.updatedAt,
Expand Down Expand Up @@ -2602,6 +2623,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
interactionMode: threadRow.value.interactionMode,
branch: threadRow.value.branch,
worktreePath: threadRow.value.worktreePath,
...(threadRow.value.linkedPullRequest === null
? {}
: { linkedPullRequest: threadRow.value.linkedPullRequest }),
latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,
createdAt: threadRow.value.createdAt,
updatedAt: threadRow.value.updatedAt,
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
: {}),
...(branch !== undefined ? { branch } : {}),
...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}),
...(command.linkedPullRequest !== undefined
? { linkedPullRequest: command.linkedPullRequest }
: {}),
updatedAt: occurredAt,
},
};
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,9 @@ export function projectEvent(
: {}),
...(payload.branch !== undefined ? { branch: payload.branch } : {}),
...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),
...(payload.linkedPullRequest !== undefined
? { linkedPullRequest: payload.linkedPullRequest }
: {}),
updatedAt: payload.updatedAt,
}),
})),
Expand Down
Loading
Loading