From f94eb7bbcab9bcc5e2d591ea0da5aefacb12586d Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:42:46 -0400 Subject: [PATCH] fix(web): a merged pull request wraps up its thread once, and a setting can turn that off A thread whose pull request merged was filed under Wrapped again after every turn, because the sidebar only knew the pull request was merged and not when. The row also kept the branch the thread began on after its worktree moved, so it stayed linked to the old, merged pull request. Listings now carry when a pull request landed, and the sidebar files a thread only while nothing has come from the user since: a later message, or a later keep-active, brings it back until the next landing. A thread that owns a worktree records the branch the worktree is on, so its badge follows the new pull request. A new switch under Settings, Projects & Threads, turns the automatic wrap-up off. --- .../settings/DesktopClientSettings.test.ts | 1 + .../src/pullRequest/PullRequestProvider.ts | 2 + .../src/pullRequest/PullRequestService.ts | 1 + .../gitHubPullRequestGraphql.test.ts | 1 + .../src/pullRequest/gitHubPullRequestList.ts | 17 ++++++ .../web/src/components/ChatView.logic.test.ts | 54 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 33 ++++++++++++ apps/web/src/components/ChatView.tsx | 28 ++++++++++ apps/web/src/components/Sidebar.logic.test.ts | 33 +++++++++++- apps/web/src/components/Sidebar.logic.ts | 36 +++++++++++-- apps/web/src/components/Sidebar.tsx | 14 ++++- .../pull-requests/pullRequests.logic.test.ts | 1 + .../pull-requests/pullRequests.logic.ts | 7 +++ .../components/settings/SettingsPanels.tsx | 27 ++++++++++ .../components/sidebar/InboxRows.browser.tsx | 1 + apps/web/src/localApi.test.ts | 2 + packages/contracts/src/pullRequest.ts | 2 + packages/contracts/src/settings.ts | 5 ++ 18 files changed, 258 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index c61a03708..917cf1e28 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -17,6 +17,7 @@ const clientSettings: ClientSettings = { chatChangedFilesDefaultExpanded: false, confirmThreadArchive: true, confirmThreadDelete: false, + wrapUpThreadsOnPullRequestSettled: true, dismissedProviderUpdateNotificationKeys: [], diffChangesOnly: false, diffIgnoreWhitespace: true, diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index f1f8bdb8f..b5ac6ebd2 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -78,6 +78,8 @@ export interface ProviderChangeRequest { readonly deletions: number; readonly createdAt: string; readonly updatedAt: string; + /** When the row merged or closed; absent or null while open, or where the host did not say. */ + readonly settledAt?: string | null; /** Accounts with a review outstanding; team requests are dropped by each host. */ readonly reviewRequestedLogins: ReadonlyArray; readonly labels: ReadonlyArray; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 8461c150d..44d136988 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -391,6 +391,7 @@ function toEntry(input: { deletions: row.deletions, createdAt: row.createdAt, updatedAt: row.updatedAt, + ...(row.settledAt == null ? {} : { settledAt: row.settledAt }), viewerIsAuthor: row.author !== null && matchesViewer(row.author.login), viewerReviewRequested: row.reviewRequestedLogins.some(matchesViewer), ...(input.viewerCanWrite === undefined ? {} : { viewerCanWrite: input.viewerCanWrite }), diff --git a/apps/server/src/pullRequest/gitHubPullRequestGraphql.test.ts b/apps/server/src/pullRequest/gitHubPullRequestGraphql.test.ts index c0ea3c02c..c9ce4b37a 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestGraphql.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestGraphql.test.ts @@ -299,6 +299,7 @@ describe("decodeGitHubAuthoredPullRequestsJson", () => { deletions: 2, createdAt: "2026-08-30T10:00:00Z", updatedAt: "2026-08-31T10:00:00Z", + settledAt: null, reviewRequestedLogins: ["hubot"], reviewDecision: "changes-requested", checksState: "failure", diff --git a/apps/server/src/pullRequest/gitHubPullRequestList.ts b/apps/server/src/pullRequest/gitHubPullRequestList.ts index e1343919c..62f35d182 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestList.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestList.ts @@ -35,6 +35,7 @@ export const GITHUB_PULL_REQUEST_LIST_FIELDS = [ "createdAt", "updatedAt", "mergedAt", + "closedAt", "mergeable", "reviewDecision", "reviewRequests", @@ -63,6 +64,8 @@ export interface GitHubPullRequestListRow { readonly deletions: number; readonly createdAt: string; readonly updatedAt: string; + /** When the row merged or closed; null while open, or where the host did not say. */ + readonly settledAt: string | null; /** User logins with a pending review request; team requests are dropped. */ readonly reviewRequestedLogins: ReadonlyArray; readonly reviewDecision?: PullRequestReviewDecision; @@ -117,6 +120,7 @@ export const GitHubPullRequestListRowSchema = Schema.Struct({ baseRefName: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), additions: Schema.optional(Schema.NullOr(NonNegativeInt)), deletions: Schema.optional(Schema.NullOr(NonNegativeInt)), @@ -198,6 +202,18 @@ function normalizeState(raw: { return state === "CLOSED" ? "closed" : "open"; } +/** The moment a settled row landed: the merge for a merged one, the close otherwise. */ +function normalizeSettledAt(raw: { + readonly state?: string | null | undefined; + readonly mergedAt?: string | null | undefined; + readonly closedAt?: string | null | undefined; +}): string | null { + if (normalizeState(raw) === "open") { + return null; + } + return nonEmptyText(raw.mergedAt) ?? nonEmptyText(raw.closedAt); +} + function normalizeReviewDecision( value: string | null | undefined, ): PullRequestReviewDecision | undefined { @@ -287,6 +303,7 @@ export function normalizeGitHubPullRequestListRow( deletions: raw.deletions ?? 0, createdAt: raw.createdAt, updatedAt: raw.updatedAt, + settledAt: normalizeSettledAt(raw), reviewRequestedLogins: (raw.reviewRequests ?? []).flatMap((request) => { const typename = nonEmptyText(request.__typename); if (typename !== null && typename !== "User") { diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 1f4acf77c..e0cdc5b79 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -51,7 +51,9 @@ import { THREAD_DETAIL_STALL_REFRESH_COOLDOWN_MS, THREAD_DETAIL_STALL_REFRESH_THRESHOLD_MS, waitForStartedServerThread, + resolveThreadBranchToRecord, } from "./ChatView.logic"; +import { buildTemporaryWorktreeBranchName } from "@threadlines/shared/git"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -2727,3 +2729,55 @@ describe("deriveProviderSendPreflight", () => { expect(deriveProviderSendPreflight({ instanceId: null, providers: [] })).toBeNull(); }); }); + +describe("resolveThreadBranchToRecord", () => { + const thread = { branch: "feature/one", worktreePath: "C:/wt/one" }; + + it("records the branch the thread's own worktree moved to", () => { + expect( + resolveThreadBranchToRecord({ + thread, + cwd: "C:\\wt\\one\\apps\\web", + checkoutRef: "feature/two", + }), + ).toBe("feature/two"); + expect( + resolveThreadBranchToRecord({ + thread: { branch: null, worktreePath: "/wt/one" }, + cwd: "/wt/one", + checkoutRef: "feature/two", + }), + ).toBe("feature/two"); + }); + + it("stays quiet where the checkout is shared, unchanged, elsewhere, detached, or temporary", () => { + expect( + resolveThreadBranchToRecord({ + thread: { ...thread, worktreePath: null }, + cwd: "C:/repo", + checkoutRef: "feature/two", + }), + ).toBeNull(); + expect( + resolveThreadBranchToRecord({ thread, cwd: "C:/wt/one", checkoutRef: "feature/one" }), + ).toBeNull(); + expect( + resolveThreadBranchToRecord({ thread, cwd: "C:/other/repo", checkoutRef: "feature/two" }), + ).toBeNull(); + expect(resolveThreadBranchToRecord({ thread, cwd: "C:/wt/one", checkoutRef: null })).toBeNull(); + expect( + resolveThreadBranchToRecord({ + thread, + cwd: "C:/wt/one", + checkoutRef: buildTemporaryWorktreeBranchName(), + }), + ).toBeNull(); + expect( + resolveThreadBranchToRecord({ + thread: { ...thread, branch: buildTemporaryWorktreeBranchName() }, + cwd: "C:/wt/one", + checkoutRef: "feature/two", + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 2796e9ddb..531d78d04 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -17,6 +17,8 @@ import { isProviderAuthErrorMessage, providerAuthReconnectCommand, } from "@threadlines/shared/providerAuth"; +import { isTemporaryWorktreeBranch } from "@threadlines/shared/git"; +import { normalizeFilesystemPathForComparison } from "@threadlines/shared/path"; import { normalizeTerminalActivityCommand } from "@threadlines/shared/terminalCommandTracker"; import type { DesktopCapturedScreenshot } from "@threadlines/contracts"; import { getModelPickerProviderAvailability } from "./chat/modelPickerEmptyState"; @@ -2145,3 +2147,34 @@ export function resolveRemoteBehindCount( } return status.behindCount > 0 ? status.behindCount : null; } + +/** + * The branch a thread should record after its own worktree moved. Only a + * thread that owns a worktree follows the checkout, and only while the status + * being read is that worktree's: a shared checkout's ref says nothing about + * any one thread in it, and a status read from elsewhere says nothing about + * this one. Temporary worktree branches are the server's to rename, so those + * are left alone on both sides. Null when there is nothing to record. + */ +export function resolveThreadBranchToRecord(input: { + readonly thread: Pick | undefined; + readonly cwd: string | null; + readonly checkoutRef: string | null; +}): string | null { + const { thread, cwd, checkoutRef } = input; + if (!thread || thread.worktreePath === null || cwd === null || checkoutRef === null) { + return null; + } + if (!isPathWithin(cwd, thread.worktreePath)) return null; + if (checkoutRef === thread.branch) return null; + if (isTemporaryWorktreeBranch(checkoutRef)) return null; + if (thread.branch !== null && isTemporaryWorktreeBranch(thread.branch)) return null; + return checkoutRef; +} + +/** Whether `cwd` is `root` or sits inside it, however the separators are spelled. */ +function isPathWithin(cwd: string, root: string): boolean { + const inner = normalizeFilesystemPathForComparison(cwd); + const outer = normalizeFilesystemPathForComparison(root); + return inner === outer || inner.startsWith(`${outer}\\`) || inner.startsWith(`${outer}/`); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d286a74f3..353527132 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -292,6 +292,7 @@ import { resolveRemoteBehindCount, resolveWorkingTreeDiffStat, type RevertConfirmView, + resolveThreadBranchToRecord, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { selectThreadBrowserState, useBrowserPanelStore } from "../browserPanelStore"; @@ -2444,6 +2445,33 @@ export default function ChatView(props: ChatViewProps) { }) : null; const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); + // A thread that owns its worktree follows the branch that worktree is on: a + // checkout switched from the shell or by the agent must not leave the record, + // and with it the pull request badge, on the branch the thread began with. + const branchToRecord = resolveThreadBranchToRecord({ + thread: isServerThread ? serverThread : undefined, + cwd: gitCwd, + checkoutRef: gitStatusQuery.data?.refName ?? null, + }); + const recordedBranchKeyRef = useRef(null); + useEffect(() => { + if (branchToRecord === null || !serverThread) return; + const key = `${routeThreadKey}:${branchToRecord}`; + if (recordedBranchKeyRef.current === key) return; + recordedBranchKeyRef.current = key; + const api = readEnvironmentApi(environmentId); + if (!api) return; + api.orchestration + .dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: serverThread.id, + branch: branchToRecord, + }) + .catch((error: unknown) => { + console.warn("[chat] failed to record the worktree branch", error); + }); + }, [branchToRecord, environmentId, routeThreadKey, serverThread]); // Watched separately from the thread's own checkout: when that checkout is // gone, the project root's status is what tells us whether there is still a // repository to fall back to. Same key as every other subscriber of this diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 421f01e5b..f7643de24 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1194,7 +1194,7 @@ describe("inbox done lifecycle", () => { isThreadDone(pinnedAndFresh, null, { now: NOW, autoDoneAfterDays: 2, - pullRequestSettled: true, + pullRequestSettledAt: "2026-07-28T11:30:00.000Z", }), ).toBe(true); // A thread that is still moving was never eligible in the first place. @@ -1202,11 +1202,40 @@ describe("inbox done lifecycle", () => { isThreadDone({ ...pinnedAndFresh, session: { status: "running" } as never }, null, { now: NOW, autoDoneAfterDays: 2, - pullRequestSettled: true, + pullRequestSettledAt: "2026-07-28T11:30:00.000Z", }), ).toBe(false); }); + it("files a landing once: a later message or a later keep-active brings the thread back", () => { + // The merge is the thread's last word only while nothing has happened + // since. A message sent after it is new work; so is an explicit "keep + // active" given after it, even once later agent activity has aged that + // override out of the override rule. + const landedAt = "2026-07-28T11:30:00.000Z"; + const options = { now: NOW, autoDoneAfterDays: 2, pullRequestSettledAt: landedAt }; + const spokeAfter = { ...base, latestUserMessageAt: "2026-07-28T11:45:00.000Z" }; + expect(isThreadDone(spokeAfter, null, options)).toBe(false); + + const quietSince = { + ...base, + latestUserMessageAt: "2026-07-28T11:00:00.000Z", + latestTurn: { + requestedAt: "2026-07-28T11:00:00.000Z", + completedAt: "2026-07-28T11:50:00.000Z", + } as never, + lastVisitedAt: "2026-07-28T11:55:00.000Z", + }; + expect(isThreadDone(quietSince, null, options)).toBe(true); + expect( + isThreadDone(quietSince, { state: "active", at: "2026-07-28T11:40:00.000Z" }, options), + ).toBe(false); + // A keep-active from before the landing is not a word on the landing. + expect( + isThreadDone(quietSince, { state: "active", at: "2026-07-28T11:20:00.000Z" }, options), + ).toBe(true); + }); + it("never files a completion the user has not seen", () => { // Filing unread work is the sidebar reading your mail for you. The row // should offer Wrap up only after the completed thread has been inspected. diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9ef181971..c25b17d0f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -696,8 +696,12 @@ export function isThreadDone( options: { readonly now: string; readonly autoDoneAfterDays?: number | null; - /** The thread's pull request has merged or closed. */ - readonly pullRequestSettled?: boolean; + /** + * When the thread's pull request merged or closed. The landing files the + * thread once: a message sent after it, or a "keep active" given after it, + * is the user's word that the thread is still in use. + */ + readonly pullRequestSettledAt?: string | null; }, ): boolean { if (!canMarkThreadDone(thread, options)) return false; @@ -709,7 +713,12 @@ export function isThreadDone( if (override != null && !overrideIsStale) { return override.state === "done"; } - if (options.pullRequestSettled === true && !hasUnseenCompletion(thread)) return true; + if ( + isFiledByPullRequest(thread, override, options.pullRequestSettledAt) && + !hasUnseenCompletion(thread) + ) { + return true; + } if (options.autoDoneAfterDays == null) return false; if (thread.pinnedAt !== null) return false; if (hasUnseenCompletion(thread)) return false; @@ -717,6 +726,27 @@ export function isThreadDone( return Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoDoneAfterDays * DAY_MS; } +/** + * Whether a landed pull request is the last word on the thread. The user's own + * later moves outrank it: a message sent after the landing, or an explicit + * "keep active" given after it, even one that later activity has made stale + * for the override rule above. + */ +function isFiledByPullRequest( + thread: DoneSortInput, + override: ThreadDoneOverride | null | undefined, + settledAt: string | null | undefined, +): boolean { + if (settledAt == null) return false; + const settledMs = Date.parse(settledAt); + if (Number.isNaN(settledMs)) return false; + if (override?.state === "active" && Date.parse(override.at) >= settledMs) return false; + const userSpokeAt = thread.latestUserMessageAt ?? thread.latestTurn?.requestedAt ?? null; + if (userSpokeAt === null) return true; + const userSpokeMs = Date.parse(userSpokeAt); + return Number.isNaN(userSpokeMs) || userSpokeMs <= settledMs; +} + /** * Pins are a deliberate, stable group at the top. Everything else tracks the * user's latest message rather than background agent activity. diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 75d7a7a00..9cbf039a0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -460,6 +460,7 @@ export default function Sidebar() { const isOnSettings = pathname.startsWith("/settings"); const isOnChats = pathname.startsWith("/chats"); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); + const wrapUpOnPullRequestSettled = useSettings((s) => s.wrapUpThreadsOnPullRequestSettled); const appSettingsConfirmThreadArchive = useSettings( (settings) => settings.confirmThreadArchive, ); @@ -668,11 +669,19 @@ export default function Sidebar() { doneThreadOverlays[threadKey], thread.doneOverride, ); - const pullRequestState = pullRequestByThreadKey.get(threadKey)?.state; + const pullRequest = pullRequestByThreadKey.get(threadKey); + const pullRequestSettled = + pullRequest !== undefined && + (pullRequest.state === "merged" || pullRequest.state === "closed"); const isDone = isThreadDone({ ...thread, lastVisitedAt }, override, { now: nowIso, autoDoneAfterDays: INBOX_AUTO_DONE_AFTER_DAYS, - pullRequestSettled: pullRequestState === "merged" || pullRequestState === "closed", + // A landing the host did not date is taken as now, which files the + // thread the way it always did. + pullRequestSettledAt: + wrapUpOnPullRequestSettled && pullRequestSettled + ? (pullRequest.settledAt ?? nowIso) + : null, }); return { thread, @@ -698,6 +707,7 @@ export default function Sidebar() { resolveThreadProjectKey, seenThreadOverlays, sidebarProjectByKey, + wrapUpOnPullRequestSettled, threadSeedVisitedAtById, ], ); diff --git a/apps/web/src/components/pull-requests/pullRequests.logic.test.ts b/apps/web/src/components/pull-requests/pullRequests.logic.test.ts index 7a19ad01f..e417958c8 100644 --- a/apps/web/src/components/pull-requests/pullRequests.logic.test.ts +++ b/apps/web/src/components/pull-requests/pullRequests.logic.test.ts @@ -495,6 +495,7 @@ describe("resolveThreadPullRequest", () => { title: "Merged already", url: "https://github.com/threadlines/threadlines/pull/7", repository: "threadlines/threadlines", + settledAt: null, }); }); diff --git a/apps/web/src/components/pull-requests/pullRequests.logic.ts b/apps/web/src/components/pull-requests/pullRequests.logic.ts index 00c07ab4d..26681e6d5 100644 --- a/apps/web/src/components/pull-requests/pullRequests.logic.ts +++ b/apps/web/src/components/pull-requests/pullRequests.logic.ts @@ -540,6 +540,8 @@ export interface ThreadPullRequest { /** The repository name, or null when the project sits on a host we cannot * read. The detail surface needs it; the badge does not. */ readonly repository: string | null; + /** When it merged or closed; null while open, or where the source did not say. */ + readonly settledAt: string | null; } /** The thread fields both the sidebar summary and the full thread record carry. */ @@ -572,6 +574,8 @@ export function pullRequestFromGitStatus( title: gitStatus.pr.title, url: gitStatus.pr.url, repository, + // The status read carries no dates. + settledAt: null, }; } @@ -625,6 +629,9 @@ export function resolveThreadPullRequest(input: { title: entry.title, url: entry.url, repository: entry.repository, + // A host that does not date the landing gets the row's last update, which + // is at or after it. + settledAt: entry.settledAt ?? (entry.state === "open" ? null : entry.updatedAt), }; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9c0ae3503..3d86a6d85 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -995,6 +995,33 @@ export function GeneralSettingsPanel({ surface = "full" }: { surface?: "full" | } /> + + updateSettings({ + wrapUpThreadsOnPullRequestSettled: + DEFAULT_UNIFIED_SETTINGS.wrapUpThreadsOnPullRequestSettled, + }) + } + /> + ) : null + } + control={ + + updateSettings({ wrapUpThreadsOnPullRequestSettled: Boolean(checked) }) + } + aria-label="Wrap up merged threads" + /> + } + /> { chatChangedFilesDefaultExpanded: false, confirmThreadArchive: true, confirmThreadDelete: false, + wrapUpThreadsOnPullRequestSettled: true, dismissedProviderUpdateNotificationKeys: [], diffChangesOnly: false, diffIgnoreWhitespace: true, @@ -847,6 +848,7 @@ describe("wsApi", () => { chatChangedFilesDefaultExpanded: false, confirmThreadArchive: true, confirmThreadDelete: false, + wrapUpThreadsOnPullRequestSettled: true, dismissedProviderUpdateNotificationKeys: [], diffChangesOnly: false, diffIgnoreWhitespace: true, diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 7ba2f5abf..6d2edd02c 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -79,6 +79,8 @@ export const PullRequestListEntry = Schema.Struct({ deletions: NonNegativeInt, createdAt: IsoDateTime, updatedAt: IsoDateTime, + /** When the row merged or closed. Absent while open, or where the host did not say. */ + settledAt: Schema.optionalKey(IsoDateTime), viewerIsAuthor: Schema.Boolean, viewerReviewRequested: Schema.Boolean, /** diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index d9b48040c..918b758ff 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -69,6 +69,10 @@ export const ClientSettingsSchema = Schema.Struct({ ), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + /** File a thread under Wrapped once its pull request merges or closes. */ + wrapUpThreadsOnPullRequestSettled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + ), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -625,6 +629,7 @@ export const ClientSettingsPatch = Schema.Struct({ chatChangedFilesDefaultExpanded: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), + wrapUpThreadsOnPullRequestSettled: Schema.optionalKey(Schema.Boolean), diffChangesOnly: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), diffRenderMode: Schema.optionalKey(DiffRenderMode),