From 15eda897d37aad232fca5089f5eb6ef7b726c31c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 12:13:24 -0700 Subject: [PATCH 01/90] perf(web): defer image URL requests for thread history (#9760) --- apps/web/src/components/ChatView.tsx | 26 +--- .../src/components/chat/MessagesTimeline.tsx | 21 ++- apps/web/src/session-logic.test.ts | 124 ++++++++++++++++++ apps/web/src/session-logic.ts | 55 ++++++-- 4 files changed, 196 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cad2859d1804..ccd970e6dda3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -104,6 +104,7 @@ import { deriveWorkLogEntries, hasActionableProposedPlan, isLatestTurnSettled, + selectHandoffImageResources, type TimelineEntriesProjection, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; @@ -2763,35 +2764,20 @@ export default function ChatView(props: ChatViewProps) { }, [activeThreadRef, downloadFileAttachment], ); - const serverAttachmentIds = useMemo(() => { - const attachmentIds = new Set(); - for (const message of serverMessages ?? []) { - for (const attachment of message.attachments ?? []) { - if (isImageAttachment(attachment)) { - attachmentIds.add(attachment.id); - } - } - } - return [...attachmentIds]; - }, [serverMessages]); const serverAttachmentResources = useMemo( - () => - serverAttachmentIds.map((attachmentId) => ({ - _tag: "attachment" as const, - attachmentId, - })), - [serverAttachmentIds], + () => selectHandoffImageResources(serverMessages, attachmentPreviewHandoffByMessageId), + [serverMessages, attachmentPreviewHandoffByMessageId], ); const serverAttachmentUrls = useAssetUrls(environmentId, serverAttachmentResources); const serverAttachmentUrlById = useMemo( () => new Map( - serverAttachmentIds.flatMap((attachmentId, index) => { + serverAttachmentResources.flatMap((resource, index) => { const url = serverAttachmentUrls[index]; - return url ? [[attachmentId, url] as const] : []; + return url ? [[resource.attachmentId, url] as const] : []; }), ), - [serverAttachmentIds, serverAttachmentUrls], + [serverAttachmentResources, serverAttachmentUrls], ); const displayServerMessages = useMemo>(() => { if (!serverMessages) return []; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 7874daa02c5a..4e55991f8703 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -52,7 +52,9 @@ import { import { FileDiff } from "@pierre/diffs/react"; import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; import { + createMessageAttachmentPreviewProjector, deriveTimelineEntries, + selectMessageImageResources, workEntryDisplayIndicatesToolFailure, workEntrySignalsSevereFailure, workLogEntryIsToolLike, @@ -99,7 +101,7 @@ import { ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; -import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls"; +import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; import { @@ -1227,9 +1229,24 @@ function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) { function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + const resources = useMemo( + () => selectMessageImageResources(row.message.attachments), + [row.message.attachments], + ); + const previewUrls = useAssetUrls(ctx.activeThreadEnvironmentId, resources); + const [projectPreviews] = useState(createMessageAttachmentPreviewProjector); + const messageWithPreviews = useMemo(() => { + const urlsById = new Map( + resources.flatMap((resource, index) => { + const url = previewUrls[index]; + return url ? [[resource.attachmentId, url] as const] : []; + }), + ); + return projectPreviews(row.message, (attachment) => urlsById.get(attachment.id)); + }, [previewUrls, projectPreviews, resources, row.message]); // The attachment union has an open member, so guards (not literal type // comparisons) split it. Unknown types render as inert rows below the files. - const userImages = (row.message.attachments ?? []).filter(isImageAttachment); + const userImages = (messageWithPreviews.attachments ?? []).filter(isImageAttachment); const userFiles = (row.message.attachments ?? []).filter(isFileAttachment); const userVideos = userFiles.filter(isVideoAttachment); const otherUserFiles = userFiles.filter((file) => !isVideoAttachment(file)); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index b0d6797bf4d5..dfb0c49be614 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -21,6 +21,8 @@ import { findLatestProposedPlan, hasActionableProposedPlan, isLatestTurnSettled, + selectHandoffImageResources, + selectMessageImageResources, workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workEntryIndicatesToolSuccess, @@ -2075,6 +2077,128 @@ describe("deriveWorkLogEntries", () => { }); }); +describe("image asset requests", () => { + const image = { + type: "image" as const, + id: "image", + name: "image.png", + mimeType: "image/png", + sizeBytes: 42, + }; + const message = { + id: MessageId.make("image-message"), + role: "user" as const, + text: "Inspect these images", + turnId: null, + createdAt: "2026-09-04T00:00:00.000Z", + updatedAt: "2026-09-04T00:00:00.000Z", + streaming: false, + attachments: [image], + }; + + it("requests the whole row's gallery and crops without signing local preview IDs", () => { + const attachments = Object.freeze([ + image, + { ...image, id: "second" }, + { ...image, id: "crop", name: "preview-annotation-1.png" }, + { ...image, id: "local", previewUrl: "blob:local" }, + { ...image, id: "inline", previewUrl: "data:image/png;base64,AA==" }, + { ...image, id: "provided", previewUrl: "https://preview.test/image" }, + { ...image, type: "file" as const, id: "file", mimeType: "application/pdf" }, + { ...image, type: "future", id: "unknown" }, + image, + ]); + + expect(selectMessageImageResources(attachments)).toEqual([ + { _tag: "attachment", attachmentId: "image" }, + { _tag: "attachment", attachmentId: "second" }, + { _tag: "attachment", attachmentId: "crop" }, + { _tag: "attachment", attachmentId: "provided" }, + ]); + }); + + it("requests offscreen handoffs without signing the rest of the loaded history", () => { + const history = { + ...message, + id: MessageId.make("history"), + attachments: [{ ...image, id: "history-image" }], + }; + const offscreen = { + ...message, + id: MessageId.make("offscreen"), + attachments: [image, { ...image, id: "crop", name: "preview-annotation-1.png" }], + }; + const empty = { + ...message, + id: MessageId.make("empty"), + attachments: [{ ...image, id: "empty" }], + }; + const assistant = { + ...message, + id: MessageId.make("assistant"), + role: "assistant" as const, + attachments: [{ ...image, id: "assistant-image" }], + }; + expect( + selectHandoffImageResources([history, message, offscreen, empty, assistant], { + [message.id]: ["blob:message"], + [offscreen.id]: ["blob:offscreen", "blob:crop"], + [empty.id]: [], + [assistant.id]: ["blob:unused"], + }), + ).toEqual([ + { _tag: "attachment", attachmentId: "image" }, + { _tag: "attachment", attachmentId: "crop" }, + ]); + }); + + it("does not scan history when no handoff is pending", () => { + let reads = 0; + const messages = new Proxy([message], { + get(target, property, receiver) { + if (property === "0") reads += 1; + return Reflect.get(target, property, receiver); + }, + }); + const empty = selectHandoffImageResources(messages, {}); + expect(reads).toBe(0); + expect(empty).toHaveLength(0); + expect(selectHandoffImageResources(undefined, { missing: ["blob:missing"] })).toBe(empty); + expect(selectMessageImageResources(undefined)).toBe(empty); + }); + + it("hands signed URLs to a mounted row only after the local preview is released", () => { + const server = createMessageAttachmentPreviewProjector(); + const handoff = createMessageAttachmentPreviewProjector(); + const row = createMessageAttachmentPreviewProjector(); + const pending = handoff( + server(message, () => undefined), + () => "blob:pending", + ); + expect(selectMessageImageResources(pending.attachments)).toEqual([]); + expect(selectHandoffImageResources([message], { [message.id]: ["blob:pending"] })).toEqual([ + { _tag: "attachment", attachmentId: image.id }, + ]); + + const ready = server(message, () => "https://server.test/image"); + expect(selectMessageImageResources(handoff(ready, () => "blob:pending").attachments)).toEqual( + [], + ); + const released = server(message, () => undefined); + expect(selectMessageImageResources(released.attachments)).toEqual([ + { _tag: "attachment", attachmentId: image.id }, + ]); + const displayed = row(released, () => "https://server.test/image"); + expect(displayed).toEqual(ready); + expect(pending.attachments?.[0]).toMatchObject({ previewUrl: "blob:pending" }); + expect(row(released, () => "https://server.test/renewed").attachments?.[0]).toMatchObject({ + previewUrl: "https://server.test/renewed", + }); + expect(displayed.attachments?.[0]).toMatchObject({ previewUrl: "https://server.test/image" }); + expect(row(released, () => undefined)).toBe(message); + }); +}); + describe("deriveTimelineEntries", () => { const streamingMessage = { id: MessageId.make("streaming-message"), diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 1fb0391d9eb9..47fe1b49ac32 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -12,6 +12,7 @@ import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-lo import { ApprovalRequestId, isToolLifecycleItemType, + type AssetResource, type OrchestrationLatestTurn, type OrchestrationThreadActivity, type OrchestrationProposedPlanId, @@ -24,14 +25,15 @@ import { type TurnId, } from "@t3tools/contracts"; -import type { - ChatAttachment, - ChatMessage, - ProposedPlan, - SessionPhase, - Thread, - ThreadSession, - TurnDiffSummary, +import { + isImageAttachment, + type ChatAttachment, + type ChatMessage, + type ProposedPlan, + type SessionPhase, + type Thread, + type ThreadSession, + type TurnDiffSummary, } from "./types"; export type ProviderPickerKind = ProviderDriverKind; @@ -1883,6 +1885,43 @@ function mergeTimelineEntrySuffix( return merged; } +type AttachmentResource = Extract; +const EMPTY_IMAGE_RESOURCES = Object.freeze>([]); + +/** A mounted row requests its stored images. Local previews keep their existing URLs. */ +export function selectMessageImageResources( + attachments: ChatMessage["attachments"], +): ReadonlyArray { + const attachmentIds = new Set(); + for (const attachment of attachments ?? []) { + if (!isImageAttachment(attachment)) continue; + const previewUrl = attachment.previewUrl; + if (previewUrl?.startsWith("blob:") || previewUrl?.startsWith("data:")) continue; + attachmentIds.add(attachment.id); + } + return attachmentIds.size === 0 + ? EMPTY_IMAGE_RESOURCES + : Array.from(attachmentIds, (attachmentId) => ({ _tag: "attachment", attachmentId })); +} + +/** Handoffs need server URLs even while their message rows are unmounted. */ +export function selectHandoffImageResources( + messages: ReadonlyArray | undefined, + handoffs: Readonly>>, +): ReadonlyArray { + if (Object.keys(handoffs).length === 0) return EMPTY_IMAGE_RESOURCES; + const attachmentIds = new Set(); + for (const message of messages ?? []) { + if (message.role !== "user" || !handoffs[message.id]?.length) continue; + for (const attachment of message.attachments ?? []) { + if (isImageAttachment(attachment)) attachmentIds.add(attachment.id); + } + } + return attachmentIds.size === 0 + ? EMPTY_IMAGE_RESOURCES + : Array.from(attachmentIds, (attachmentId) => ({ _tag: "attachment", attachmentId })); +} + /** Own one mapper per preview stage. Immutable messages retain unchanged preview objects. */ export function createMessageAttachmentPreviewProjector() { const attachmentsBySource = new WeakMap< From bc03c3640d6d3bb44e5fb477bfd78d7484cd0e00 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 12:15:41 -0700 Subject: [PATCH 02/90] fix(models): make GPT-6-Astra current (#9762) --- apps/server/src/provider/model-manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 590d02c8ac03..bca9f09eee8c 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,8 +1,9 @@ { "version": 1, - "updatedAt": "2026-09-03T09:58:00Z", + "updatedAt": "2026-09-04T19:10:48Z", "currentModels": { "codex": [ + "gpt-6-astra", "gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", From d115a96763b76d00f06b06272688cf68eebe8206 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 4 Sep 2026 15:20:07 -0400 Subject: [PATCH 03/90] fix(web): stop empty diffs replacing pull requests (#9753) --- .../web/src/components/ChatView.logic.test.ts | 75 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 18 +++++ apps/web/src/components/ChatView.tsx | 23 +++--- .../components/settings/SettingsPanels.tsx | 2 +- docs/user/source-control.md | 2 +- 5 files changed, 109 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index c8afd10d3725..c67ea3f3d5c3 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -41,6 +41,7 @@ import { resolveComposerInteractionMode, resolveComposerProviderSelection, resolveDraftPromotionNavigationTarget, + resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, resolveDraftHeroState, @@ -159,6 +160,80 @@ describe("proactive panels", () => { }), ).toBe(false); }); + + it("opens a completed turn diff only for changed files", () => { + const changedCheckpoint = { + status: "ready", + files: [{ path: "src/app.ts", kind: "modified", additions: 1, deletions: 0 }], + } satisfies Pick; + const unchangedCheckpoint = { + status: "ready", + files: [], + } satisfies Pick; + + expect( + resolveProactiveTurnDiffAction({ + checkpoint: changedCheckpoint, + isGitRepo: true, + activeSurfaceKind: null, + }), + ).toBe("open"); + expect( + resolveProactiveTurnDiffAction({ + checkpoint: unchangedCheckpoint, + isGitRepo: true, + activeSurfaceKind: null, + }), + ).toBe("ignore"); + }); + + it("waits for definitive checkpoint and repository state", () => { + const missingCheckpoint = { + status: "missing", + files: [], + } satisfies Pick; + const changedCheckpoint = { + status: "ready", + files: [{ path: "src/app.ts", kind: "modified", additions: 1, deletions: 0 }], + } satisfies Pick; + + expect( + resolveProactiveTurnDiffAction({ + checkpoint: undefined, + isGitRepo: true, + activeSurfaceKind: null, + }), + ).toBe("defer"); + expect( + resolveProactiveTurnDiffAction({ + checkpoint: missingCheckpoint, + isGitRepo: true, + activeSurfaceKind: null, + }), + ).toBe("defer"); + expect( + resolveProactiveTurnDiffAction({ + checkpoint: changedCheckpoint, + isGitRepo: undefined, + activeSurfaceKind: null, + }), + ).toBe("defer"); + }); + + it("keeps an active pull request above a completed turn diff", () => { + const changedCheckpoint = { + status: "ready", + files: [{ path: "src/app.ts", kind: "modified", additions: 1, deletions: 0 }], + } satisfies Pick; + + expect( + resolveProactiveTurnDiffAction({ + checkpoint: changedCheckpoint, + isGitRepo: true, + activeSurfaceKind: "pull-request", + }), + ).toBe("ignore"); + }); }); describe("toolGroupConsumesUpwardNavigation", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 4a0b9f576103..10c2fd4710ee 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -121,6 +121,24 @@ export function shouldOpenProactiveTurnDiff(input: { ); } +export function resolveProactiveTurnDiffAction(input: { + checkpoint: Pick | undefined; + isGitRepo: boolean | undefined; + activeSurfaceKind: RightPanelSurface["kind"] | null; +}): "defer" | "ignore" | "open" { + if (input.activeSurfaceKind === "pull-request") return "ignore"; + if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer"; + if (input.isGitRepo === undefined) return "defer"; + if ( + !input.isGitRepo || + input.checkpoint.status !== "ready" || + input.checkpoint.files.length === 0 + ) { + return "ignore"; + } + return "open"; +} + export function codexArtifactTemplatePromptToAppend( currentDraft: string, template: CodexArtifactTemplate, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ccd970e6dda3..cfbeb3aa7f72 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -385,6 +385,7 @@ import { resolveComposerInteractionMode, resolveComposerProviderSelection, resolveDraftHeroState, + resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, @@ -3922,18 +3923,21 @@ export default function ChatView(props: ChatViewProps) { : null; const eligibleCompletion = settings.proactivePanelsEnabled && !shouldUseRightPanelSheet && newlyCompletedTurnId !== null; - const checkpointReady = - eligibleCompletion && - activeThread?.checkpoints.some((checkpoint) => checkpoint.turnId === newlyCompletedTurnId) === - true; - const shouldOpenTurn = checkpointReady && gitStatusQuery.data?.isRepo === true; - const shouldDeferCompletion = - eligibleCompletion && !shouldOpenTurn && gitStatusQuery.data?.isRepo !== false; + const completedCheckpoint = eligibleCompletion + ? activeThread?.checkpoints.find((checkpoint) => checkpoint.turnId === newlyCompletedTurnId) + : undefined; + const diffAction = eligibleCompletion + ? resolveProactiveTurnDiffAction({ + checkpoint: completedCheckpoint, + isGitRepo: gitStatusQuery.data?.isRepo, + activeSurfaceKind: activeRightPanelSurface?.kind ?? null, + }) + : "ignore"; proactiveTurnObservationRef.current = { threadKey: activeThreadKey, - runningTurnId: shouldDeferCompletion ? (previousRunningTurnId ?? null) : activeRunningTurnId, + runningTurnId: diffAction === "defer" ? (previousRunningTurnId ?? null) : activeRunningTurnId, }; - if (!shouldOpenTurn || newlyCompletedTurnId === null) return; + if (diffAction !== "open" || newlyCompletedTurnId === null) return; useDiffPanelStore.getState().selectTurn(activeThreadRef, newlyCompletedTurnId); useRightPanelStore.getState().open(activeThreadRef, "diff"); @@ -3945,6 +3949,7 @@ export default function ChatView(props: ChatViewProps) { activeRunningTurnId, activeThreadKey, activeThreadRef, + activeRightPanelSurface?.kind, clientSettingsHydrated, gitStatusQuery.data?.isRepo, isServerThread, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d78828da5cfe..b0344b640393 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2308,7 +2308,7 @@ export function GeneralSettingsPanel() { Date: Fri, 4 Sep 2026 15:20:20 -0400 Subject: [PATCH 04/90] fix(sidebar): mute background working threads (#9759) --- apps/web/src/components/Sidebar.logic.test.ts | 46 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 15 ++++++ apps/web/src/components/Sidebar.tsx | 44 ++++++++++-------- 3 files changed, 85 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 62204d9d4a31..bf3d37d0edc2 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -29,6 +29,7 @@ import { formatWorkingDurationLabel, shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, + shouldRecedeSidebarThread, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebar, pinOrderKeyBetween, @@ -295,6 +296,51 @@ describe("hasUnseenCompletion", () => { }); }); +describe("shouldRecedeSidebarThread", () => { + it.each(["working", "monitoring"] as const)( + "recedes an inactive %s thread even when it is unread and woke", + (status) => { + expect( + shouldRecedeSidebarThread({ + status, + isUnread: true, + isWoke: true, + isActive: false, + isSelected: false, + }), + ).toBe(true); + }, + ); + + it.each(["ready", "approval", "input"] as const)( + "keeps an unread %s thread prominent", + (status) => { + expect( + shouldRecedeSidebarThread({ + status, + isUnread: true, + isWoke: false, + isActive: false, + isSelected: false, + }), + ).toBe(false); + }, + ); + + it("keeps active and selected working threads prominent", () => { + const input = { + status: "working" as const, + isUnread: true, + isWoke: true, + isActive: false, + isSelected: false, + }; + + expect(shouldRecedeSidebarThread({ ...input, isActive: true })).toBe(false); + expect(shouldRecedeSidebarThread({ ...input, isSelected: true })).toBe(false); + }); +}); + describe("createThreadJumpHintVisibilityController", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 6a2d44da1a79..8e88aea37f4c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -532,6 +532,21 @@ export type SidebarThreadStatus = | "failed" | "ready"; +export function shouldRecedeSidebarThread(input: { + status: SidebarThreadStatus; + isUnread: boolean; + isWoke: boolean; + isActive: boolean; + isSelected: boolean; +}): boolean { + if (input.isActive || input.isSelected) return false; + if (input.status === "working" || input.status === "monitoring") return true; + if (input.status === "ready" || input.status === "approval" || input.status === "input") { + return !input.isUnread && !input.isWoke; + } + return false; +} + type SidebarThreadStatusInput = Pick< SidebarThreadSummary, "hasPendingApprovals" | "hasPendingUserInput" | "session" | "backgroundLiveness" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0719f873e6a9..4509fde6ceb9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -144,6 +144,7 @@ import { resolveSidebarThreadStatus, searchSidebarThreadsByTitle, shouldCreateNewThreadInCurrentProject, + shouldRecedeSidebarThread, resolveWorkingStartedAt, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, @@ -874,6 +875,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // switching sidebars must not light up every historical thread as unread. const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarThreadStatus(thread); + const isInFlight = + status === "working" || status === "monitoring" || status === "approval" || status === "input"; // A woken thread reappears at its original position (the sort is // deliberately static), so the pill has to carry the weight. Snoozing is // an explicit act, so the pill clears only when the user re-engages: @@ -887,17 +890,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && thread.settledOverride !== "settled"; - // In-flight rows (working, or waiting on approval/input) fade as a whole: - // there is nothing for the user to do yet, so prominence is reserved for - // rows that need a human — done (unread), read-but-unsettled, failed, and - // freshly woken. The status label keeps its hue, so waiting rows stay - // findable. In-flight rows recede the same as read-ready ones (inbox-zero: - // working threads aren't your problem yet) — only the colored status label - // stands out. - const isInFlight = - status === "working" || status === "monitoring" || status === "approval" || status === "input"; - const shouldRecede = - (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; + // Background work always recedes when it is not selected: an unread parent + // completion must not pull a still-working thread back into the foreground. + // Ready and action-required rows keep their unread and wake prominence. + const shouldRecede = shouldRecedeSidebarThread({ + status, + isUnread, + isWoke, + isActive: props.isActive, + isSelected, + }); // Status hues follow the system-wide convention set by sidebar v1 and the // mobile Live Activity/widgets (amber approval, indigo input, sky working) // so a thread reads the same color everywhere it surfaces. @@ -1210,21 +1212,23 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { variant === "card" ? cn( "truncate", - isUnread || isWoke - ? "text-foreground" - : shouldRecede - ? "text-secondary-label" + shouldRecede + ? "text-secondary-label" + : isUnread || isWoke + ? "text-foreground" : status === "failed" ? "text-foreground/95" : "text-foreground/90", ) : cn( "truncate group-hover/sidebar-row:text-foreground", - props.isActive || isWoke - ? "text-foreground" - : isUnread - ? "text-muted-foreground" - : "text-secondary-label/70", + shouldRecede + ? "text-secondary-label/70" + : props.isActive || isWoke + ? "text-foreground" + : isUnread + ? "text-muted-foreground" + : "text-secondary-label/70", ), isRegeneratingTitle && "opacity-[0.55]", )} From f6db4206258b0ef30e8dd8949627acfd209bf338 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 4 Sep 2026 15:24:34 -0400 Subject: [PATCH 05/90] fix(web): reset automatic pull to default (#9763) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/settings/ProjectSettingsPanel.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 41b942d09f83..7be0f15cd5c2 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -990,6 +990,11 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { setAutoPull(false)} /> + ) : null + } control={ Date: Fri, 4 Sep 2026 21:37:26 +0200 Subject: [PATCH 06/90] fix(web): restore file comment focus in editable preview (#9061) --- .../diffs/DiffCommentAnnotation.tsx | 18 ++++- .../src/components/files/FilePreviewPanel.tsx | 75 ++++++++++--------- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/diffs/DiffCommentAnnotation.tsx b/apps/web/src/components/diffs/DiffCommentAnnotation.tsx index d210732b6b60..bce9f7e55f58 100644 --- a/apps/web/src/components/diffs/DiffCommentAnnotation.tsx +++ b/apps/web/src/components/diffs/DiffCommentAnnotation.tsx @@ -1,5 +1,5 @@ import { MessageCircle, Trash2 } from "lucide-react"; -import { useState, type ReactNode } from "react"; +import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { Button } from "~/components/ui/button"; import { Textarea } from "~/components/ui/textarea"; @@ -25,6 +25,7 @@ interface DiffCommentAnnotationProps { submitLabel?: string; pending?: boolean; secondaryAction?: DiffCommentSecondaryAction; + focusOnMount?: boolean; } /** The shared inline comment treatment for file previews, thread diffs, and pull-request diffs. */ @@ -40,10 +41,20 @@ export function DiffCommentAnnotation({ submitLabel = "Comment", pending = false, secondaryAction, + focusOnMount = true, }: DiffCommentAnnotationProps) { const [localDraftText, setLocalDraftText] = useState(""); const displayedText = kind === "draft" && !onTextChange ? localDraftText : text; const trimmedText = displayedText.trim(); + const textareaRef = useRef(null); + + useLayoutEffect(() => { + if (kind !== "draft" || !focusOnMount) return; + const frame = window.requestAnimationFrame(() => { + textareaRef.current?.focus({ preventScroll: true }); + }); + return () => window.cancelAnimationFrame(frame); + }, [focusOnMount, kind]); if (kind === "comment") { return ( @@ -78,9 +89,10 @@ export function DiffCommentAnnotation({ onPointerDown={(event) => event.stopPropagation()} >