diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index c61a0370..917cf1e2 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 c28c0569..665df32d 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -79,6 +79,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 d2f1bf7f..20875c7a 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 da15b568..b15d2eac 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestGraphql.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestGraphql.test.ts @@ -300,6 +300,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 84b4dd55..066d2eea 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", @@ -64,6 +65,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; @@ -120,6 +123,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)), @@ -203,6 +207,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 { @@ -292,6 +308,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 1f4acf77..e0cdc5b7 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 2796e9dd..531d78d0 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 af78e60c..c7900414 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 421f01e5..f7643de2 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 9ef18197..c25b17d0 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 75d7a7a0..9cbf039a 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 a1fffe0a..75503b6a 100644 --- a/apps/web/src/components/pull-requests/pullRequests.logic.test.ts +++ b/apps/web/src/components/pull-requests/pullRequests.logic.test.ts @@ -499,6 +499,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 2abd9ee6..9ddc507f 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; } /** @@ -609,6 +611,8 @@ export function pullRequestFromGitStatus( title: gitStatus.pr.title, url: gitStatus.pr.url, repository, + // The status read carries no dates. + settledAt: null, }; } @@ -662,6 +666,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 9c0ae350..3d86a6d8 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 3b5cdbb6..08da200e 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -89,6 +89,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 d9b48040..918b758f 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),