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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const clientSettings: ClientSettings = {
chatChangedFilesDefaultExpanded: false,
confirmThreadArchive: true,
confirmThreadDelete: false,
wrapUpThreadsOnPullRequestSettled: true,
dismissedProviderUpdateNotificationKeys: [],
diffChangesOnly: false,
diffIgnoreWhitespace: true,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/pullRequest/PullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
readonly labels: ReadonlyArray<PullRequestLabel>;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/pullRequest/PullRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/pullRequest/gitHubPullRequestList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const GITHUB_PULL_REQUEST_LIST_FIELDS = [
"createdAt",
"updatedAt",
"mergedAt",
"closedAt",
"mergeable",
"reviewDecision",
"reviewRequests",
Expand Down Expand Up @@ -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<string>;
readonly reviewDecision?: PullRequestReviewDecision;
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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") {
Expand Down
54 changes: 54 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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();
});
});
33 changes: 33 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Thread, "branch" | "worktreePath"> | 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}/`);
}
28 changes: 28 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ import {
resolveRemoteBehindCount,
resolveWorkingTreeDiffStat,
type RevertConfirmView,
resolveThreadBranchToRecord,
} from "./ChatView.logic";
import { useLocalStorage } from "~/hooks/useLocalStorage";
import { selectThreadBrowserState, useBrowserPanelStore } from "../browserPanelStore";
Expand Down Expand Up @@ -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<string | null>(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
Expand Down
33 changes: 31 additions & 2 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1194,19 +1194,48 @@ 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.
expect(
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.
Expand Down
36 changes: 33 additions & 3 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -709,14 +713,40 @@ 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;
if (lastActivityAt === null) return false;
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.
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>(
(settings) => settings.confirmThreadArchive,
);
Expand Down Expand Up @@ -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,
Expand All @@ -698,6 +707,7 @@ export default function Sidebar() {
resolveThreadProjectKey,
seenThreadOverlays,
sidebarProjectByKey,
wrapUpOnPullRequestSettled,
threadSeedVisitedAtById,
],
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,7 @@ describe("resolveThreadPullRequest", () => {
title: "Merged already",
url: "https://github.com/threadlines/threadlines/pull/7",
repository: "threadlines/threadlines",
settledAt: null,
});
});

Expand Down
Loading
Loading