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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions SEAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,29 @@ label is the nature of this fork's change.
- **Optional** `infra/relay/src/observability.ts` — Axiom resources require the complete pair.
- **Optional** `infra/relay/src/worker.ts` — APNs queues and tracing layers are conditional.

## Settled-lifecycle fix (upstream candidate)

One behavioral change carried until upstream lands its own (see pingdotgg/t3code#5575 /
pingdotgg/t3code#5643): the explicit un-settle pin is sticky against activity, and a merged/closed
PR only insta-settles a thread whose activity is not newer than the PR's `updatedAt`.

- **Behavioral** `apps/server/src/orchestration/decider.ts` — activity un-settles only a `"settled"`
override; the `"active"` keep-alive pin survives messages, session starts, and approval/input
requests (three sites).
- **Behavioral** `packages/client-runtime/src/state/threadSettled.ts` — `effectiveSettled` accepts
`changeRequestUpdatedAt`; post-completion activity defers a merged/closed PR to the inactivity
rule.
- **Additive** `packages/contracts/src/git.ts` — optional `updatedAt` on `VcsStatusChangeRequest`.
- **Additive** `apps/server/src/git/GitManager.ts` — `toStatusPr` forwards the PR's `updatedAt`.
- **Additive** web (`SidebarV2.tsx`, `ChatView.tsx`, `chat/ChatHeader.tsx`,
`hooks/useThreadActionMenu.ts`) and mobile (`threadListV2.ts`, `thread-list-v2-items.tsx`,
`HomeScreen.tsx`, `ThreadNavigationSidebar.tsx`, `state/thread-pr-presentation.ts`) — thread the
PR `updatedAt` into the settled classification.

On a nightly-sync conflict here, prefer upstream's version wholesale if upstream has merged an
equivalent (a sticky un-settle or a completed-PR settle gate/toggle); otherwise reapply only the
behavior above.

## Nightly sync conflicts

Resolve against the new upstream file first, then reapply only the behavior above; never take the
Expand Down
14 changes: 10 additions & 4 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -483,17 +483,23 @@ export function HomeScreen(props: HomeScreenProps) {
// PR states stream in per-row (rows own the VCS subscriptions); a merged or
// closed PR auto-settles its thread on the next partition (mirrors web).
const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
ReadonlyMap<string, "open" | "closed" | "merged">
ReadonlyMap<
string,
{ readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null }
>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, state: "open" | "closed" | "merged" | null) => {
(threadKey: string, state: "open" | "closed" | "merged" | null, updatedAt: string | null) => {
setChangeRequestStateByKey((current) => {
if ((current.get(threadKey) ?? null) === state) return current;
const existing = current.get(threadKey) ?? null;
if ((existing?.state ?? null) === state && (existing?.updatedAt ?? null) === updatedAt) {
return current;
}
const next = new Map(current);
if (state === null) {
next.delete(threadKey);
} else {
next.set(threadKey, state);
next.set(threadKey, { state, updatedAt });
}
return next;
});
Expand Down
14 changes: 10 additions & 4 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -409,17 +409,23 @@ function ThreadNavigationSidebarPane(
// PR states stream in per-row; merged/closed PRs auto-settle their thread
// on the next partition.
const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
ReadonlyMap<string, "open" | "closed" | "merged">
ReadonlyMap<
string,
{ readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null }
>
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, state: "open" | "closed" | "merged" | null) => {
(threadKey: string, state: "open" | "closed" | "merged" | null, updatedAt: string | null) => {
setChangeRequestStateByKey((current) => {
if ((current.get(threadKey) ?? null) === state) return current;
const existing = current.get(threadKey) ?? null;
if ((existing?.state ?? null) === state && (existing?.updatedAt ?? null) === updatedAt) {
return current;
}
const next = new Map(current);
if (state === null) {
next.delete(threadKey);
} else {
next.set(threadKey, state);
next.set(threadKey, { state, updatedAt });
}
return next;
});
Expand Down
9 changes: 6 additions & 3 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,10 +357,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
readonly onSwipeableClose: (methods: SwipeableMethods) => void;
/** Reports this row's live PR state up so the partition can auto-settle
merged/closed work (mirrors web's onChangeRequestState). */
merged/closed work (mirrors web's onChangeRequestState). updatedAt
rides along so post-merge activity can hold the thread active. */
readonly onChangeRequestState?: (
threadKey: string,
state: "open" | "closed" | "merged" | null,
updatedAt: string | null,
) => void;
readonly projectCwd?: string | null;
readonly searchMatch?: EnvironmentThreadSearchMatch;
Expand Down Expand Up @@ -389,10 +391,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {

const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
const prState = pr?.state ?? null;
const prUpdatedAt = pr?.updatedAt ?? null;
const threadKey = `${thread.environmentId}:${thread.id}`;
useEffect(() => {
onChangeRequestState?.(threadKey, prState);
}, [onChangeRequestState, prState, threadKey]);
onChangeRequestState?.(threadKey, prState, prUpdatedAt);
}, [onChangeRequestState, prState, prUpdatedAt, threadKey]);

const screenColor = useThemeColor("--color-screen");
const drawerColor = useThemeColor("--color-drawer");
Expand Down
18 changes: 14 additions & 4 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,8 +318,13 @@ export function buildThreadListV2Items(input: {
}> | null;
readonly searchQuery: string;
readonly matchedThreadKeys?: ReadonlySet<string>;
/** Per-row PR state reported up by visible rows ("env:threadId" keys). */
readonly changeRequestStateByKey?: ReadonlyMap<string, "open" | "closed" | "merged">;
/** Per-row PR state reported up by visible rows ("env:threadId" keys).
updatedAt lets the partition hold a thread active when its activity is
newer than the merge/close. */
readonly changeRequestStateByKey?: ReadonlyMap<
string,
{ readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null }
>;
/** Environments whose server supports thread.settle/unsettle. Threads on
other environments never classify as settled — the user could neither
un-settle nor pin them. Absent = no gating (tests). */
Expand Down Expand Up @@ -379,7 +384,7 @@ export function buildThreadListV2Items(input: {
}
const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;
const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
const changeRequestState =
const changeRequest =
input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
// Visibility parity with web: snooze outranks everything, including a
// pin — a snoozed thread leaves the list until it wakes (or raises its
Expand All @@ -404,7 +409,12 @@ export function buildThreadListV2Items(input: {
}
if (
supportsSettlement &&
effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
effectiveSettled(thread, {
now,
autoSettleAfterDays,
changeRequestState: changeRequest?.state ?? null,
changeRequestUpdatedAt: changeRequest?.updatedAt ?? null,
})
) {
settled.push(thread);
} else {
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/state/thread-pr-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export type ThreadPr = NonNullable<VcsStatusResult["pr"]>;
export interface ThreadPrPresentation {
readonly number: number;
readonly state: ThreadPr["state"];
/** Provider's last update to the PR (ISO); an upper bound on merge/close
time for the settled classification. Absent from older servers. */
readonly updatedAt: string | null;
readonly url: string;
/** Compact pull request number label, e.g. "3774". */
readonly label: string;
Expand All @@ -28,6 +31,7 @@ export function presentThreadPr(
return {
number: pr.number,
state: pr.state,
updatedAt: pr.updatedAt ?? null,
url: pr.url,
label: String(pr.number),
accessibilityLabel: `#${pr.number} ${presentation.longName} ${pr.state}`,
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,44 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("status forwards the PR's updatedAt for the settled classification", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, ["checkout", "-b", "feature/status-updated-at"]);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-updated-at"]);

const { manager } = yield* makeManager({
ghScenario: {
prListSequence: [
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify([
{
number: 15,
title: "Merged PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/15",
baseRefName: "main",
headRefName: "feature/status-updated-at",
state: "MERGED",
mergedAt: "2026-01-30T10:00:00Z",
updatedAt: "2026-01-30T10:05:00Z",
},
]),
],
},
});

const status = yield* manager.status({ cwd: repoDir });

// Clients compare thread activity against this timestamp to keep a
// thread active when work continued after the merge.
expect(status.pr?.state).toBe("merged");
expect(status.pr?.updatedAt).toBe("2026-01-30T10:05:00.000Z");
}),
);

it.effect("status trims PR metadata returned by gh before publishing it", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down Expand Up @@ -855,6 +893,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
baseRef: "main",
headRef: "feature/status-lowercase-state",
state: "merged",
updatedAt: "2026-01-02T00:00:00.000Z",
});
}),
);
Expand Down Expand Up @@ -1051,6 +1090,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
baseRef: "main",
headRef: "statemachine",
state: "open",
updatedAt: "2026-03-10T07:00:00.000Z",
});
expect(ghCalls).toContain(
"pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
Expand Down Expand Up @@ -1159,6 +1199,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
baseRef: "main",
headRef: "effect-atom",
state: "open",
updatedAt: "2026-03-01T10:00:00.000Z",
});
expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe(
false,
Expand Down Expand Up @@ -1210,6 +1251,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
baseRef: "main",
headRef: "feature/status-merged-pr",
state: "merged",
updatedAt: "2026-01-30T10:00:00.000Z",
});
}),
);
Expand Down Expand Up @@ -1289,6 +1331,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
baseRef: "main",
headRef: "feature/status-open-over-merged",
state: "open",
updatedAt: "2026-01-30T10:00:00.000Z",
});
}),
);
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,14 +517,17 @@ function toStatusPr(pr: PullRequestInfo): {
baseRef: string;
headRef: string;
state: "open" | "closed" | "merged";
updatedAt?: string;
} {
const updatedAt = Option.map(pr.updatedAt, DateTime.formatIso);
return {
number: pr.number,
title: pr.title,
url: pr.url,
baseRef: pr.baseRefName,
headRef: pr.headRefName,
state: pr.state,
...(Option.isSome(updatedAt) ? { updatedAt: updatedAt.value } : {}),
};
}

Expand Down
33 changes: 22 additions & 11 deletions apps/server/src/orchestration/decider.settled.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,9 +385,8 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
session: makeSession("running"),
createdAt: NOW,
},
// A keep-active pin is also an override: real activity clears it
// back to neutral so auto-settle can apply again later.
readModel: makeReadModel("active"),
// A settled override must never hide a session coming alive.
readModel: makeReadModel("settled"),
});
const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult];
expect(sessionEvents.map((event) => event.type)).toEqual([
Expand All @@ -397,8 +396,13 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
}),
);

it.effect("clears a keep-active pin on real activity", () =>
it.effect("keeps a keep-active pin through real activity", () =>
Effect.gen(function* () {
// The pin is the user's "this thread is NOT done" statement. Activity
// must not spend it: clearing it on a message/session start let the
// merged-PR auto-settle rule re-settle the thread the moment the same
// burst of work went quiet, defeating every un-settle. Only an
// explicit settle clears the pin.
const turnResult = yield* decideOrchestrationCommand({
command: {
type: "thread.turn.start",
Expand All @@ -417,14 +421,24 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
readModel: makeReadModel("active"),
});
const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult];
// The pin exists to suppress AUTO-settle, not to survive real work:
// activity resets it to neutral, restoring the default lifecycle.
expect(turnEvents.map((event) => event.type)).toEqual([
"thread.unsettled",
"thread.message-sent",
"thread.turn-start-requested",
]);

const sessionResult = yield* decideOrchestrationCommand({
command: {
type: "thread.session.set",
commandId: CommandId.make("cmd-active-session-set"),
threadId: ThreadId.make("thread-1"),
session: makeSession("running"),
createdAt: NOW,
},
readModel: makeReadModel("active"),
});
const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult];
expect(sessionEvents.map((event) => event.type)).toEqual(["thread.session-set"]);

const activityResult = yield* decideOrchestrationCommand({
command: {
type: "thread.activity.append",
Expand All @@ -444,10 +458,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
readModel: makeReadModel("active"),
});
const activityEvents = Array.isArray(activityResult) ? activityResult : [activityResult];
expect(activityEvents.map((event) => event.type)).toEqual([
"thread.unsettled",
"thread.activity-appended",
]);
expect(activityEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]);
}),
);

Expand Down
28 changes: 18 additions & 10 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,13 +935,17 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
createdAt: command.createdAt,
},
};
// Real activity resets ANY override: it wakes an explicitly settled
// thread, and it clears a keep-active pin back to neutral so the
// thread can auto-settle again after this burst of work goes stale.
// A snooze clears the same way — sending a message to a snoozed
// thread is the user re-engaging, so the return ticket is spent.
// Real activity wakes an explicitly settled thread — a settled
// override must never hide new work. The keep-active pin is the
// opposite statement ("this thread is NOT done, whatever the auto
// rules say") and is deliberately sticky: clearing it on activity
// let the merged-PR rule re-settle the thread minutes after every
// un-settle. Only an explicit settle spends the pin.
// A snooze clears on activity either way — sending a message to a
// snoozed thread is the user re-engaging, so the return ticket is
// spent.
const lifecycleResetEvents: Array<Omit<OrchestrationEvent, "sequence">> = [];
if (targetThread.settledOverride !== null) {
if (targetThread.settledOverride === "settled") {
lifecycleResetEvents.push({
...(yield* withEventBase({
aggregateKind: "thread",
Expand Down Expand Up @@ -1123,8 +1127,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
// as snoozed, without spending the return ticket.
const isSessionActivity =
command.session.status === "starting" || command.session.status === "running";
// Real activity resets ANY override (settled wakes, active unpins).
if (thread.settledOverride === null || !isSessionActivity) {
// Real activity wakes a settled override only; the keep-active pin is
// sticky (see thread.message.user.post) — a session merely starting or
// resuming must not erase the user's "not done" statement.
if (thread.settledOverride !== "settled" || !isSessionActivity) {
return sessionSetEvent;
}
const unsettledEvent: Omit<OrchestrationEvent, "sequence"> = {
Expand Down Expand Up @@ -1300,8 +1306,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
const wakesSettledThread =
command.activity.kind === "approval.requested" ||
command.activity.kind === "user-input.requested";
// Real activity resets ANY override (settled wakes, active unpins).
if (thread.settledOverride === null || !wakesSettledThread) {
// Real activity wakes a settled override only; the keep-active pin is
// sticky (see thread.message.user.post) and already keeps the thread
// visible, so there is nothing to reset.
if (thread.settledOverride !== "settled" || !wakesSettledThread) {
return activityAppendedEvent;
}
const unsettledEvent: Omit<OrchestrationEvent, "sequence"> = {
Expand Down
Loading
Loading