authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-02 18:51:21-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 18:51:21-04:00
logfb93902ee24d4ba380508df33d25478d3baf7a72
treede5158330f547a7b48f2e15f298b2cc104114ccd
parent535557b3f785629e6f48cad40c5a4b78b3b3c5d6
signature Signed by PGP key B5690EEEBB952194

feat(web): add proactive panels (#9276)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

9 files changed, 244 insertions(+), 3 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1
......@@ -43,6 +43,7 @@ const clientSettings: ClientSettings = {
4343 glassOpacity: 80,
4444 panelAnimationDurationMs: 0,
4545 planModeEnabled: false,
46 proactivePanelsEnabled: true,
4647 showSkillsInSlashMenu: false,
4748 providerModelPreferences: {},
4849 sidebarProjectGroupingMode: "repository_path",
apps/web/src/components/ChatView.logic.test.ts+47
......@@ -40,6 +40,8 @@ import {
4040 codexArtifactTemplatePromptToAppend,
4141 shouldDockDraftHeroForSubmission,
4242 shouldReleaseTimelineAnchorForToolActivity,
43 shouldOpenProactivePullRequest,
44 shouldOpenProactiveTurnDiff,
4345 shouldShowBranchMismatchBanner,
4446 shouldShowPlanFollowUpPrompt,
4547 shouldWriteThreadErrorToCurrentServerThread,
......@@ -83,6 +85,51 @@ describe("agent browser close confirmation", () => {
8385 });
8486});
8587
88describe("proactive panels", () => {
89 it("opens a pull request only after a newly observed link appears", () => {
90 expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(false);
91 expect(shouldOpenProactivePullRequest(null, "project:repo:42")).toBe(true);
92 expect(shouldOpenProactivePullRequest("project:repo:42", "project:repo:42")).toBe(false);
93 expect(shouldOpenProactivePullRequest("project:repo:42", null)).toBe(false);
94 });
95
96 it("opens the diff only when the observed running turn settles", () => {
97 const turnId = TurnId.make("turn-1");
98 expect(
99 shouldOpenProactiveTurnDiff({
100 previousRunningTurnId: undefined,
101 runningTurnId: null,
102 settledTurnId: turnId,
103 turnCompleted: true,
104 }),
105 ).toBe(false);
106 expect(
107 shouldOpenProactiveTurnDiff({
108 previousRunningTurnId: turnId,
109 runningTurnId: null,
110 settledTurnId: turnId,
111 turnCompleted: true,
112 }),
113 ).toBe(true);
114 expect(
115 shouldOpenProactiveTurnDiff({
116 previousRunningTurnId: turnId,
117 runningTurnId: TurnId.make("turn-2"),
118 settledTurnId: turnId,
119 turnCompleted: true,
120 }),
121 ).toBe(false);
122 expect(
123 shouldOpenProactiveTurnDiff({
124 previousRunningTurnId: turnId,
125 runningTurnId: null,
126 settledTurnId: turnId,
127 turnCompleted: false,
128 }),
129 ).toBe(false);
130 });
131});
132
86133describe("isVideoPreviewRequestCurrent", () => {
87134 it("rejects changed threads and replaced previews", () => {
88135 expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false);
apps/web/src/components/ChatView.logic.ts+22
......@@ -78,6 +78,28 @@ export function agentControlledBrowserCloseConfirmation(
7878 ].join("\n");
7979}
8080
81export function shouldOpenProactivePullRequest(
82 previousTargetKey: string | null | undefined,
83 targetKey: string | null,
84): boolean {
85 return previousTargetKey !== undefined && targetKey !== null && targetKey !== previousTargetKey;
86}
87
88export function shouldOpenProactiveTurnDiff(input: {
89 previousRunningTurnId: TurnId | null | undefined;
90 runningTurnId: TurnId | null;
91 settledTurnId: TurnId | null;
92 turnCompleted: boolean;
93}): boolean {
94 return (
95 input.previousRunningTurnId !== undefined &&
96 input.previousRunningTurnId !== null &&
97 input.runningTurnId === null &&
98 input.turnCompleted &&
99 input.settledTurnId === input.previousRunningTurnId
100 );
101}
102
81103export function codexArtifactTemplatePromptToAppend(
82104 currentDraft: string,
83105 template: CodexArtifactTemplate,
apps/web/src/components/ChatView.tsx+124-3
......@@ -361,6 +361,8 @@ import {
361361 shouldReleaseTimelineAnchorForToolActivity,
362362 shouldShowBranchMismatchBanner,
363363 shouldShowPlanFollowUpPrompt,
364 shouldOpenProactivePullRequest,
365 shouldOpenProactiveTurnDiff,
364366 getStartedThreadModelChangeBlockReason,
365367 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
366368 LastInvokedScriptByProjectSchema,
......@@ -1766,6 +1768,7 @@ function ChatViewContent(props: ChatViewProps) {
17661768 [activeThreadEnvironmentId, activeThreadId],
17671769 );
17681770 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
1771 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
17691772 const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom);
17701773 const [timelineAnchor, setTimelineAnchor] = useState<{
17711774 readonly threadKey: string | null;
......@@ -3665,7 +3668,16 @@ function ChatViewContent(props: ChatViewProps) {
36653668 );
36663669 // The thread's own change request, placed against the project it belongs to. Without a
36673670 // project there is nothing to resolve it against, so the caller falls back to the browser.
3668 const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null;
3671 const linkedThreadPullRequest = isServerThread
3672 ? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null)
3673 : (activeThread?.linkedPullRequest ?? null);
3674 const linkedThreadPullRequestKey = linkedThreadPullRequest
3675 ? JSON.stringify([
3676 linkedThreadPullRequest.projectId,
3677 linkedThreadPullRequest.repository,
3678 linkedThreadPullRequest.number,
3679 ])
3680 : null;
36693681 const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null;
36703682 const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository;
36713683 const openThreadPullRequest = useCallback(
......@@ -3708,6 +3720,117 @@ function ChatViewContent(props: ChatViewProps) {
37083720 },
37093721 [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests],
37103722 );
3723 const proactiveTurnObservationRef = useRef<{
3724 threadKey: string;
3725 runningTurnId: TurnId | null;
3726 } | null>(null);
3727 const proactivePullRequestObservationRef = useRef<{
3728 threadKey: string;
3729 targetKey: string | null;
3730 } | null>(null);
3731
3732 useEffect(() => {
3733 if (!isServerThread || activeThreadKey === null || activeThreadRef === null) {
3734 proactiveTurnObservationRef.current = null;
3735 return;
3736 }
3737 if (!clientSettingsHydrated || threadDetailLoading) {
3738 return;
3739 }
3740
3741 const previousObservation = proactiveTurnObservationRef.current;
3742 const observingSameThread = previousObservation?.threadKey === activeThreadKey;
3743 const previousRunningTurnId = observingSameThread
3744 ? previousObservation.runningTurnId
3745 : undefined;
3746 const settledTurnId = latestTurnSettled ? (activeLatestTurn?.turnId ?? null) : null;
3747 const newlyCompletedTurnId = shouldOpenProactiveTurnDiff({
3748 previousRunningTurnId,
3749 runningTurnId: activeRunningTurnId,
3750 settledTurnId,
3751 turnCompleted: activeLatestTurn?.state === "completed",
3752 })
3753 ? settledTurnId
3754 : null;
3755 const eligibleCompletion =
3756 settings.proactivePanelsEnabled && !shouldUseRightPanelSheet && newlyCompletedTurnId !== null;
3757 const checkpointReady =
3758 eligibleCompletion &&
3759 activeThread?.checkpoints.some((checkpoint) => checkpoint.turnId === newlyCompletedTurnId) ===
3760 true;
3761 const shouldOpenTurn = checkpointReady && gitStatusQuery.data?.isRepo === true;
3762 const shouldDeferCompletion =
3763 eligibleCompletion && !shouldOpenTurn && gitStatusQuery.data?.isRepo !== false;
3764 proactiveTurnObservationRef.current = {
3765 threadKey: activeThreadKey,
3766 runningTurnId: shouldDeferCompletion ? (previousRunningTurnId ?? null) : activeRunningTurnId,
3767 };
3768 if (!shouldOpenTurn || newlyCompletedTurnId === null) return;
3769
3770 useDiffPanelStore.getState().selectTurn(activeThreadRef, newlyCompletedTurnId);
3771 useRightPanelStore.getState().open(activeThreadRef, "diff");
3772 onDiffPanelOpen?.();
3773 }, [
3774 activeThread?.checkpoints,
3775 activeLatestTurn?.turnId,
3776 activeLatestTurn?.state,
3777 activeRunningTurnId,
3778 activeThreadKey,
3779 activeThreadRef,
3780 clientSettingsHydrated,
3781 gitStatusQuery.data?.isRepo,
3782 isServerThread,
3783 latestTurnSettled,
3784 onDiffPanelOpen,
3785 settings.proactivePanelsEnabled,
3786 shouldUseRightPanelSheet,
3787 threadDetailLoading,
3788 ]);
3789
3790 useEffect(() => {
3791 if (!isServerThread || activeThreadKey === null || activeThreadRef === null) {
3792 proactivePullRequestObservationRef.current = null;
3793 return;
3794 }
3795 if (!clientSettingsHydrated || threadDetailLoading) {
3796 return;
3797 }
3798
3799 const previousObservation = proactivePullRequestObservationRef.current;
3800 const observingSameThread = previousObservation?.threadKey === activeThreadKey;
3801 const previousTargetKey = observingSameThread ? previousObservation.targetKey : undefined;
3802 const newlyLinkedPullRequest = shouldOpenProactivePullRequest(
3803 previousTargetKey,
3804 linkedThreadPullRequestKey,
3805 );
3806 const eligibleLink =
3807 settings.proactivePanelsEnabled && !shouldUseRightPanelSheet && newlyLinkedPullRequest;
3808 const shouldOpenLink =
3809 eligibleLink &&
3810 pullRequestsCapabilityKnown &&
3811 supportsPullRequests &&
3812 linkedThreadPullRequest !== null;
3813 const shouldDeferLink = eligibleLink && !pullRequestsCapabilityKnown;
3814 proactivePullRequestObservationRef.current = {
3815 threadKey: activeThreadKey,
3816 targetKey: shouldDeferLink ? (previousTargetKey ?? null) : linkedThreadPullRequestKey,
3817 };
3818 if (!shouldOpenLink || linkedThreadPullRequest === null) return;
3819
3820 useRightPanelStore.getState().openPullRequest(activeThreadRef, linkedThreadPullRequest);
3821 }, [
3822 activeThreadKey,
3823 activeThreadRef,
3824 clientSettingsHydrated,
3825 isServerThread,
3826 linkedThreadPullRequest,
3827 linkedThreadPullRequestKey,
3828 pullRequestsCapabilityKnown,
3829 settings.proactivePanelsEnabled,
3830 shouldUseRightPanelSheet,
3831 supportsPullRequests,
3832 threadDetailLoading,
3833 ]);
37113834 const togglePreviewPanel = useCallback(() => {
37123835 if (!activeThreadRef || !isPreviewSupportedInRuntime()) return;
37133836 if (previewPanelOpen) {
......@@ -4612,8 +4735,6 @@ function ChatViewContent(props: ChatViewProps) {
46124735 : null,
46134736 [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread],
46144737 );
4615 // The server-projected settled state keeps the banner and sidebar in sync.
4616 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
46174738 const activeComposerTasksProgress = useMemo(() => {
46184739 if (!activeLatestTurn || latestTurnSettled || activePlan?.turnId !== activeLatestTurn.turnId) {
46194740 return null;
apps/web/src/components/settings/SettingsPanels.tsx+31
......@@ -525,6 +525,9 @@ export function useSettingsRestore(onRestored?: () => void) {
525525 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
526526 ? ["Diff whitespace changes"]
527527 : []),
528 ...(settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled
529 ? ["Proactive panels"]
530 : []),
528531 ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu
529532 ? ["Show skills in slash menu"]
530533 : []),
......@@ -588,6 +591,7 @@ export function useSettingsRestore(onRestored?: () => void) {
588591 settings.defaultThreadEnvMode,
589592 settings.newWorktreesStartFromOrigin,
590593 settings.diffIgnoreWhitespace,
594 settings.proactivePanelsEnabled,
591595 settings.environmentIdentificationMode,
592596 settings.contextWindowMeterEnabled,
593597 settings.fontFamilyCode,
......@@ -683,6 +687,7 @@ export function useSettingsRestore(onRestored?: () => void) {
683687 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
684688 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
685689 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
690 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,
686691 showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,
687692 contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,
688693 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
......@@ -2206,6 +2211,32 @@ export function GeneralSettingsPanel() {
22062211 }
22072212 />
22082213
2214 <SettingsRow
2215 {...searchableSetting("proactive-panels")}
2216 description="Automatically open the linked pull request when it appears and the turn diff when agent work finishes."
2217 resetAction={
2218 settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled ? (
2219 <SettingResetButton
2220 label="proactive panels"
2221 onClick={() =>
2222 updateSettings({
2223 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,
2224 })
2225 }
2226 />
2227 ) : null
2228 }
2229 control={
2230 <Switch
2231 checked={settings.proactivePanelsEnabled}
2232 onCheckedChange={(checked) =>
2233 updateSettings({ proactivePanelsEnabled: Boolean(checked) })
2234 }
2235 aria-label="Proactive panels"
2236 />
2237 }
2238 />
2239
22092240 <SettingsRow
22102241 {...searchableSetting("skills-in-slash-menu")}
22112242 description="Also include skills in the / command menu. Skills always appear when you type $."
apps/web/src/components/settings/settingsSearch.ts+6
......@@ -184,6 +184,12 @@ export const SETTINGS_SEARCH_ITEMS = [
184184 to: "/settings/general",
185185 searchTerms: ["diff ignore spaces edits default"],
186186 },
187 {
188 id: "proactive-panels",
189 title: "Proactive panels",
190 to: "/settings/general",
191 searchTerms: ["automatically open diff pull request pr right panel agent completion"],
192 },
187193 {
188194 id: "skills-in-slash-menu",
189195 title: "Show skills in slash menu",
docs/user/source-control.md+2
......@@ -46,6 +46,8 @@ T3 Code works with the platforms your team already uses:
4646 time, or change size
4747- While working in a thread, open linked reviews in the same compact right-panel tabs without
4848 leaving the conversation
49- Enable **Settings → General → Proactive panels** to open a newly linked review automatically and
50 switch to the completed turn's diff when agent work finishes
4951- Open the review directly in your browser with one click
5052- If T3 Code cannot load a GitHub pull request, including when GitHub rate limits requests, use
5153 **Open on GitHub** in the error view
packages/contracts/src/settings.test.ts+9
......@@ -67,6 +67,15 @@ describe("ClientSettings word wrap", () => {
6767 });
6868});
6969
70describe("ClientSettings proactive panels", () => {
71 it("is opt-in and accepts client-local updates", () => {
72 expect(decodeClientSettings({}).proactivePanelsEnabled).toBe(false);
73 expect(decodeClientSettingsPatch({ proactivePanelsEnabled: true }).proactivePanelsEnabled).toBe(
74 true,
75 );
76 });
77});
78
7079describe("ClientSettings quit confirmation", () => {
7180 it("defaults to hold", () => {
7281 expect(decodeClientSettings({}).confirmQuit).toBe("hold");
packages/contracts/src/settings.ts+2
......@@ -299,6 +299,7 @@ export const ClientSettingsSchema = Schema.Struct({
299299 // Legacy context window meter. The composer hides it by default; users who
300300 // still want the old usage indicator can restore it from Settings.
301301 contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
302 proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
302303 showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
303304 // Legacy sidebar (the original per-project tree). Deliberately a fresh key
304305 // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the
......@@ -1029,6 +1030,7 @@ export const ClientSettingsPatch = Schema.Struct({
10291030 ),
10301031 planModeEnabled: Schema.optionalKey(Schema.Boolean),
10311032 contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean),
1033 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),
10321034 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
10331035 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
10341036 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),