| author | |
| committer | |
| log | fb93902ee24d4ba380508df33d25478d3baf7a72 |
| tree | de5158330f547a7b48f2e15f298b2cc104114ccd |
| parent | 535557b3f785629e6f48cad40c5a4b78b3b3c5d6 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 = { |
| 43 | 43 | glassOpacity: 80, |
| 44 | 44 | panelAnimationDurationMs: 0, |
| 45 | 45 | planModeEnabled: false, |
| 46 | proactivePanelsEnabled: true, | |
| 46 | 47 | showSkillsInSlashMenu: false, |
| 47 | 48 | providerModelPreferences: {}, |
| 48 | 49 | sidebarProjectGroupingMode: "repository_path", |
apps/web/src/components/ChatView.logic.test.ts+47| ... | ... | @@ -40,6 +40,8 @@ import { |
| 40 | 40 | codexArtifactTemplatePromptToAppend, |
| 41 | 41 | shouldDockDraftHeroForSubmission, |
| 42 | 42 | shouldReleaseTimelineAnchorForToolActivity, |
| 43 | shouldOpenProactivePullRequest, | |
| 44 | shouldOpenProactiveTurnDiff, | |
| 43 | 45 | shouldShowBranchMismatchBanner, |
| 44 | 46 | shouldShowPlanFollowUpPrompt, |
| 45 | 47 | shouldWriteThreadErrorToCurrentServerThread, |
| ... | ... | @@ -83,6 +85,51 @@ describe("agent browser close confirmation", () => { |
| 83 | 85 | }); |
| 84 | 86 | }); |
| 85 | 87 | |
| 88 | describe("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 | ||
| 86 | 133 | describe("isVideoPreviewRequestCurrent", () => { |
| 87 | 134 | it("rejects changed threads and replaced previews", () => { |
| 88 | 135 | 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( |
| 78 | 78 | ].join("\n"); |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | export function shouldOpenProactivePullRequest( | |
| 82 | previousTargetKey: string | null | undefined, | |
| 83 | targetKey: string | null, | |
| 84 | ): boolean { | |
| 85 | return previousTargetKey !== undefined && targetKey !== null && targetKey !== previousTargetKey; | |
| 86 | } | |
| 87 | ||
| 88 | export 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 | ||
| 81 | 103 | export function codexArtifactTemplatePromptToAppend( |
| 82 | 104 | currentDraft: string, |
| 83 | 105 | template: CodexArtifactTemplate, |
apps/web/src/components/ChatView.tsx+124-3| ... | ... | @@ -361,6 +361,8 @@ import { |
| 361 | 361 | shouldReleaseTimelineAnchorForToolActivity, |
| 362 | 362 | shouldShowBranchMismatchBanner, |
| 363 | 363 | shouldShowPlanFollowUpPrompt, |
| 364 | shouldOpenProactivePullRequest, | |
| 365 | shouldOpenProactiveTurnDiff, | |
| 364 | 366 | getStartedThreadModelChangeBlockReason, |
| 365 | 367 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| 366 | 368 | LastInvokedScriptByProjectSchema, |
| ... | ... | @@ -1766,6 +1768,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 1766 | 1768 | [activeThreadEnvironmentId, activeThreadId], |
| 1767 | 1769 | ); |
| 1768 | 1770 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1771 | const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); | |
| 1769 | 1772 | const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); |
| 1770 | 1773 | const [timelineAnchor, setTimelineAnchor] = useState<{ |
| 1771 | 1774 | readonly threadKey: string | null; |
| ... | ... | @@ -3665,7 +3668,16 @@ function ChatViewContent(props: ChatViewProps) { |
| 3665 | 3668 | ); |
| 3666 | 3669 | // The thread's own change request, placed against the project it belongs to. Without a |
| 3667 | 3670 | // 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; | |
| 3669 | 3681 | const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; |
| 3670 | 3682 | const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository; |
| 3671 | 3683 | const openThreadPullRequest = useCallback( |
| ... | ... | @@ -3708,6 +3720,117 @@ function ChatViewContent(props: ChatViewProps) { |
| 3708 | 3720 | }, |
| 3709 | 3721 | [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests], |
| 3710 | 3722 | ); |
| 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 | ]); | |
| 3711 | 3834 | const togglePreviewPanel = useCallback(() => { |
| 3712 | 3835 | if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; |
| 3713 | 3836 | if (previewPanelOpen) { |
| ... | ... | @@ -4612,8 +4735,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 4612 | 4735 | : null, |
| 4613 | 4736 | [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], |
| 4614 | 4737 | ); |
| 4615 | // The server-projected settled state keeps the banner and sidebar in sync. | |
| 4616 | const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); | |
| 4617 | 4738 | const activeComposerTasksProgress = useMemo(() => { |
| 4618 | 4739 | if (!activeLatestTurn || latestTurnSettled || activePlan?.turnId !== activeLatestTurn.turnId) { |
| 4619 | 4740 | return null; |
apps/web/src/components/settings/SettingsPanels.tsx+31| ... | ... | @@ -525,6 +525,9 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 525 | 525 | ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace |
| 526 | 526 | ? ["Diff whitespace changes"] |
| 527 | 527 | : []), |
| 528 | ...(settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled | |
| 529 | ? ["Proactive panels"] | |
| 530 | : []), | |
| 528 | 531 | ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu |
| 529 | 532 | ? ["Show skills in slash menu"] |
| 530 | 533 | : []), |
| ... | ... | @@ -588,6 +591,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 588 | 591 | settings.defaultThreadEnvMode, |
| 589 | 592 | settings.newWorktreesStartFromOrigin, |
| 590 | 593 | settings.diffIgnoreWhitespace, |
| 594 | settings.proactivePanelsEnabled, | |
| 591 | 595 | settings.environmentIdentificationMode, |
| 592 | 596 | settings.contextWindowMeterEnabled, |
| 593 | 597 | settings.fontFamilyCode, |
| ... | ... | @@ -683,6 +687,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 683 | 687 | timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, |
| 684 | 688 | wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, |
| 685 | 689 | diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, |
| 690 | proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, | |
| 686 | 691 | showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, |
| 687 | 692 | contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, |
| 688 | 693 | environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, |
| ... | ... | @@ -2206,6 +2211,32 @@ export function GeneralSettingsPanel() { |
| 2206 | 2211 | } |
| 2207 | 2212 | /> |
| 2208 | 2213 | |
| 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 | ||
| 2209 | 2240 | <SettingsRow |
| 2210 | 2241 | {...searchableSetting("skills-in-slash-menu")} |
| 2211 | 2242 | 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 = [ |
| 184 | 184 | to: "/settings/general", |
| 185 | 185 | searchTerms: ["diff ignore spaces edits default"], |
| 186 | 186 | }, |
| 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 | }, | |
| 187 | 193 | { |
| 188 | 194 | id: "skills-in-slash-menu", |
| 189 | 195 | 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: |
| 46 | 46 | time, or change size |
| 47 | 47 | - While working in a thread, open linked reviews in the same compact right-panel tabs without |
| 48 | 48 | 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 | |
| 49 | 51 | - Open the review directly in your browser with one click |
| 50 | 52 | - If T3 Code cannot load a GitHub pull request, including when GitHub rate limits requests, use |
| 51 | 53 | **Open on GitHub** in the error view |
packages/contracts/src/settings.test.ts+9| ... | ... | @@ -67,6 +67,15 @@ describe("ClientSettings word wrap", () => { |
| 67 | 67 | }); |
| 68 | 68 | }); |
| 69 | 69 | |
| 70 | describe("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 | ||
| 70 | 79 | describe("ClientSettings quit confirmation", () => { |
| 71 | 80 | it("defaults to hold", () => { |
| 72 | 81 | expect(decodeClientSettings({}).confirmQuit).toBe("hold"); |
packages/contracts/src/settings.ts+2| ... | ... | @@ -299,6 +299,7 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 299 | 299 | // Legacy context window meter. The composer hides it by default; users who |
| 300 | 300 | // still want the old usage indicator can restore it from Settings. |
| 301 | 301 | contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 302 | proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | |
| 302 | 303 | showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 303 | 304 | // Legacy sidebar (the original per-project tree). Deliberately a fresh key |
| 304 | 305 | // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the |
| ... | ... | @@ -1029,6 +1030,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 1029 | 1030 | ), |
| 1030 | 1031 | planModeEnabled: Schema.optionalKey(Schema.Boolean), |
| 1031 | 1032 | contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), |
| 1033 | proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), | |
| 1032 | 1034 | showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), |
| 1033 | 1035 | legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), |
| 1034 | 1036 | sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), |